feat: GET /enroll/csr/schema field map (mint still 501)

Schema-only CSR contract for future live PKI; no fake crypto.
This commit is contained in:
George Lambert 2026-10-13 23:08:58 -04:00
commit ae820a5db6
5 changed files with 120 additions and 7 deletions

View file

@ -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).

View file

@ -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

View file

@ -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

View file

@ -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);

View file

@ -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`, {