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)
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`).
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`.
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.
@ -34,15 +38,17 @@ Content-Type: application/json
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` |
|------------|------|---------|
| Missing / empty body | **501** | `not_implemented` |
| Valid-looking PEM armor (`BEGIN`/`END CERTIFICATE REQUEST` + non-empty middle) | **501** | `not_implemented` |
| Present but invalid shape (wrong armor, empty middle, private key, non-string) | **400** | `invalid_csr_pem` |
| Body `csr` | `LAB_CA_ENABLE` + lab-pki | HTTP | Result |
|------------|---------------------------|------|--------|
| Missing / empty body | any | **501** | `not_implemented` |
| Invalid PEM shape | any | **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/1.1 501 Not Implemented
@ -51,7 +57,17 @@ Content-Type: application/json
{ "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`
@ -70,7 +86,8 @@ Unauthenticated-friendly status for **login banners** and **My security / admin*
| `renew` | string | Copy only: how to renew (proxy only) |
| `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) |
| `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` |
| `mtlsPresent` | boolean | Proxy reported a client cert on **this** request |
| `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_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

View file

@ -38,6 +38,9 @@ npm start
# optional gates:
# STAFF_TOTP_REQUIRED=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
```
@ -47,7 +50,9 @@ Open `http://127.0.0.1:3028/login`. Persist store with `STAFF_IAM_PATH`. Login i
| 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 |
| [NATS.md](./NATS.md) | NATS notes (if used) |
| [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 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. |
## Wire other doors
@ -133,7 +138,7 @@ Signed-in **Security** tab covers:
- Change password (`/change-password`)
- 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

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**.
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).
@ -10,9 +10,9 @@ Canonical behavior and header contract: [ENROLL.md](../ENROLL.md). Service overv
| 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 |
| `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 |
| `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 |
@ -33,7 +33,7 @@ Fields-only contract for a **future** mint body. **Does not mint.**
### 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`
- **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 `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.
- 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)
@ -75,23 +75,23 @@ Shipped this pass (still **no mint**):
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] **Not** wired into `POST /enroll/csr`, server boot, or any default env flag — HTTP path stays **501**
- [x] Documented relationship to future live mint in this file + [TEST-CA-DESIGN.md](./TEST-CA-DESIGN.md)
- [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] **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) |
|-------|--------------------------|---------------------------|
| 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.
Operators continue to issue workstation certs at external reverse-proxy / internal PKI.
### 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)
- [ ] 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
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.”
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.
## Related docs
- [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
- [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)

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.**
Offline lab helper **shipped:** [`scripts/lab-test-ca.sh`](../scripts/lab-test-ca.sh) (OpenSSL self-signed root + CSR→PEM sign on disk only).
**Status:**
- 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).
## Lab script (shipped — offline only)
## Lab script + opt-in HTTP (LAB ONLY — never production)
| Item | Detail |
|------|--------|
| 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 |
| What it does **not** | Call staff-iam HTTP; change `POST /enroll/csr`; set any server env; trust itself in production |
| Warnings | Prints a loud **LAB ONLY / NOT FOR PRODUCTION** banner on every run |
| 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 |
| Offline | `init` → self-signed lab root under `lab-pki/` (gitignored); `sign --csr` → short-lived leaf PEM + chain on disk |
| 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` |
| Default | Flag **off** → `POST /enroll/csr` **501** (unchanged production scaffold) |
| 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
# example (lab machine only)
./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"
# offline sign still works:
./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`.
@ -41,34 +53,37 @@ The test CA is a **fixture toolchain**, not a staff-iam runtime feature and not
| Non-goal | Why |
|----------|-----|
| Soften `POST /enroll/csr` to 200 with sample PEM | Violates “no fake crypto”; tests must not teach UIs that mint works |
| Ship a self-signed CA inside the production Node process | Production mint must use a real CA model (see backlog) |
| Return success PEM when `LAB_CA_ENABLE` is **off** | Default must stay honest **501** |
| 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 |
| 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 |
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
[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)
POST /enroll/csr succeeds Future: same handler, TEST_CA_* env only
(never default; never production NODE_ENV)
External/HSM/EST signer ←→ scripts/lab-test-ca.sh
POST /enroll/csr succeeds LAB_CA_ENABLE=1 + lab-pki → 201 + X-Lab-CA:1
(never default lab) Default off → 501; production NODE_ENV refuses
Proxy trusts corp roots Dev proxy may trust lab-pki root (lab only)
```
| Stage | Lab script | HTTP `POST /enroll/csr` |
|-------|------------|-------------------------|
| **Today** | Offline init/sign on disk | **501** scaffold (unchanged) |
| **Live mint ships** | Still offline fixture / CI helper | Real production `Signer` |
| **Gated test signer (future)** | May share profile defaults with script | Only with `STAFF_TEST_CA=1` and non-production |
| **Default today** | Offline init/sign on disk | **501** scaffold |
| **Lab opt-in today** | Same script, server shell-out | **201** + `X-Lab-CA: 1` when flag + pki ready |
| **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
@ -102,14 +117,14 @@ platform/verae-staff-iam/
## Trust and environment gates
| Variable (proposed) | Effect |
|---------------------|--------|
| `STAFF_TEST_CA=1` | Allows in-process or scripted test signer to run |
| `STAFF_TEST_CA_KEY_DIR` | Path to root/intermediate key material |
| `NODE_ENV=production` | **Must refuse** test CA even if `STAFF_TEST_CA=1` |
| Variable | Effect |
|----------|--------|
| `LAB_CA_ENABLE=1` | **Shipped.** Allows shell-out lab sign on `POST /enroll/csr` when lab-pki ready |
| `LAB_PKI_DIR` | Lab CA directory (default `./lab-pki`) |
| `NODE_ENV=production` | **Refuses** lab CA even if `LAB_CA_ENABLE=1` |
| `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)
@ -126,9 +141,10 @@ In-process mint (if chosen) would call the same validation + sign helpers the pr
| Layer | Assertion |
|-------|-----------|
| Contract (today) | `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 |
| Integration (future) | With `STAFF_TEST_CA=1` + key dir, valid CSR → 201 + chain; serial stored as metadata |
| Contract (default) | `GET /enroll/csr/schema` → 200, `mint: false`; `POST /enroll/csr` → 501; no cert fields |
| Lab integration (today) | `LAB_CA_ENABLE=1` + lab-pki + openssl → 201 + `X-Lab-CA: 1` + certificate PEM; skip if no openssl |
| 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 |
| 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
- **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 (future mint):** same offline script or gated `STAFF_TEST_CA` signer; never enable lab CA in default server boot.
- **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 (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.
## Implementation order (blocked on product)
@ -161,11 +177,11 @@ Exact algorithms should match whatever production CA model is selected in [PKI-M
## 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.
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.
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** — production stays 501 until real `Signer`; lab 201 always carries `X-Lab-CA: 1` + warning body.
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.
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.
4. **Never enable lab CA by default or under `NODE_ENV=production`.**
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

View file

@ -1,12 +1,17 @@
#!/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.
# Material stays on disk under a lab directory. This script is NEVER imported
# by the Node server and is NOT wired into POST /enroll/csr (still 501).
# Material stays on disk under a lab directory (default lab-pki/, gitignored).
#
# NOT FOR PRODUCTION. NOT A STAFF-IAM RUNTIME FEATURE.
# Do not trust this root on real workstations, staging staff fleets, or prod.
# Optional HTTP path (LAB ONLY): when staff-iam is started with LAB_CA_ENABLE=1
# 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:
# scripts/lab-test-ca.sh init [--dir DIR]
@ -27,9 +32,11 @@ warn_banner() {
LAB ONLY — verae-staff-iam scripts/lab-test-ca.sh
Self-signed throwaway CA. NOT for production, staging staff, or
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.
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
}
@ -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)
Root cert: lab-root.crt.pem (public)
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.
See docs/TEST-CA-DESIGN.md and docs/PKI-MINT-BACKLOG.md.
With LAB_CA_ENABLE=1 the staff-iam server may shell-out sign against this dir.
Default (flag off) POST /enroll/csr stays 501. NEVER production.
See docs/TEST-CA-DESIGN.md, ENROLL.md, docs/PKI-MINT-BACKLOG.md.
EOF
echo "lab CA created:"
@ -241,8 +249,8 @@ EOF
echo
echo "Next (lab/dev only): point a *dev* reverse proxy trust store at:"
echo " $ca"
echo "staff-iam still does not mint; POST /enroll/csr remains 501."
echo "See docs/TEST-CA-DESIGN.md — this script is offline fixture only."
echo "Default POST /enroll/csr is still 501 unless LAB_CA_ENABLE=1."
echo "See docs/TEST-CA-DESIGN.md — LAB ONLY, never production."
warn_banner
}

