feat: LAB_CA_ENABLE opt-in lab mint via lab-test-ca.sh (prod refused)

POST /enroll/csr returns 201 + X-Lab-CA when lab-pki ready; default 501.
This commit is contained in:
George Lambert 2026-10-14 01:08:21 -04:00
commit ca117eb458
7 changed files with 504 additions and 97 deletions

View file

@ -1,11 +1,15 @@
# Device certificate enrollment (mTLS) # Device certificate enrollment (mTLS)
staff-iam **does not mint, renew, or verify** device certificates. There is **no fake PKI** in this service: no PKCS#12 download, no local cert crypto. `POST /enroll/csr` exists only as an honest **501** scaffold (`error: "not_implemented"`) — it never signs or returns a cert. TLS client-auth terminates at the reverse proxy (nginx, Caddy, Envoy, etc.). This service only: staff-iam **does not ship production device-cert mint**. Default `POST /enroll/csr` is an honest **501** scaffold (`error: "not_implemented"`). There is **no production CA** in this service.
**LAB ONLY exception (never production):** when `LAB_CA_ENABLE=1` **and** a lab PKI directory exists (`lab-pki/` or `LAB_PKI_DIR`, initialized via [`scripts/lab-test-ca.sh`](./scripts/lab-test-ca.sh) `init`), `POST /enroll/csr` may shell-out to `lab-test-ca.sh sign` and return **201** with leaf cert PEM plus response headers `X-Lab-CA: 1` and `X-Lab-CA-Warning: NOT-FOR-PRODUCTION`. `NODE_ENV=production` **always refuses** this path even if the flag is set. Do **not** trust lab roots on real staff devices or production proxies.
TLS client-auth for real operators still terminates at the reverse proxy (nginx, Caddy, Envoy, etc.). This service:
1. Stores an enrollment flag per user (`mtlsEnrolled`, set by IAM admin via `PUT /users/:username`). 1. Stores an enrollment flag per user (`mtlsEnrolled`, set by IAM admin via `PUT /users/:username`).
2. Reads **proxy-forwarded** headers to decide if a client cert was present on the request. 2. Reads **proxy-forwarded** headers to decide if a client cert was present on the request.
3. Optionally enforces that pair at login when `STAFF_MTLS_ENFORCE=1`. 3. Optionally enforces that pair at login when `STAFF_MTLS_ENFORCE=1`.
4. Publishes `GET /enroll/csr/schema` (fields only) and reserves `POST /enroll/csr` as 501-only until live PKI ships ([PKI-MINT-BACKLOG.md](./docs/PKI-MINT-BACKLOG.md)). 4. Publishes `GET /enroll/csr/schema` (fields only) and keeps default `POST /enroll/csr` as **501** until live production PKI ships ([PKI-MINT-BACKLOG.md](./docs/PKI-MINT-BACKLOG.md)).
TOTP is orthogonal — see [TOTP.md](./TOTP.md) for `STAFF_TOTP_REQUIRED` and the authenticator enroll path. TOTP is orthogonal — see [TOTP.md](./TOTP.md) for `STAFF_TOTP_REQUIRED` and the authenticator enroll path.
@ -34,15 +38,17 @@ Content-Type: application/json
Auth optional. Allowed under the TOTP enrollment allow-list. Never returns cert or key material. Auth optional. Allowed under the TOTP enrollment allow-list. Never returns cert or key material.
## `POST /enroll/csr` (scaffold — 501 + PEM shape gate) ## `POST /enroll/csr` (default 501 + PEM shape gate; optional lab 201)
Reserved for a future live mint. **Never mints** a cert. **Default (production path):** scaffold only — **never** a production mint.
| Body `csr` | HTTP | `error` | | Body `csr` | `LAB_CA_ENABLE` + lab-pki | HTTP | Result |
|------------|------|---------| |------------|---------------------------|------|--------|
| Missing / empty body | **501** | `not_implemented` | | Missing / empty body | any | **501** | `not_implemented` |
| Valid-looking PEM armor (`BEGIN`/`END CERTIFICATE REQUEST` + non-empty middle) | **501** | `not_implemented` | | Invalid PEM shape | any | **400** | `invalid_csr_pem` |
| Present but invalid shape (wrong armor, empty middle, private key, non-string) | **400** | `invalid_csr_pem` | | Valid-looking PEM | **off** (default) or lab-pki missing | **501** | `not_implemented` |
| Valid-looking PEM | **on** + lab-pki ready + not production | **201** | `certificate` PEM + `X-Lab-CA: 1` (**LAB ONLY**) |
| Valid-looking PEM | on + lab-pki ready but sign fails | **502** | `lab_ca_sign_failed` + `X-Lab-CA: 1` |
```http ```http
HTTP/1.1 501 Not Implemented HTTP/1.1 501 Not Implemented
@ -51,7 +57,17 @@ Content-Type: application/json
{ "error": "not_implemented", "nextSteps": [ /* proxy/PKI path + schema pointer */ ] } { "error": "not_implemented", "nextSteps": [ /* proxy/PKI path + schema pointer */ ] }
``` ```
Shape check is **headers only** (no ASN.1, no sign). Every attempt writes audit `csr_attempt` with outcome/pemShape flags — **PEM text is never stored**. No cert, PKCS#12, or key is ever returned. Details: [PKI-MINT-BACKLOG.md](./docs/PKI-MINT-BACKLOG.md). ### Lab-only 201 (NEVER production)
```sh
# once per lab machine
./scripts/lab-test-ca.sh init --dir ./lab-pki
LAB_CA_ENABLE=1 npm start
# POST JSON { "csr": "-----BEGIN CERTIFICATE REQUEST-----..." }
# → 201 + X-Lab-CA: 1 + { ok, lab:true, warning, certificate, chain? }
```
`NODE_ENV=production` refuses lab mint even when `LAB_CA_ENABLE=1`. Shape check is **headers only** before lab sign (openssl does real parse on sign). Every attempt writes audit `csr_attempt` with outcome/pemShape/labCa flags — **CSR PEM text is never stored**. Details: [TEST-CA-DESIGN.md](./docs/TEST-CA-DESIGN.md), [PKI-MINT-BACKLOG.md](./docs/PKI-MINT-BACKLOG.md).
## `GET /enroll/status` ## `GET /enroll/status`
@ -70,7 +86,8 @@ Unauthenticated-friendly status for **login banners** and **My security / admin*
| `renew` | string | Copy only: how to renew (proxy only) | | `renew` | string | Copy only: how to renew (proxy only) |
| `nextSteps` | `string[]` | Actionable checklist for this `state` (admin/login UX; **no PKI mint**) | | `nextSteps` | `string[]` | Actionable checklist for this `state` (admin/login UX; **no PKI mint**) |
| `csrSchemaUrl` | string | **Always** `"/enroll/csr/schema"` — path to future CSR body field map (GET → 200, no mint) | | `csrSchemaUrl` | string | **Always** `"/enroll/csr/schema"` — path to future CSR body field map (GET → 200, no mint) |
| `csrPostStatus` | number | **Always** `501` — `POST /enroll/csr` is scaffold-only and never mints | | `csrPostStatus` | number | **`501`** by default (scaffold). **`201`** only when `LAB_CA_ENABLE=1` **and** lab-pki is ready (lab path may mint; still not production) |
| `labCa` | object | `{ enabled, ready, warning }` — LAB ONLY probe; `enabled` false under `NODE_ENV=production` |
| `mtlsEnrolled` | boolean | User flag (`false` if not signed in). Deprecated alias of enrollment; prefer `state` | | `mtlsEnrolled` | boolean | User flag (`false` if not signed in). Deprecated alias of enrollment; prefer `state` |
| `mtlsPresent` | boolean | Proxy reported a client cert on **this** request | | `mtlsPresent` | boolean | Proxy reported a client cert on **this** request |
| `mtlsEnforce` | boolean | `STAFF_MTLS_ENFORCE` is on | | `mtlsEnforce` | boolean | `STAFF_MTLS_ENFORCE` is on |
@ -210,6 +227,8 @@ staff-iam **never** parses the PEM, checks expiry, or matches CN to username. Bi
|----------|--------| |----------|--------|
| `STAFF_MTLS_ENFORCE=1` | Login requires proxy cert headers when `user.mtlsEnrolled` | | `STAFF_MTLS_ENFORCE=1` | Login requires proxy cert headers when `user.mtlsEnrolled` |
| `STAFF_TOTP_REQUIRED=1` | Orthogonal TOTP enroll gate — [TOTP.md](./TOTP.md) | | `STAFF_TOTP_REQUIRED=1` | Orthogonal TOTP enroll gate — [TOTP.md](./TOTP.md) |
| `LAB_CA_ENABLE=1` | **LAB ONLY.** Allows `POST /enroll/csr` to shell-out to `scripts/lab-test-ca.sh sign` when lab-pki exists → **201** + `X-Lab-CA: 1`. **Default off → 501.** Refused when `NODE_ENV=production`. **Never production.** |
| `LAB_PKI_DIR` | Optional path to lab CA dir (default `./lab-pki` under the service root). Must contain `lab-root.key.pem` + `lab-root.crt.pem` from `lab-test-ca.sh init`. |
## Related docs ## Related docs

View file

@ -38,6 +38,9 @@ npm start
# optional gates: # optional gates:
# STAFF_TOTP_REQUIRED=1 npm start # STAFF_TOTP_REQUIRED=1 npm start
# STAFF_MTLS_ENFORCE=1 npm start # STAFF_MTLS_ENFORCE=1 npm start
# LAB ONLY (never production): init lab-pki then:
# ./scripts/lab-test-ca.sh init --dir ./lab-pki
# LAB_CA_ENABLE=1 npm start # POST /enroll/csr → 201 + X-Lab-CA:1
npm test npm test
``` ```
@ -47,7 +50,9 @@ Open `http://127.0.0.1:3028/login`. Persist store with `STAFF_IAM_PATH`. Login i
| Doc | Topic | | Doc | Topic |
|-----|--------| |-----|--------|
| [ENROLL.md](./ENROLL.md) | Device cert (mTLS): `GET /enroll/status` + `GET /enroll/csr/schema` (fields only); `POST /enroll/csr` → 501; **proxy headers only — no fake PKI** | | [ENROLL.md](./ENROLL.md) | Device cert (mTLS): status + CSR schema; default `POST /enroll/csr` → **501**; opt-in **LAB ONLY** `LAB_CA_ENABLE=1` → 201 + `X-Lab-CA:1` (never production) |
| [docs/TEST-CA-DESIGN.md](./docs/TEST-CA-DESIGN.md) | Lab CA script + gated HTTP path; production mint still backlog |
| [docs/PKI-MINT-BACKLOG.md](./docs/PKI-MINT-BACKLOG.md) | Live production PKI checklist |
| [TOTP.md](./TOTP.md) | Authenticator 2FA + **`STAFF_TOTP_REQUIRED`** enroll path; how login / app UI use flags | | [TOTP.md](./TOTP.md) | Authenticator 2FA + **`STAFF_TOTP_REQUIRED`** enroll path; how login / app UI use flags |
| [NATS.md](./NATS.md) | NATS notes (if used) | | [NATS.md](./NATS.md) | NATS notes (if used) |
| [SUMMARY.md](./SUMMARY.md) | One-line service summary | | [SUMMARY.md](./SUMMARY.md) | One-line service summary |
@ -70,7 +75,7 @@ Override with `IAM_OWNER_PASSWORD`, `IAM_CS_PASSWORD`, etc.
|------|----------| |------|----------|
| **TOTP** | Optional per user by default. `POST /2fa/setup` → `enable {code}` while signed in. When `totpEnabled`, login returns `401 {error:"totp_required"}` until a valid 6-digit `totpCode` is sent. UI: **My security** + setup QR/secret. Full ops: [TOTP.md](./TOTP.md). | | **TOTP** | Optional per user by default. `POST /2fa/setup` → `enable {code}` while signed in. When `totpEnabled`, login returns `401 {error:"totp_required"}` until a valid 6-digit `totpCode` is sent. UI: **My security** + setup QR/secret. Full ops: [TOTP.md](./TOTP.md). |
| **TOTP required** | `STAFF_TOTP_REQUIRED=1` — seeds stay `totpEnabled: false`; after password login full app access is `403 {error:"totp_enrollment_required"}` until `/2fa/setup` + `/2fa/enable`. `/me`, `/2fa/*`, `/logout`, `/enroll/status` still work. Disable refused while policy is on. | | **TOTP required** | `STAFF_TOTP_REQUIRED=1` — seeds stay `totpEnabled: false`; after password login full app access is `403 {error:"totp_enrollment_required"}` until `/2fa/setup` + `/2fa/enable`. `/me`, `/2fa/*`, `/logout`, `/enroll/status` still work. Disable refused while policy is on. |
| **mTLS** | Copy + status only. `GET /enroll/status` returns `updatedAt`, `state` (`not_enrolled` \| `pending_proxy` \| `enrolled` \| `enforce_on`), `nextSteps[]`, **always** `csrSchemaUrl: "/enroll/csr/schema"` + `csrPostStatus: 501`, `proxyHeadersSeen` (`verify`/`verifyOk`/`dn`/`pem`/`any`), and `proxy` probe. `GET /enroll/csr/schema` = future body fields only; `POST /enroll/csr` → 501. Enforce only with `STAFF_MTLS_ENFORCE=1` **and** `mtlsEnrolled: true`. **No device-cert mint** — proxy headers only. [ENROLL.md](./ENROLL.md). | | **mTLS** | Copy + status + optional **lab** mint. `GET /enroll/status` returns `state`, `nextSteps[]`, `csrSchemaUrl`, `csrPostStatus` (**501** default; **201** when lab CA ready), `labCa` probe, `proxyHeadersSeen`, `proxy`. `GET /enroll/csr/schema` = fields only. Default `POST /enroll/csr` → **501**. **LAB ONLY:** `LAB_CA_ENABLE=1` + lab-pki → shell-out `lab-test-ca.sh` → **201** + `X-Lab-CA: 1` (refused when `NODE_ENV=production`). Enforce with `STAFF_MTLS_ENFORCE=1` **and** `mtlsEnrolled: true`. Production mint still external PKI. [ENROLL.md](./ENROLL.md). |
| **Session** | Cookie / Bearer `staff_session` only. Never issues customer `xVerae-` tokens. | | **Session** | Cookie / Bearer `staff_session` only. Never issues customer `xVerae-` tokens. |
## Wire other doors ## Wire other doors
@ -133,7 +138,7 @@ Signed-in **Security** tab covers:
- Change password (`/change-password`) - Change password (`/change-password`)
- Authenticator enrollment (`/enroll-2fa`, `/2fa/*`) — required when `STAFF_TOTP_REQUIRED=1` - Authenticator enrollment (`/enroll-2fa`, `/2fa/*`) — required when `STAFF_TOTP_REQUIRED=1`
- Browser client certificate (mTLS) status from `GET /enroll/status` (`state`, `nextSteps`, `updatedAt`, `proxyHeadersSeen` chips) — **proxy/PKI only; no mint in this app** - Browser client certificate (mTLS) status from `GET /enroll/status` (`state`, `nextSteps`, `updatedAt`, `proxyHeadersSeen`, `labCa` chips) — **default no mint**; lab opt-in only via `LAB_CA_ENABLE=1`
## Contributing ## Contributing

View file

