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:
parent
128ad01ff9
commit
ae820a5db6
5 changed files with 120 additions and 7 deletions
29
ENROLL.md
29
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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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`, {
|
||||
|
|
|
|||
Loading…
Reference in a new issue