View file

@ -1,7 +1,9 @@
#!/usr/bin/env node
import fs from 'node:fs';
import http from 'node:http';
import os from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { allows, publicUser, ROLES, PERMISSIONS, ROLE_INTERFACES, CATALOG_ROLES, roleInterfaceUrl, inferRoleFromNext, interfacesForRoles } from './roles.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 PUBLIC = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'public');
const iam = loadIam();
@ -331,8 +424,13 @@ function withTotpFlags(user) {
const server = http.createServer(async (req, res) => {
const url = new URL(req.url || '/', `http://127.0.0.1:${PORT}`);
const extra = corsHeaders(req);
const json = (code, obj) => {
res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store', ...extra });
const json = (code, obj, hdrs = {}) => {
res.writeHead(code, {
'content-type': 'application/json',
'cache-control': 'no-store',
...extra,
...hdrs,
});
res.end(JSON.stringify(obj));
};
try {
@ -347,6 +445,8 @@ const server = http.createServer(async (req, res) => {
users: iam.users.length,
totpRequired: totpRequiredPolicy(),
mtlsEnforce: mtlsEnforceEnabled(),
labCa: labCaEnabled(),
labPkiReady: labCaEnabled() ? labPkiReady() : false,
});
}
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') {
/*
* Scaffold + PEM header-shape gate only.
* - invalid csr string → 400 (shape reject)
* - missing csr or valid-looking PEM → 501 (never mint)
* Default: scaffold + PEM header-shape gate → 501 (never production mint).
* Opt-in LAB ONLY: LAB_CA_ENABLE=1 + lab-pki ready → shell-out lab-test-ca.sh → 201 + X-Lab-CA:1.
* NODE_ENV=production always refuses lab path.
* Audit csr_attempt without storing PEM body.
*/
const body = await readBody(req);
@ -467,6 +567,7 @@ const server = http.createServer(async (req, res) => {
let outcome = 'scaffold_501';
let status = 501;
let resp = ENROLL_CSR_NOT_IMPLEMENTED;
let respHdrs = {};
if (hasCsrField && csrVal != null && csrVal !== '') {
if (typeof csrVal !== 'string' || !csrPemShapeOk(csrVal)) {
@ -476,21 +577,71 @@ const server = http.createServer(async (req, res) => {
resp = ENROLL_CSR_INVALID_PEM;
} else {
pemShape = 'ok';
outcome = 'scaffold_501';
status = 501;
resp = ENROLL_CSR_NOT_IMPLEMENTED;
/* 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';
status = 501;
resp = ENROLL_CSR_NOT_IMPLEMENTED;
}
}
}
/* detail: flags only — never the PEM text */
const labFlag = labCaEnabled() ? 'labCa=1' : 'labCa=0';
const readyFlag = labPkiReady() ? 'labPki=1' : 'labPki=0';
iam.log(
actor,
'csr_attempt',
me?.username || undefined,
`outcome=${outcome}; pemShape=${pemShape}; signedIn=${!!me}`,
`outcome=${outcome}; pemShape=${pemShape}; signedIn=${!!me}; ${labFlag}; ${readyFlag}`,
);
saveIam(iam);
return json(status, resp);
return json(status, resp, respHdrs);
}
if (req.method === 'GET' && url.pathname === '/enroll/status') {
const me = actorOf(req);
@ -529,10 +680,16 @@ const server = http.createServer(async (req, res) => {
*/
csrSchemaUrl: '/enroll/csr/schema',
/**
* Always 501: POST /enroll/csr is scaffold-only and never mints.
* UIs can show this without probing the POST path.
* Default 501 (production/scaffold). When LAB_CA_ENABLE=1 and lab-pki is ready,
* 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.
* Pure flags for admin/login chips — no PEM/DN strings.

View file

@ -1,6 +1,6 @@
import { test } from 'node:test';
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 os from 'node:os';
import path from 'node:path';
@ -512,3 +512,205 @@ test('enroll/status states: pending_proxy and enrolled (enforce off)', async ()
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');
}
});