@ -2,7 +2,7 @@
**Status: not shipped.** Live device-certificate **mint** and **renew** are **out of scope** for `verae-staff-iam` today. This service does not run a CA, sign CSRs, issue PKCS#12/PEM client certs, renew expiry, or perform any local cert crypto. There is **no fake PKI**. **Status: not shipped.** Live device-certificate **mint** and **renew** are **out of scope** for `verae-staff-iam` today. This service does not run a CA, sign CSRs, issue PKCS#12/PEM client certs, renew expiry, or perform any local cert crypto. There is **no fake PKI**.
What *is* shipped is **proxy-only enroll/status**, a **CSR field schema** (`GET /enroll/csr/schema`), plus an **honest CSR scaffold** that always refuses: IAM stores an admin flag, reads reverse-proxy mTLS headers, optionally enforces that pair at login, and exposes `POST /enroll/csr` as **501 only**. Certificate lifecycle stays at the edge PKI / reverse proxy. What *is* shipped is **proxy-only enroll/status**, a **CSR field schema** (`GET /enroll/csr/schema`), an **honest CSR scaffold** that **defaults to 501**, plus an **opt-in LAB ONLY** path (`LAB_CA_ENABLE=1` + lab-pki → shell-out [`lab-test-ca.sh`](../scripts/lab-test-ca.sh) → **201** + `X-Lab-CA: 1`). Production mint is still not shipped. Certificate lifecycle for real operators stays at the edge PKI / reverse proxy.
Canonical behavior and header contract: [ENROLL.md](../ENROLL.md). Service overview and security gates: [README.md](../README.md). Canonical behavior and header contract: [ENROLL.md](../ENROLL.md). Service overview and security gates: [README.md](../README.md).
@ -10,9 +10,9 @@ Canonical behavior and header contract: [ENROLL.md](../ENROLL.md). Service overv
| Surface | What it does | What it does not do | | Surface | What it does | What it does not do |
|---------|----------------|---------------------| |---------|----------------|---------------------|
| `GET /enroll/status` | Returns `state`, `stateLabel`, `note`, `updatedAt`, `nextSteps[]`, **always** `csrSchemaUrl: "/enroll/csr/schema"` + `csrPostStatus: 501`, `proxyHeadersSeen`, `proxy` probe, `mtlsEnrolled` / `mtlsPresent` / `mtlsEnforce`, plus orthogonal TOTP flags | No cert body, no PEM parse, no expiry check, no CN↔user bind | | `GET /enroll/status` | Returns `state`, `stateLabel`, `note`, `updatedAt`, `nextSteps[]`, `csrSchemaUrl`, `csrPostStatus` (**501** default; **201** when lab CA ready), `labCa` probe, `proxyHeadersSeen`, `proxy`, mTLS + TOTP flags | No production cert body; lab warning only when lab enabled |
| `GET /enroll/csr/schema` | **200** JSON: future `POST /enroll/csr` body **fields only** (`csr`, `deviceId`, `commonName`, `sans`); `mint: false`, `status: "schema_only"` | No sample CSR, no PEM/PKCS#12, no mint | | `GET /enroll/csr/schema` | **200** JSON: future `POST /enroll/csr` body **fields only** (`csr`, `deviceId`, `commonName`, `sans`); `mint: false`, `status: "schema_only"` | No sample CSR, no PEM/PKCS#12, no mint |
| `POST /enroll/csr` | **Scaffold + PEM shape gate:** missing/`csr` valid-looking PEM → `501` `{ error: "not_implemented" }`; invalid PEM armor → `400` `{ error: "invalid_csr_pem" }`; audit `csr_attempt` (no PEM stored) | Does **not** ASN.1-parse, mint, sign, or return PEM/PKCS#12 | | `POST /enroll/csr` | **Default 501** scaffold + PEM shape gate; **lab opt-in** `LAB_CA_ENABLE=1` + lab-pki → shell-out sign → **201** + `X-Lab-CA: 1` (never production / refused when `NODE_ENV=production`); invalid PEM → `400`; audit `csr_attempt` (no CSR PEM stored) | No production CA; lab path is explicit and headed |
| `mint` / `renew` fields on status | **Copy only** — tell operators mint/renew happen at proxy/PKI | Not crypto | | `mint` / `renew` fields on status | **Copy only** — tell operators mint/renew happen at proxy/PKI | Not crypto |
| `not_enrolled` `nextSteps` | Mentions the CSR scaffold (`501`) so UIs do not expect in-app mint | Still directs operators to external PKI | | `not_enrolled` `nextSteps` | Mentions the CSR scaffold (`501`) so UIs do not expect in-app mint | Still directs operators to external PKI |
| `PUT /users/:username` `{ mtlsEnrolled }` | Admin enrollment flag in the IAM store | Does not issue or revoke a cert | | `PUT /users/:username` `{ mtlsEnrolled }` | Admin enrollment flag in the IAM store | Does not issue or revoke a cert |
@ -33,7 +33,7 @@ Fields-only contract for a **future** mint body. **Does not mint.**
### CSR scaffold (`POST /enroll/csr`) ### CSR scaffold (`POST /enroll/csr`)
Reserved path for a future live mint. **Today it must never succeed (no mint).** Reserved path for production live mint. **Default must not succeed (501).** Lab opt-in may return 201 with `X-Lab-CA: 1` only.
- Method/path: `POST /enroll/csr` - Method/path: `POST /enroll/csr`
- **PEM header-shape gate (TODO-7):** if JSON body includes a non-empty `csr` value: - **PEM header-shape gate (TODO-7):** if JSON body includes a non-empty `csr` value:
@ -58,11 +58,11 @@ Reserved path for a future live mint. **Today it must never succeed (no mint).**
``` ```
- No CSR ASN.1 parse, no sign, no storage of PEM body, no cert material returned. - No CSR ASN.1 parse, no sign, no storage of PEM body, no cert material returned.
- No `certificate`, `pem`, `cert`, `pkcs12`, or key fields in the response — ever — while this remains a scaffold. - Default scaffold responses never include `certificate` / `pem` / `pkcs12`. Lab 201 includes `certificate` (+ optional `chain`) with `lab: true` and warning text only.
- Auth optional; same status rules whether anonymous or signed-in. - Auth optional; same status rules whether anonymous or signed-in.
- Allowed under the TOTP enrollment allow-list so a pending-2FA session can still discover the 501 / shape gate. - Allowed under the TOTP enrollment allow-list so a pending-2FA session can still discover the 501 / shape gate.
When live mint ships, replace this handler with real CA integration (see “Left for live PKI” below). Until then, do not soften the 501 or return demo material. When production mint ships, replace the default 501 with real CA integration (see “Left for live PKI” below). Until then, do not soften the **default** 501 or return demo material without the lab gate + `X-Lab-CA` headers.
### TODO-7 slice (MULTI-AGENT-TODO-7 A4) ### TODO-7 slice (MULTI-AGENT-TODO-7 A4)
@ -75,23 +75,23 @@ Shipped this pass (still **no mint**):
Still left for live PKI: full checklist under “Left for live PKI” below. Still left for live PKI: full checklist under “Left for live PKI” below.
### Lab offline CA script (P0 Issue 4 advance — still **no HTTP mint**) ### Lab CA script + opt-in HTTP (P0 Issue 4 — still **no production mint**)
Shipped for **lab/dev disk fixtures only**: Shipped for **lab/dev only** (loud warnings; never production):
- [x] [`scripts/lab-test-ca.sh`](../scripts/lab-test-ca.sh) — OpenSSL self-signed lab root (`init`) + sign CSR → leaf PEM/chain (`sign`); loud **NOT FOR PRODUCTION** warnings; default output `lab-pki/` (gitignored) - [x] [`scripts/lab-test-ca.sh`](../scripts/lab-test-ca.sh) — OpenSSL self-signed lab root (`init`) + sign CSR → leaf PEM/chain (`sign`); loud **NOT FOR PRODUCTION** warnings; default output `lab-pki/` (gitignored)
- [x] **Not** wired into `POST /enroll/csr`, server boot, or any default env flag — HTTP path stays **501** - [x] **Opt-in HTTP:** `LAB_CA_ENABLE=1` + lab-pki ready → `POST /enroll/csr` shells out to `sign` → **201** + `certificate` PEM + `X-Lab-CA: 1`
- [x] Documented relationship to future live mint in this file + [TEST-CA-DESIGN.md](./TEST-CA-DESIGN.md) - [x] **Default off** → HTTP path stays **501**; `NODE_ENV=production` refuses lab path
- [x] Documented in [ENROLL.md](../ENROLL.md) + [TEST-CA-DESIGN.md](./TEST-CA-DESIGN.md)
**How the lab script relates to future live mint** | Layer | Lab path | Production mint (backlog below) |
|-------|----------|----------------------------------|
| Default today | Flag off → **501** | Not shipped |
| Lab opt-in | `LAB_CA_ENABLE=1` + pki → **201** + `X-Lab-CA: 1` | Must not use lab CA |
| When live mint ships | Keep as CI/dev fixture | Real `Signer` (HSM/EST/ACME/delegated) |
| Never | Default-on or production `NODE_ENV` | — |
| Layer | Role of `lab-test-ca.sh` | Live mint (backlog below) | Operators continue to issue workstation certs at external reverse-proxy / internal PKI.
|-------|--------------------------|---------------------------|
| Today | Manual offline OpenSSL CA for local experiments / future fixture prep | Not shipped; CSR route is 501 |
| When live mint ships | May generate throwaway leaves for **dev proxy** trust tests and unit fixtures | Production `Signer` (HSM/EST/ACME/delegated) issues real certs via AuthZ’d `POST /enroll/csr` |
| Never | Become the default server mint path, production trust anchor, or silent 200 on `/enroll/csr` | Must not call the lab script from production `NODE_ENV` |
Operators continue to issue workstation certs at external reverse-proxy / internal PKI. Developers who need a PEM on disk run the script themselves; they still must not expect staff-iam HTTP to return cert material.
### Operator path (current) ### Operator path (current)
@ -133,20 +133,20 @@ Checklist for a real in-app mint (product + security design required before codi
- [ ] Remove 501 scaffold; keep “no fake crypto” rule for tests (fixtures may use offline [`scripts/lab-test-ca.sh`](../scripts/lab-test-ca.sh) or a future gated test CA — see [TEST-CA-DESIGN.md](./TEST-CA-DESIGN.md); **never** default-on in server) - [ ] Remove 501 scaffold; keep “no fake crypto” rule for tests (fixtures may use offline [`scripts/lab-test-ca.sh`](../scripts/lab-test-ca.sh) or a future gated test CA — see [TEST-CA-DESIGN.md](./TEST-CA-DESIGN.md); **never** default-on in server)
- [ ] Operator runbooks + ENROLL.md / README updated for the live path - [ ] Operator runbooks + ENROLL.md / README updated for the live path
If product later needs in-app mint/renew, that is a **new** design (real CA integration or delegated signer), not an extension of the current status strings or a softened 501. Until then, keep mint/renew copy pointing at the proxy/PKI, keep `/enroll/status` read-only with respect to crypto, keep `GET /enroll/csr/schema` fields-only, and keep `POST /enroll/csr` as 501-only. If product later needs in-app production mint/renew, that is a **new** design (real CA integration or delegated signer), not the lab CA path. Until then, keep operator mint/renew copy pointing at the proxy/PKI, keep default `POST /enroll/csr` as 501, and keep lab mint behind `LAB_CA_ENABLE` + `X-Lab-CA: 1`.
## Hard rules ## Hard rules
1. **No fake crypto** — do not add endpoints, fixtures, or UI that return made-up certs, keys, or signed CSRs over HTTP. The CSR scaffold’s only success criteria is an honest **501**. Offline lab PEMs from `scripts/lab-test-ca.sh` stay on disk and must not be served as mint responses. 1. **No fake production crypto** — default `POST /enroll/csr` stays honest **501**. Lab **201** is allowed only with `LAB_CA_ENABLE=1`, real openssl sign via `lab-test-ca.sh`, and `X-Lab-CA: 1` headers — never silent success, never under `NODE_ENV=production`.
2. **Proxy is source of truth** for “cert present on this request”; IAM flag is source of truth for “user is enrolled for enforce.” 2. **Proxy is source of truth** for “cert present on this request”; IAM flag is source of truth for “user is enrolled for enforce.”
3. Do not turn on `STAFF_MTLS_ENFORCE` until the proxy actually verifies clients (see [ENROLL.md](../ENROLL.md) “Do not”). 3. Do not turn on `STAFF_MTLS_ENFORCE` until the proxy actually verifies clients (see [ENROLL.md](../ENROLL.md) “Do not”).
4. **Never enable lab CA in the default server path** — no import of `lab-test-ca.sh`, no boot-time sign, no implicit trust of `lab-pki/`. 4. **Never enable lab CA by default** — no boot-time sign, no production image with `LAB_CA_ENABLE=1`.
5. TOTP remains orthogonal ([TOTP.md](../TOTP.md)); authenticator enroll is real crypto (RFC 6238) and is unrelated to device-cert mint. 5. TOTP remains orthogonal ([TOTP.md](../TOTP.md)); authenticator enroll is real crypto (RFC 6238) and is unrelated to device-cert mint.
## Related docs ## Related docs
- [TEST-CA-DESIGN.md](./TEST-CA-DESIGN.md) — lab script (offline) + **future** gated test CA design; no HTTP fake mint - [TEST-CA-DESIGN.md](./TEST-CA-DESIGN.md) — lab script (offline) + **future** gated test CA design; no HTTP fake mint
- [`scripts/lab-test-ca.sh`](../scripts/lab-test-ca.sh) — OpenSSL lab CA init/sign (NOT production; not wired to `/enroll/csr`) - [`scripts/lab-test-ca.sh`](../scripts/lab-test-ca.sh) — OpenSSL lab CA init/sign (NOT production; optional `LAB_CA_ENABLE=1` HTTP shell-out)
- [ENROLL.md](../ENROLL.md) — mTLS states, `nextSteps`, `proxyHeadersSeen`, proxy header contract, admin workflow - [ENROLL.md](../ENROLL.md) — mTLS states, `nextSteps`, `proxyHeadersSeen`, proxy header contract, admin workflow
- [README.md](../README.md) — install, usage, staff security gates (mTLS row: proxy headers only, no device-cert mint) - [README.md](../README.md) — install, usage, staff security gates (mTLS row: proxy headers only, no device-cert mint)
- [TOTP.md](../TOTP.md) — authenticator 2FA (orthogonal gate) - [TOTP.md](../TOTP.md) — authenticator 2FA (orthogonal gate)

View file

@ -1,28 +1,40 @@
# Test CA design (future only + lab offline script) # Test CA design (lab opt-in HTTP + future production)
**Status: design for in-process/CI test CA — not implemented in the server.** **Status:**
Offline lab helper **shipped:** [`scripts/lab-test-ca.sh`](../scripts/lab-test-ca.sh) (OpenSSL self-signed root + CSR→PEM sign on disk only). - Offline lab helper **shipped:** [`scripts/lab-test-ca.sh`](../scripts/lab-test-ca.sh)
- **Opt-in lab HTTP** **shipped (LAB ONLY):** `LAB_CA_ENABLE=1` + initialized lab-pki → `POST /enroll/csr` may shell-out to `lab-test-ca.sh sign` → **201** cert PEM + headers `X-Lab-CA: 1` / `X-Lab-CA-Warning: NOT-FOR-PRODUCTION`
- **Default** (flag off): `POST /enroll/csr` remains **501**
- **Production mint:** still **not** shipped — never enable lab CA in production (`NODE_ENV=production` refuses it)
`verae-staff-iam` does **not** run a CA, sign CSRs, or return cert material over HTTP today. `POST /enroll/csr` remains **501**. This document describes (1) the **lab script** (manual/offline) and (2) how a **future** gated test CA *could* support automated tests once live mint is approved. It is not a license to add mint endpoints, demo certs, or softened 501 responses now. This document describes (1) the lab script, (2) the gated lab HTTP path, and (3) how a **future** production `Signer` would replace the default 501. **Never production** for lab material.
Live mint product backlog and hard “no fake crypto” rules: [PKI-MINT-BACKLOG.md](./PKI-MINT-BACKLOG.md). Current proxy-only enroll contract: [ENROLL.md](../ENROLL.md). Live mint product backlog and hard “no fake crypto” rules: [PKI-MINT-BACKLOG.md](./PKI-MINT-BACKLOG.md). Current proxy-only enroll contract: [ENROLL.md](../ENROLL.md).
## Lab script (shipped — offline only) ## Lab script + opt-in HTTP (LAB ONLY — never production)
| Item | Detail | | Item | Detail |
|------|--------| |------|--------|
| Path | [`scripts/lab-test-ca.sh`](../scripts/lab-test-ca.sh) | | Path | [`scripts/lab-test-ca.sh`](../scripts/lab-test-ca.sh) |
| What it does | `init` → self-signed lab root under `lab-pki/` (gitignored); `sign --csr` → short-lived leaf PEM + chain on disk | | Offline | `init` → self-signed lab root under `lab-pki/` (gitignored); `sign --csr` → short-lived leaf PEM + chain on disk |
| What it does **not** | Call staff-iam HTTP; change `POST /enroll/csr`; set any server env; trust itself in production | | Opt-in HTTP | `LAB_CA_ENABLE=1` + lab-pki ready → staff-iam shells out to `sign` on `POST /enroll/csr` → **201** + `X-Lab-CA: 1` |
| Warnings | Prints a loud **LAB ONLY / NOT FOR PRODUCTION** banner on every run | | Default | Flag **off** → `POST /enroll/csr` **501** (unchanged production scaffold) |
| Relation to live mint | Fixture toolchain only. When product ships a real `Signer` + live `POST /enroll/csr`, this script (or a successor) may feed **dev proxy** trust stores and unit fixtures — it must **never** become the default server mint path | | Production refuse | `NODE_ENV=production` → lab path off even if `LAB_CA_ENABLE=1` |
| Warnings | Script banner + response `warning` field + `X-Lab-CA-Warning: NOT-FOR-PRODUCTION` |
| Relation to live mint | Lab/dev only. Production mint needs a real `Signer` (see backlog) — lab CA must **never** be the default or production path |
```sh ```sh
# example (lab machine only) # example (lab machine only)
./scripts/lab-test-ca.sh init --dir ./lab-pki ./scripts/lab-test-ca.sh init --dir ./lab-pki
openssl req -new -newkey rsa:2048 -nodes -keyout dev.key -out dev.csr -subj "/CN=lab-user@staff.local" openssl req -new -newkey rsa:2048 -nodes -keyout dev.key -out dev.csr -subj "/CN=lab-user@staff.local"
# offline sign still works:
./scripts/lab-test-ca.sh sign --dir ./lab-pki --csr dev.csr --out ./lab-pki/leaf.crt.pem ./scripts/lab-test-ca.sh sign --dir ./lab-pki --csr dev.csr --out ./lab-pki/leaf.crt.pem
# Still: curl -X POST …/enroll/csr → 501 (server untouched) # opt-in HTTP (LAB ONLY):
LAB_CA_ENABLE=1 LAB_PKI_DIR=./lab-pki npm start
curl -sS -D- -X POST http://127.0.0.1:3028/enroll/csr \
-H 'content-type: application/json' \
--data-binary @<(python3 -c 'import json,sys; print(json.dumps({"csr":open("dev.csr").read()}))')
# expect: HTTP 201, X-Lab-CA: 1, body.certificate PEM
# default without LAB_CA_ENABLE → 501
``` ```
Do **not** point production or shared staging reverse proxies at `lab-pki/lab-root.crt.pem`. Do **not** point production or shared staging reverse proxies at `lab-pki/lab-root.crt.pem`.
@ -41,34 +53,37 @@ The test CA is a **fixture toolchain**, not a staff-iam runtime feature and not
| Non-goal | Why | | Non-goal | Why |
|----------|-----| |----------|-----|
| Soften `POST /enroll/csr` to 200 with sample PEM | Violates “no fake crypto”; tests must not teach UIs that mint works | | Return success PEM when `LAB_CA_ENABLE` is **off** | Default must stay honest **501** |
| Ship a self-signed CA inside the production Node process | Production mint must use a real CA model (see backlog) | | Enable lab CA under `NODE_ENV=production` | Hard-refused in server |
| Ship lab-pki private keys in production images | Keys stay gitignored / CI secret only |
| Soften production mint with static demo PEMs | Violates “no fake crypto”; lab path is explicit + headed `X-Lab-CA: 1` only |
| Trust lab roots on real staff fleets | Production mint must use a real CA model (see backlog) |
| Browser “Download test cert” in login / My security | Operator path stays proxy/PKI until live mint is real | | Browser “Download test cert” in login / My security | Operator path stays proxy/PKI until live mint is real |
| Reuse test CA roots in staging that faces real staff | Test roots are untrusted junk; isolate to CI/dev | | Reuse test CA roots in staging that faces real staff | Test roots are untrusted junk; isolate to CI/dev |
| Parse or store private keys in IAM user records | Metadata only (serial, fingerprint, notAfter) if mint ever lands | | Parse or store private keys in IAM user records | Metadata only (serial, fingerprint, notAfter) if mint ever lands |
Until live mint ships, the only honest test of enroll CSR is: schema **200** fields-only, POST **501**, status copy points at proxy. Default honest contract: schema **200** fields-only, POST **501**, status `csrPostStatus: 501`. Lab tests may set `LAB_CA_ENABLE=1` + temp lab-pki and assert **201** + `X-Lab-CA: 1` (skip if no openssl).
## Relationship to live PKI backlog ## Relationship to live PKI backlog
[PKI-MINT-BACKLOG.md](./PKI-MINT-BACKLOG.md) lists what production mint needs (CA model, AuthZ, CSR policy, bind to user, renew, revoke). Lab/test tooling is a **companion**, not a substitute: [PKI-MINT-BACKLOG.md](./PKI-MINT-BACKLOG.md) lists what production mint needs (CA model, AuthZ, CSR policy, bind to user, renew, revoke). Lab/test tooling is a **companion**, not a substitute:
``` ```
Product mint (future) Lab / test CA Product mint (future) Lab CA (today, opt-in)
───────────────────── ────────────────────────── ───────────────────── ──────────────────────────
External/HSM/EST signer ←→ scripts/lab-test-ca.sh (offline NOW) External/HSM/EST signer ←→ scripts/lab-test-ca.sh
POST /enroll/csr succeeds Future: same handler, TEST_CA_* env only POST /enroll/csr succeeds LAB_CA_ENABLE=1 + lab-pki → 201 + X-Lab-CA:1
(never default; never production NODE_ENV) (never default lab) Default off → 501; production NODE_ENV refuses
Proxy trusts corp roots Dev proxy may trust lab-pki root (lab only) Proxy trusts corp roots Dev proxy may trust lab-pki root (lab only)
``` ```
| Stage | Lab script | HTTP `POST /enroll/csr` | | Stage | Lab script | HTTP `POST /enroll/csr` |
|-------|------------|-------------------------| |-------|------------|-------------------------|
| **Today** | Offline init/sign on disk | **501** scaffold (unchanged) | | **Default today** | Offline init/sign on disk | **501** scaffold |
| **Live mint ships** | Still offline fixture / CI helper | Real production `Signer` | | **Lab opt-in today** | Same script, server shell-out | **201** + `X-Lab-CA: 1` when flag + pki ready |
| **Gated test signer (future)** | May share profile defaults with script | Only with `STAFF_TEST_CA=1` and non-production | | **Live production mint** | Still offline fixture / CI helper | Real production `Signer` (drop default 501) |
Checklist items that mention “fixtures may use a test CA only” refer here. **Do not** wire `lab-test-ca.sh` into the default server path or soften 501 because the script exists. **Do not** enable `LAB_CA_ENABLE` by default or in production images.
## Layout ## Layout
@ -102,14 +117,14 @@ platform/verae-staff-iam/
## Trust and environment gates ## Trust and environment gates
| Variable (proposed) | Effect | | Variable | Effect |
|---------------------|--------| |----------|--------|
| `STAFF_TEST_CA=1` | Allows in-process or scripted test signer to run | | `LAB_CA_ENABLE=1` | **Shipped.** Allows shell-out lab sign on `POST /enroll/csr` when lab-pki ready |
| `STAFF_TEST_CA_KEY_DIR` | Path to root/intermediate key material | | `LAB_PKI_DIR` | Lab CA directory (default `./lab-pki`) |
| `NODE_ENV=production` | **Must refuse** test CA even if `STAFF_TEST_CA=1` | | `NODE_ENV=production` | **Refuses** lab CA even if `LAB_CA_ENABLE=1` |
| `STAFF_MTLS_ENFORCE` | Unchanged: still requires real proxy verify headers | | `STAFF_MTLS_ENFORCE` | Unchanged: still requires real proxy verify headers |
Fail closed: missing key dir, production env, or unset flag → same behavior as today (501 / no mint). Fail closed: missing key dir, production env, or unset flag → **501** (no mint).
## Signing flow (future test only) ## Signing flow (future test only)
@ -126,9 +141,10 @@ In-process mint (if chosen) would call the same validation + sign helpers the pr
| Layer | Assertion | | Layer | Assertion |
|-------|-----------| |-------|-----------|
| Contract (today) | `GET /enroll/csr/schema` → 200, `mint: false`; `POST /enroll/csr` → 501; no cert fields | | Contract (default) | `GET /enroll/csr/schema` → 200, `mint: false`; `POST /enroll/csr` → 501; no cert fields |
| Unit (future) | CSR policy rejects bad PEM / wrong key size / disallowed CN | | Lab integration (today) | `LAB_CA_ENABLE=1` + lab-pki + openssl → 201 + `X-Lab-CA: 1` + certificate PEM; skip if no openssl |
| Integration (future) | With `STAFF_TEST_CA=1` + key dir, valid CSR → 201 + chain; serial stored as metadata | | Unit (future production) | CSR policy rejects bad PEM / wrong key size / disallowed CN |
| Integration (future production) | Real `Signer` → 201 without lab headers; serial stored as metadata |
| Proxy (future) | Client presents test cert; proxy headers flip `proxyHeadersSeen.*`; enforce path works | | Proxy (future) | Client presents test cert; proxy headers flip `proxyHeadersSeen.*`; enforce path works |
| Negative | `NODE_ENV=production` + test flag → no sign; anonymous rate limit; revoke on offboard | | Negative | `NODE_ENV=production` + test flag → no sign; anonymous rate limit; revoke on offboard |
@ -147,8 +163,8 @@ Exact algorithms should match whatever production CA model is selected in [PKI-M
## Operator vs developer story ## Operator vs developer story
- **Operators (production):** unchanged — issue workstation certs in corporate PKI; proxy terminates mTLS; IAM flag + enforce. See [ENROLL.md](../ENROLL.md). Never run `lab-test-ca.sh` for real staff devices. - **Operators (production):** unchanged — issue workstation certs in corporate PKI; proxy terminates mTLS; IAM flag + enforce. See [ENROLL.md](../ENROLL.md). Never run `lab-test-ca.sh` for real staff devices.
- **Developers (lab now):** run `scripts/lab-test-ca.sh init|sign`, keep material under gitignored `lab-pki/`, optionally point a *local* dev proxy at `lab-root.crt.pem`. HTTP mint still 501. - **Developers (lab now):** run `scripts/lab-test-ca.sh init`, set `LAB_CA_ENABLE=1`, keep material under gitignored `lab-pki/`, optionally point a *local* dev proxy at `lab-root.crt.pem`. Expect `X-Lab-CA: 1` on 201 — never treat as production.
- **Developers (future mint):** same offline script or gated `STAFF_TEST_CA` signer; never enable lab CA in default server boot. - **Developers (default):** leave `LAB_CA_ENABLE` unset → HTTP mint stays 501.
- **CI (future):** generate or restore test CA into a job-local volume; tear down after; never publish leaves as release artifacts. - **CI (future):** generate or restore test CA into a job-local volume; tear down after; never publish leaves as release artifacts.
## Implementation order (blocked on product) ## Implementation order (blocked on product)
@ -161,11 +177,11 @@ Exact algorithms should match whatever production CA model is selected in [PKI-M
## Hard rules ## Hard rules
1. **Server does not implement mint.** `lab-test-ca.sh` is offline only; no HTTP code path should claim test-CA or lab-CA support until live mint is intentionally scheduled and gated. 1. **Default server path does not mint** — `LAB_CA_ENABLE` off → 501. Lab HTTP is explicit opt-in only.
2. **No fake crypto in production API** — 501 stays until real sign; tests that need certs use the offline lab script or a future gated signer, not a lying success response in default config. 2. **No fake crypto in production API** — production stays 501 until real `Signer`; lab 201 always carries `X-Lab-CA: 1` + warning body.
3. **Lab/test roots never trust-anchor production.** 3. **Lab/test roots never trust-anchor production.**
4. **Never enable lab CA in the default server path** — no boot-time hook, no implicit `LAB_PKI_DIR` read, no `STAFF_TEST_CA` default-on. 4. **Never enable lab CA by default or under `NODE_ENV=production`.**
5. Cross-link changes: any future mint PR must update [PKI-MINT-BACKLOG.md](./PKI-MINT-BACKLOG.md), this doc’s status line, and [ENROLL.md](../ENROLL.md) together. 5. Cross-link changes: production mint PR must update [PKI-MINT-BACKLOG.md](./PKI-MINT-BACKLOG.md), this doc, and [ENROLL.md](../ENROLL.md) together.
## Related docs ## Related docs

View file

@ -1,12 +1,17 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# lab-test-ca.sh — offline OpenSSL lab CA for local/dev experiments ONLY. # lab-test-ca.sh — OpenSSL lab CA for local/dev experiments ONLY.
# #
# Creates a self-signed lab root CA and signs a PKCS#10 CSR → leaf PEM. # Creates a self-signed lab root CA and signs a PKCS#10 CSR → leaf PEM.
# Material stays on disk under a lab directory. This script is NEVER imported # Material stays on disk under a lab directory (default lab-pki/, gitignored).
# by the Node server and is NOT wired into POST /enroll/csr (still 501).
# #
# NOT FOR PRODUCTION. NOT A STAFF-IAM RUNTIME FEATURE. # Optional HTTP path (LAB ONLY): when staff-iam is started with LAB_CA_ENABLE=1
# Do not trust this root on real workstations, staging staff fleets, or prod. # AND this directory has been initialized, POST /enroll/csr may shell-out to
# `sign` and return 201 + cert PEM with header X-Lab-CA: 1.
# Default (LAB_CA_ENABLE unset/off) → POST /enroll/csr stays 501.
# NODE_ENV=production always refuses the lab HTTP path.
#
# NOT FOR PRODUCTION. Do not trust this root on real workstations,
# staging staff fleets, or prod reverse proxies.
# #
# Usage: # Usage:
# scripts/lab-test-ca.sh init [--dir DIR] # scripts/lab-test-ca.sh init [--dir DIR]
@ -27,9 +32,11 @@ warn_banner() {
LAB ONLY — verae-staff-iam scripts/lab-test-ca.sh LAB ONLY — verae-staff-iam scripts/lab-test-ca.sh
Self-signed throwaway CA. NOT for production, staging staff, or Self-signed throwaway CA. NOT for production, staging staff, or
any reverse-proxy trust store that faces real operators. any reverse-proxy trust store that faces real operators.
Does NOT call or enable POST /enroll/csr (remains 501 scaffold). Opt-in HTTP: LAB_CA_ENABLE=1 + init'd lab-pki → POST /enroll/csr may
shell-out here (201 + X-Lab-CA:1). Default off → 501 scaffold.
NODE_ENV=production refuses lab HTTP. NOT a production CA.
Do NOT ship lab-pki/ keys in images or commit private keys. Do NOT ship lab-pki/ keys in images or commit private keys.
Live mint stays on the product backlog (docs/PKI-MINT-BACKLOG.md). Live production mint stays on the product backlog (docs/PKI-MINT-BACKLOG.md).
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
EOF EOF
} }
@ -130,8 +137,9 @@ LAB PKI material — NOT FOR PRODUCTION
Generated by scripts/lab-test-ca.sh on $(date -u +%Y-%m-%dT%H:%M:%SZ) Generated by scripts/lab-test-ca.sh on $(date -u +%Y-%m-%dT%H:%M:%SZ)
Root cert: lab-root.crt.pem (public) Root cert: lab-root.crt.pem (public)
Root key: lab-root.key.pem (PRIVATE — never commit, never production trust) Root key: lab-root.key.pem (PRIVATE — never commit, never production trust)
staff-iam server does not read this directory. POST /enroll/csr stays 501. With LAB_CA_ENABLE=1 the staff-iam server may shell-out sign against this dir.
See docs/TEST-CA-DESIGN.md and docs/PKI-MINT-BACKLOG.md. Default (flag off) POST /enroll/csr stays 501. NEVER production.
See docs/TEST-CA-DESIGN.md, ENROLL.md, docs/PKI-MINT-BACKLOG.md.
EOF EOF
echo "lab CA created:" echo "lab CA created:"
@ -241,8 +249,8 @@ EOF
echo echo
echo "Next (lab/dev only): point a *dev* reverse proxy trust store at:" echo "Next (lab/dev only): point a *dev* reverse proxy trust store at:"
echo " $ca" echo " $ca"
echo "staff-iam still does not mint; POST /enroll/csr remains 501." echo "Default POST /enroll/csr is still 501 unless LAB_CA_ENABLE=1."
echo "See docs/TEST-CA-DESIGN.md — this script is offline fixture only." echo "See docs/TEST-CA-DESIGN.md — LAB ONLY, never production."
warn_banner warn_banner
} }

