diff --git a/ENROLL.md b/ENROLL.md index f1b5204..46ad3c3 100644 --- a/ENROLL.md +++ b/ENROLL.md @@ -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 diff --git a/README.md b/README.md index 97fcc69..2e60992 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/PKI-MINT-BACKLOG.md b/docs/PKI-MINT-BACKLOG.md index d3d226e..43035a2 100644 --- a/docs/PKI-MINT-BACKLOG.md +++ b/docs/PKI-MINT-BACKLOG.md @@ -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) diff --git a/docs/TEST-CA-DESIGN.md b/docs/TEST-CA-DESIGN.md index ac0027c..1df68e1 100644 --- a/docs/TEST-CA-DESIGN.md +++ b/docs/TEST-CA-DESIGN.md @@ -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 diff --git a/scripts/lab-test-ca.sh b/scripts/lab-test-ca.sh index 3d99150..0330e32 100755 --- a/scripts/lab-test-ca.sh +++ b/scripts/lab-test-ca.sh @@ -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 } diff --git a/src/server.js b/src/server.js index e93e622..b06ef98 100644 --- a/src/server.js +++ b/src/server.js @@ -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. diff --git a/test/health.test.js b/test/health.test.js index 3e759c0..d7d9dd2 100644 --- a/test/health.test.js +++ b/test/health.test.js @@ -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'); + } +}); +