diff --git a/ENROLL.md b/ENROLL.md index 2c9862e..5795936 100644 --- a/ENROLL.md +++ b/ENROLL.md @@ -5,10 +5,35 @@ staff-iam **does not mint, renew, or verify** device certificates. There is **no 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. 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 reserves `POST /enroll/csr` as 501-only until live 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. +## `GET /enroll/csr/schema` (fields only — 200) + +Describes the **future** `POST /enroll/csr` request body. **No mint.** + +```http +HTTP/1.1 200 OK +Content-Type: application/json + +{ + "ok": true, + "path": "POST /enroll/csr", + "mint": false, + "status": "schema_only", + "fields": [ + { "name": "csr", "type": "string", "required": true, "format": "pem", "description": "…" }, + { "name": "deviceId", "type": "string", "required": false, "description": "…" }, + { "name": "commonName", "type": "string", "required": false, "description": "…" }, + { "name": "sans", "type": "string[]", "required": false, "description": "…" } + ], + "notReturned": ["certificate", "pem", "cert", "pkcs12", "privateKey", "key"] +} +``` + +Auth optional. Allowed under the TOTP enrollment allow-list. Never returns cert or key material. + ## `POST /enroll/csr` (scaffold — 501) Reserved for a future live mint. **Always** responds: @@ -17,7 +42,7 @@ Reserved for a future live mint. **Always** responds: HTTP/1.1 501 Not Implemented Content-Type: application/json -{ "error": "not_implemented", "nextSteps": [ /* proxy/PKI path */ ] } +{ "error": "not_implemented", "nextSteps": [ /* proxy/PKI path + schema pointer */ ] } ``` The body is drained and ignored. No cert, PEM, PKCS#12, or key is ever returned. Details and live-PKI checklist: [PKI-MINT-BACKLOG.md](./docs/PKI-MINT-BACKLOG.md). diff --git a/README.md b/README.md index 9a303e1..47ed68c 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ 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` `state` / `nextSteps` / `updatedAt` / `proxyHeadersSeen`; **proxy headers only — no fake PKI** | +| [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** | | [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 +70,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[]`, `proxyHeadersSeen` (`verify`/`verifyOk`/`dn`/`pem`/`any`), and `proxy` probe. Enforce only with `STAFF_MTLS_ENFORCE=1` **and** `mtlsEnrolled: true`. **No device-cert mint** — proxy headers only. [ENROLL.md](./ENROLL.md). | +| **mTLS** | Copy + status only. `GET /enroll/status` returns `updatedAt`, `state` (`not_enrolled` \| `pending_proxy` \| `enrolled` \| `enforce_on`), `nextSteps[]`, `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). | | **Session** | Cookie / Bearer `staff_session` only. Never issues customer `xVerae-` tokens. | ## Wire other doors diff --git a/docs/PKI-MINT-BACKLOG.md b/docs/PKI-MINT-BACKLOG.md index d1f3a44..5764673 100644 --- a/docs/PKI-MINT-BACKLOG.md +++ b/docs/PKI-MINT-BACKLOG.md @@ -2,15 +2,16 @@ **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** 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`), 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. Canonical behavior and header contract: [ENROLL.md](../ENROLL.md). Service overview and security gates: [README.md](../README.md). -## Shipped today (proxy-only + CSR scaffold) +## Shipped today (proxy-only + CSR schema + CSR scaffold) | Surface | What it does | What it does not do | |---------|----------------|---------------------| | `GET /enroll/status` | Returns `state`, `stateLabel`, `note`, `updatedAt`, `nextSteps[]`, `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/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:** always `501` `{ error: "not_implemented", nextSteps: [...] }` | Does **not** parse CSR, mint, sign, or return PEM/PKCS#12 | | `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 | @@ -20,6 +21,16 @@ Canonical behavior and header contract: [ENROLL.md](../ENROLL.md). Service overv `state` values remain: `not_enrolled` → `pending_proxy` → `enrolled` → `enforce_on`. Full matrix and `nextSteps` by state: [ENROLL.md](../ENROLL.md). +### CSR schema (`GET /enroll/csr/schema`) + +Fields-only contract for a **future** mint body. **Does not mint.** + +- Method/path: `GET /enroll/csr/schema` +- Response: **HTTP 200** +- Body: `{ ok, path: "POST /enroll/csr", mint: false, status: "schema_only", fields: [...], notReturned: [...], nextSteps }` +- `fields` describe names/types only (`csr` required PEM PKCS#10; optional `deviceId`, `commonName`, `sans`) — no sample CSR material. +- Auth optional; allowed under the TOTP enrollment allow-list. + ### CSR scaffold (`POST /enroll/csr`) Reserved path for a future live mint. **Today it must never succeed.** @@ -33,6 +44,7 @@ Reserved path for a future live mint. **Today it must never succeed.** "error": "not_implemented", "nextSteps": [ "Live device-certificate mint is not shipped in staff-iam; POST /enroll/csr always returns 501.", + "GET /enroll/csr/schema returns the future request-body fields only (no mint).", "Issue workstation client certificates in your reverse-proxy / internal PKI (not this service).", "Confirm GET /enroll/status shows proxy.verifyOk or proxy.hasDn / proxy.hasPem when the browser presents the cert.", "Have an IAM admin set mtlsEnrolled: true via PUT /users/:username, then optionally STAFF_MTLS_ENFORCE=1.", @@ -74,6 +86,7 @@ The following are **explicitly not implemented** and must not be faked in UI or Checklist for a real in-app mint (product + security design required before coding): +- [x] ~~Publish future CSR body field schema (`GET /enroll/csr/schema`)~~ — shipped (fields only; no mint) - [ ] Choose CA model: external ACME/EST/SCEP, internal HSM-backed CA, or delegated signer service - [ ] AuthN/AuthZ for `POST /enroll/csr` (session + role/permission; rate limits; audit log) - [ ] CSR validation (PEM parse, key type/size, CN/SAN policy, proof-of-possession) @@ -86,7 +99,7 @@ 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 a test CA only) - [ ] 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, and keep `POST /enroll/csr` as 501-only. +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. ## Hard rules diff --git a/src/server.js b/src/server.js index 48af7c9..48838f6 100644 --- a/src/server.js +++ b/src/server.js @@ -138,6 +138,7 @@ function needsTotpEnroll(user) { /** Paths allowed while STAFF_TOTP_REQUIRED and user has not enabled TOTP yet. */ function totpEnrollAllowed(method, pathname) { if (pathname === '/me' || pathname === '/enroll/status' || pathname === '/health') return true; + if (pathname === '/enroll/csr/schema' && method === 'GET') return true; if (pathname === '/enroll/csr' && method === 'POST') return true; if (pathname === '/logout') return true; if (pathname === '/2fa/setup' || pathname === '/2fa/enable') return method === 'POST'; @@ -145,11 +146,61 @@ function totpEnrollAllowed(method, pathname) { return false; } +/** + * Future POST /enroll/csr body shape only — no mint, no sample CSR, no secrets. + * GET /enroll/csr/schema returns this document with HTTP 200. + */ +const ENROLL_CSR_SCHEMA = { + ok: true, + path: 'POST /enroll/csr', + contentType: 'application/json', + mint: false, + status: 'schema_only', + note: + 'Describes the future CSR request body fields only. Live mint is not shipped; POST /enroll/csr always returns 501 not_implemented.', + fields: [ + { + name: 'csr', + type: 'string', + required: true, + format: 'pem', + description: + 'PKCS#10 certificate signing request PEM (BEGIN CERTIFICATE REQUEST … END CERTIFICATE REQUEST).', + }, + { + name: 'deviceId', + type: 'string', + required: false, + description: 'Optional workstation or device id to bind at issue time (server policy).', + }, + { + name: 'commonName', + type: 'string', + required: false, + description: + 'Preferred CN hint; live mint may override from the authenticated IAM user and refuse client-only CN.', + }, + { + name: 'sans', + type: 'string[]', + required: false, + description: 'Optional subjectAltName values (DNS/URI) when CA policy allows.', + }, + ], + notReturned: ['certificate', 'pem', 'cert', 'pkcs12', 'privateKey', 'key'], + nextSteps: [ + 'POST /enroll/csr remains 501 scaffold — do not send a real CSR expecting a cert.', + 'Issue workstation client certificates in your reverse-proxy / internal PKI (not this service).', + 'See docs/PKI-MINT-BACKLOG.md for AuthN, CSR validate, sign, and renew checklist.', + ], +}; + /** Honest 501 body for POST /enroll/csr — scaffold only; never mints a cert. */ const ENROLL_CSR_NOT_IMPLEMENTED = { error: 'not_implemented', nextSteps: [ 'Live device-certificate mint is not shipped in staff-iam; POST /enroll/csr always returns 501.', + 'GET /enroll/csr/schema returns the future request-body fields only (no mint).', 'Issue workstation client certificates in your reverse-proxy / internal PKI (not this service).', 'Confirm GET /enroll/status shows proxy.verifyOk or proxy.hasDn / proxy.hasPem when the browser presents the cert.', 'Have an IAM admin set mtlsEnrolled: true via PUT /users/:username, then optionally STAFF_MTLS_ENFORCE=1.', @@ -355,6 +406,10 @@ const server = http.createServer(async (req, res) => { res.writeHead(302, { 'set-cookie': cookieHeader(token), location: loc, ...extra }); return res.end(); } + if (req.method === 'GET' && url.pathname === '/enroll/csr/schema') { + /* Fields-only contract for a future mint; never returns cert material. */ + return json(200, ENROLL_CSR_SCHEMA); + } if (req.method === 'POST' && url.pathname === '/enroll/csr') { /* Scaffold only: refuse CSR intake; never mint or parse cert material. */ await readBody(req); diff --git a/test/health.test.js b/test/health.test.js index 9106ee4..5c04607 100644 --- a/test/health.test.js +++ b/test/health.test.js @@ -124,6 +124,25 @@ test('iam login, permission check, user CRUD', async () => { assert.match(enrollAuth.updatedAt, /^\d{4}-\d{2}-\d{2}T/); assert.equal(enrollAuth.proxyHeadersSeen.any, false); + /* GET /enroll/csr/schema — fields only, no mint */ + const schemaRes = await fetch(`http://127.0.0.1:${port}/enroll/csr/schema`); + assert.equal(schemaRes.status, 200); + const schema = await schemaRes.json(); + assert.equal(schema.ok, true); + assert.equal(schema.mint, false); + assert.equal(schema.status, 'schema_only'); + assert.equal(schema.path, 'POST /enroll/csr'); + assert.ok(Array.isArray(schema.fields) && schema.fields.length >= 1); + assert.ok(schema.fields.every((f) => f && typeof f.name === 'string' && typeof f.type === 'string')); + assert.ok(schema.fields.some((f) => f.name === 'csr' && f.required === true)); + assert.ok(Array.isArray(schema.notReturned)); + assert.ok(schema.notReturned.includes('certificate') || schema.notReturned.includes('pem')); + assert.ok(!('certificate' in schema) && !('pem' in schema) && !('pkcs12' in schema)); + const schemaBlob = JSON.stringify(schema); + /* Descriptions may name PEM armor labels; forbid actual PEM blocks / keys. */ + assert.ok(!/-----BEGIN[^-]*-----[\s\S]*-----END/i.test(schemaBlob)); + assert.ok(!/BEGIN PRIVATE KEY/i.test(schemaBlob)); + /* POST /enroll/csr is scaffold-only: always 501, never mints a cert */ const csrAnon = await fetch(`http://127.0.0.1:${port}/enroll/csr`, { method: 'POST', @@ -136,6 +155,7 @@ test('iam login, permission check, user CRUD', async () => { assert.ok(Array.isArray(csrAnonBody.nextSteps) && csrAnonBody.nextSteps.length >= 3); assert.ok(csrAnonBody.nextSteps.every((s) => typeof s === 'string' && s.length > 0)); assert.ok(csrAnonBody.nextSteps.some((s) => /proxy|PKI/i.test(s))); + assert.ok(csrAnonBody.nextSteps.some((s) => /csr\/schema/i.test(s))); assert.ok(!('certificate' in csrAnonBody) && !('pem' in csrAnonBody) && !('cert' in csrAnonBody)); const csrAuth = await fetch(`http://127.0.0.1:${port}/enroll/csr`, {