View file

@ -1,7 +1,9 @@
#!/usr/bin/env node #!/usr/bin/env node
import fs from 'node:fs'; import fs from 'node:fs';
import http from 'node:http'; import http from 'node:http';
import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { allows, publicUser, ROLES, PERMISSIONS, ROLE_INTERFACES, CATALOG_ROLES, roleInterfaceUrl, inferRoleFromNext, interfacesForRoles } from './roles.js'; import { allows, publicUser, ROLES, PERMISSIONS, ROLE_INTERFACES, CATALOG_ROLES, roleInterfaceUrl, inferRoleFromNext, interfacesForRoles } from './roles.js';
import { verifyPassword } from './passwords.js'; import { verifyPassword } from './passwords.js';
@ -245,6 +247,97 @@ const ENROLL_CSR_INVALID_PEM = {
], ],
}; };
const ROOT_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
const LAB_CA_SCRIPT = path.join(ROOT_DIR, 'scripts', 'lab-test-ca.sh');
const LAB_CA_WARNING =
'LAB ONLY — NOT FOR PRODUCTION. Issued via scripts/lab-test-ca.sh under LAB_CA_ENABLE=1. Never trust this cert on real staff devices or production proxies.';
/**
* Opt-in lab CA HTTP mint. Default off → POST /enroll/csr stays 501.
* Refused when NODE_ENV=production even if LAB_CA_ENABLE=1.
*/
function labCaEnabled() {
if (String(process.env.NODE_ENV || '').toLowerCase() === 'production') return false;
const v = String(process.env.LAB_CA_ENABLE || '').trim().toLowerCase();
return v === '1' || v === 'true' || v === 'yes';
}
function labPkiDir() {
const raw = (process.env.LAB_PKI_DIR || '').trim();
if (raw) return path.isAbsolute(raw) ? raw : path.resolve(process.cwd(), raw);
return path.join(ROOT_DIR, 'lab-pki');
}
/** Lab root key+cert present under LAB_PKI_DIR / lab-pki. */
function labPkiReady(dir = labPkiDir()) {
try {
return (
fs.existsSync(path.join(dir, 'lab-root.key.pem')) &&
fs.existsSync(path.join(dir, 'lab-root.crt.pem'))
);
} catch {
return false;
}
}
/**
* Shell-out to scripts/lab-test-ca.sh sign. LAB ONLY.
* @param {string} csrPem
* @param {{ commonName?: string }} [opts]
* @returns {{ ok: true, certificate: string, chain: string|null } | { ok: false, error: string, detail?: string }}
*/
function labSignCsr(csrPem, opts = {}) {
if (!fs.existsSync(LAB_CA_SCRIPT)) {
return { ok: false, error: 'lab_ca_script_missing', detail: LAB_CA_SCRIPT };
}
const dir = labPkiDir();
if (!labPkiReady(dir)) {
return { ok: false, error: 'lab_pki_missing', detail: dir };
}
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'staff-iam-lab-csr-'));
const csrPath = path.join(tmp, 'req.csr.pem');
const outPath = path.join(tmp, 'leaf.crt.pem');
try {
fs.writeFileSync(csrPath, csrPem, { encoding: 'utf8', mode: 0o600 });
const args = ['sign', '--csr', csrPath, '--dir', dir, '--out', outPath, '--days', '7'];
const cn = typeof opts.commonName === 'string' ? opts.commonName.trim() : '';
if (cn && cn.length <= 128 && !/[\r\n\0]/.test(cn)) {
args.push('--cn', cn);
}
const r = spawnSync(LAB_CA_SCRIPT, args, {
encoding: 'utf8',
timeout: 60_000,
env: { ...process.env, LAB_PKI_DIR: dir },
});
if (r.error) {
return { ok: false, error: 'lab_sign_spawn_failed', detail: String(r.error.message || r.error) };
}
if (r.status !== 0) {
const errText = String(r.stderr || r.stdout || '').slice(0, 500);
return { ok: false, error: 'lab_sign_failed', detail: errText || `exit ${r.status}` };
}
if (!fs.existsSync(outPath)) {
return { ok: false, error: 'lab_sign_no_output' };
}
const certificate = fs.readFileSync(outPath, 'utf8');
if (!/-----BEGIN CERTIFICATE-----/.test(certificate)) {
return { ok: false, error: 'lab_sign_bad_pem' };
}
let chain = null;
const chainPath = outPath.replace(/\.crt\.pem$/, '.chain.pem');
if (chainPath !== outPath && fs.existsSync(chainPath)) {
chain = fs.readFileSync(chainPath, 'utf8');
}
return { ok: true, certificate, chain };
} finally {
try {
fs.rmSync(tmp, { recursive: true, force: true });
} catch {
/* ignore */
}
}
}
const PORT = Number(process.env.PORT || 3028); const PORT = Number(process.env.PORT || 3028);
const PUBLIC = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'public'); const PUBLIC = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'public');
const iam = loadIam(); const iam = loadIam();
@ -331,8 +424,13 @@ function withTotpFlags(user) {
const server = http.createServer(async (req, res) => { const server = http.createServer(async (req, res) => {
const url = new URL(req.url || '/', `http://127.0.0.1:${PORT}`); const url = new URL(req.url || '/', `http://127.0.0.1:${PORT}`);
const extra = corsHeaders(req); const extra = corsHeaders(req);
const json = (code, obj) => { const json = (code, obj, hdrs = {}) => {
res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store', ...extra }); res.writeHead(code, {
'content-type': 'application/json',
'cache-control': 'no-store',
...extra,
...hdrs,
});
res.end(JSON.stringify(obj)); res.end(JSON.stringify(obj));
}; };
try { try {
@ -347,6 +445,8 @@ const server = http.createServer(async (req, res) => {
users: iam.users.length, users: iam.users.length,
totpRequired: totpRequiredPolicy(), totpRequired: totpRequiredPolicy(),
mtlsEnforce: mtlsEnforceEnabled(), mtlsEnforce: mtlsEnforceEnabled(),
labCa: labCaEnabled(),
labPkiReady: labCaEnabled() ? labPkiReady() : false,
}); });
} }
if (req.method === 'GET' && (url.pathname === '/login' || url.pathname === '/index.html' || url.pathname === '/')) { if (req.method === 'GET' && (url.pathname === '/login' || url.pathname === '/index.html' || url.pathname === '/')) {
@ -453,9 +553,9 @@ const server = http.createServer(async (req, res) => {
} }
if (req.method === 'POST' && url.pathname === '/enroll/csr') { if (req.method === 'POST' && url.pathname === '/enroll/csr') {
/* /*
* Scaffold + PEM header-shape gate only. * Default: scaffold + PEM header-shape gate → 501 (never production mint).
* - invalid csr string → 400 (shape reject) * Opt-in LAB ONLY: LAB_CA_ENABLE=1 + lab-pki ready → shell-out lab-test-ca.sh → 201 + X-Lab-CA:1.
* - missing csr or valid-looking PEM → 501 (never mint) * NODE_ENV=production always refuses lab path.
* Audit csr_attempt without storing PEM body. * Audit csr_attempt without storing PEM body.
*/ */
const body = await readBody(req); const body = await readBody(req);
@ -467,6 +567,7 @@ const server = http.createServer(async (req, res) => {
let outcome = 'scaffold_501'; let outcome = 'scaffold_501';
let status = 501; let status = 501;
let resp = ENROLL_CSR_NOT_IMPLEMENTED; let resp = ENROLL_CSR_NOT_IMPLEMENTED;
let respHdrs = {};
if (hasCsrField && csrVal != null && csrVal !== '') { if (hasCsrField && csrVal != null && csrVal !== '') {
if (typeof csrVal !== 'string' || !csrPemShapeOk(csrVal)) { if (typeof csrVal !== 'string' || !csrPemShapeOk(csrVal)) {
@ -476,21 +577,71 @@ const server = http.createServer(async (req, res) => {
resp = ENROLL_CSR_INVALID_PEM; resp = ENROLL_CSR_INVALID_PEM;
} else { } else {
pemShape = 'ok'; pemShape = 'ok';
/* Lab path only when explicitly enabled AND lab-pki material exists. */
if (labCaEnabled() && labPkiReady()) {
const cnHint =
typeof body?.commonName === 'string'
? body.commonName
: typeof body?.deviceId === 'string'
? body.deviceId
: '';
const signed = labSignCsr(csrVal, { commonName: cnHint });
if (signed.ok) {
outcome = 'lab_ca_201';
status = 201;
respHdrs = {
'x-lab-ca': '1',
'x-lab-ca-warning': 'NOT-FOR-PRODUCTION',
};
resp = {
ok: true,
lab: true,
warning: LAB_CA_WARNING,
certificate: signed.certificate,
...(signed.chain ? { chain: signed.chain } : {}),
nextSteps: [
'LAB ONLY: this PEM was signed by scripts/lab-test-ca.sh — NOT FOR PRODUCTION.',
'Point a *dev* reverse proxy trust store at lab-pki/lab-root.crt.pem only on lab machines.',
'Do not enable LAB_CA_ENABLE in production (NODE_ENV=production refuses it).',
'Production path remains external PKI + proxy headers; default POST /enroll/csr is still 501.',
'See docs/TEST-CA-DESIGN.md and ENROLL.md (lab CA section).',
],
};
} else {
outcome = `lab_ca_fail_${signed.error || 'unknown'}`;
status = 502;
respHdrs = { 'x-lab-ca': '1', 'x-lab-ca-warning': 'NOT-FOR-PRODUCTION' };
resp = {
error: 'lab_ca_sign_failed',
lab: true,
warning: LAB_CA_WARNING,
reason: signed.error || 'sign_failed',
nextSteps: [
'LAB ONLY path failed — check lab-pki/ was initialized: scripts/lab-test-ca.sh init',
'Ensure openssl is on PATH and LAB_PKI_DIR points at the lab CA directory.',
'Default (LAB_CA_ENABLE unset) still returns 501; this is not production mint.',
],
};
}
} else {
outcome = 'scaffold_501'; outcome = 'scaffold_501';
status = 501; status = 501;
resp = ENROLL_CSR_NOT_IMPLEMENTED; resp = ENROLL_CSR_NOT_IMPLEMENTED;
} }
} }
}
/* detail: flags only — never the PEM text */ /* detail: flags only — never the PEM text */
const labFlag = labCaEnabled() ? 'labCa=1' : 'labCa=0';
const readyFlag = labPkiReady() ? 'labPki=1' : 'labPki=0';
iam.log( iam.log(
actor, actor,
'csr_attempt', 'csr_attempt',
me?.username || undefined, me?.username || undefined,
`outcome=${outcome}; pemShape=${pemShape}; signedIn=${!!me}`, `outcome=${outcome}; pemShape=${pemShape}; signedIn=${!!me}; ${labFlag}; ${readyFlag}`,
); );
saveIam(iam); saveIam(iam);
return json(status, resp); return json(status, resp, respHdrs);
} }
if (req.method === 'GET' && url.pathname === '/enroll/status') { if (req.method === 'GET' && url.pathname === '/enroll/status') {
const me = actorOf(req); const me = actorOf(req);
@ -529,10 +680,16 @@ const server = http.createServer(async (req, res) => {
*/ */
csrSchemaUrl: '/enroll/csr/schema', csrSchemaUrl: '/enroll/csr/schema',
/** /**
* Always 501: POST /enroll/csr is scaffold-only and never mints. * Default 501 (production/scaffold). When LAB_CA_ENABLE=1 and lab-pki is ready,
* UIs can show this without probing the POST path. * valid PEM may return 201 lab-only (X-Lab-CA:1) — still not production mint.
*/ */
csrPostStatus: 501, csrPostStatus: labCaEnabled() && labPkiReady() ? 201 : 501,
/** LAB ONLY opt-in probe — never production. */
labCa: {
enabled: labCaEnabled(),
ready: labPkiReady(),
warning: LAB_CA_WARNING,
},
/** /**
* Boolean summary of which proxy client-cert headers this request carried. * Boolean summary of which proxy client-cert headers this request carried.
* Pure flags for admin/login chips — no PEM/DN strings. * Pure flags for admin/login chips — no PEM/DN strings.

View file

@ -1,6 +1,6 @@
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { spawn } from 'node:child_process'; import { spawn, spawnSync } from 'node:child_process';
import fs from 'node:fs'; import fs from 'node:fs';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
@ -512,3 +512,205 @@ test('enroll/status states: pending_proxy and enrolled (enforce off)', async ()
child.kill('SIGTERM'); child.kill('SIGTERM');
} }
}); });
function haveOpenssl() {
try {
const r = spawnSync('openssl', ['version'], { encoding: 'utf8' });
return r.status === 0;
} catch {
return false;
}
}
test('POST /enroll/csr default LAB_CA_ENABLE off → still 501 (no cert)', async () => {
const port = 18048;
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'iam-lab-off-'));
const child = spawn(process.execPath, ['src/server.js'], {
cwd: root,
env: {
...process.env,
PORT: String(port),
STAFF_IAM_PATH: path.join(dir, 'iam.json'),
LAB_CA_ENABLE: '',
NODE_ENV: 'test',
},
stdio: ['ignore', 'pipe', 'pipe'],
});
await new Promise((r) => setTimeout(r, 500));
try {
const st = await (await fetch(`http://127.0.0.1:${port}/enroll/status`)).json();
assert.equal(st.csrPostStatus, 501);
assert.equal(st.labCa.enabled, false);
const r = await fetch(`http://127.0.0.1:${port}/enroll/csr`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
csr: '-----BEGIN CERTIFICATE REQUEST-----\nMIIB\n-----END CERTIFICATE REQUEST-----',
}),
});
assert.equal(r.status, 501);
assert.equal(r.headers.get('x-lab-ca'), null);
const b = await r.json();
assert.equal(b.error, 'not_implemented');
assert.ok(!('certificate' in b));
} finally {
child.kill('SIGTERM');
}
});
test('POST /enroll/csr LAB_CA_ENABLE=1 without lab-pki → still 501', async () => {
const port = 18049;
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'iam-lab-nopki-'));
const emptyPki = path.join(dir, 'empty-pki');
fs.mkdirSync(emptyPki);
const child = spawn(process.execPath, ['src/server.js'], {
cwd: root,
env: {
...process.env,
PORT: String(port),
STAFF_IAM_PATH: path.join(dir, 'iam.json'),
LAB_CA_ENABLE: '1',
LAB_PKI_DIR: emptyPki,
NODE_ENV: 'test',
},
stdio: ['ignore', 'pipe', 'pipe'],
});
await new Promise((r) => setTimeout(r, 500));
try {
const st = await (await fetch(`http://127.0.0.1:${port}/enroll/status`)).json();
assert.equal(st.labCa.enabled, true);
assert.equal(st.labCa.ready, false);
assert.equal(st.csrPostStatus, 501);
const r = await fetch(`http://127.0.0.1:${port}/enroll/csr`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
csr: '-----BEGIN CERTIFICATE REQUEST-----\nMIIB\n-----END CERTIFICATE REQUEST-----',
}),
});
assert.equal(r.status, 501);
assert.equal(r.headers.get('x-lab-ca'), null);
assert.equal((await r.json()).error, 'not_implemented');
} finally {
child.kill('SIGTERM');
}
});
test('POST /enroll/csr LAB_CA_ENABLE=1 + lab-pki → 201 + X-Lab-CA (skip if no openssl)', async (t) => {
if (!haveOpenssl()) {
t.skip('openssl not on PATH');
return;
}
const port = 18050;
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'iam-lab-on-'));
const pki = path.join(dir, 'lab-pki');
const init = spawnSync('bash', [path.join(root, 'scripts/lab-test-ca.sh'), 'init', '--dir', pki], {
encoding: 'utf8',
});
if (init.status !== 0) {
t.skip(`lab-test-ca init failed: ${init.stderr || init.stdout}`);
return;
}
const key = path.join(dir, 'dev.key');
const csrPath = path.join(dir, 'dev.csr');
const gen = spawnSync(
'openssl',
[
'req',
'-new',
'-newkey',
'rsa:2048',
'-nodes',
'-keyout',
key,
'-out',
csrPath,
'-subj',
'/CN=lab-user@staff.local',
],
{ encoding: 'utf8' },
);
if (gen.status !== 0) {
t.skip(`openssl req failed: ${gen.stderr}`);
return;
}
const csrPem = fs.readFileSync(csrPath, 'utf8');
const child = spawn(process.execPath, ['src/server.js'], {
cwd: root,
env: {
...process.env,
PORT: String(port),
STAFF_IAM_PATH: path.join(dir, 'iam.json'),
LAB_CA_ENABLE: '1',
LAB_PKI_DIR: pki,
NODE_ENV: 'test',
},
stdio: ['ignore', 'pipe', 'pipe'],
});
await new Promise((r) => setTimeout(r, 600));
try {
const st = await (await fetch(`http://127.0.0.1:${port}/enroll/status`)).json();
assert.equal(st.labCa.enabled, true);
assert.equal(st.labCa.ready, true);
assert.equal(st.csrPostStatus, 201);
assert.match(String(st.labCa.warning), /NOT FOR PRODUCTION/i);
const r = await fetch(`http://127.0.0.1:${port}/enroll/csr`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ csr: csrPem, commonName: 'lab-user@staff.local' }),
});
assert.equal(r.status, 201);
assert.equal(r.headers.get('x-lab-ca'), '1');
assert.equal(r.headers.get('x-lab-ca-warning'), 'NOT-FOR-PRODUCTION');
const b = await r.json();
assert.equal(b.ok, true);
assert.equal(b.lab, true);
assert.match(String(b.warning), /NOT FOR PRODUCTION/i);
assert.match(b.certificate, /-----BEGIN CERTIFICATE-----/);
assert.match(b.certificate, /-----END CERTIFICATE-----/);
assert.ok(!('privateKey' in b) && !('pkcs12' in b));
} finally {
child.kill('SIGTERM');
}
});
test('POST /enroll/csr NODE_ENV=production refuses lab even if LAB_CA_ENABLE=1', async () => {
const port = 18051;
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'iam-lab-prod-'));
const pki = path.join(dir, 'lab-pki');
fs.mkdirSync(pki);
fs.writeFileSync(path.join(pki, 'lab-root.key.pem'), 'x');
fs.writeFileSync(path.join(pki, 'lab-root.crt.pem'), 'x');
const child = spawn(process.execPath, ['src/server.js'], {
cwd: root,
env: {
...process.env,
PORT: String(port),
STAFF_IAM_PATH: path.join(dir, 'iam.json'),
LAB_CA_ENABLE: '1',
LAB_PKI_DIR: pki,
NODE_ENV: 'production',
},
stdio: ['ignore', 'pipe', 'pipe'],
});
await new Promise((r) => setTimeout(r, 500));
try {
const st = await (await fetch(`http://127.0.0.1:${port}/enroll/status`)).json();
assert.equal(st.labCa.enabled, false);
assert.equal(st.csrPostStatus, 501);
const r = await fetch(`http://127.0.0.1:${port}/enroll/csr`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
csr: '-----BEGIN CERTIFICATE REQUEST-----\nMIIB\n-----END CERTIFICATE REQUEST-----',
}),
});
assert.equal(r.status, 501);
assert.equal(r.headers.get('x-lab-ca'), null);
} finally {
child.kill('SIGTERM');
}
});