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:
parent
7a211356a7
commit
ca117eb458
7 changed files with 504 additions and 97 deletions
41
ENROLL.md
41
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
|
||||
|
||||
|
|
|
|||
11
README.md
11
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
183
src/server.js
183
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.
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue