feat: TODO-7 A4 — CSR PEM shape validation before 501

Invalid PEM armor → 400; valid-looking still 501 not_implemented; audit without PEM body.
This commit is contained in:
George Lambert 2026-10-13 23:38:37 -04:00
commit 5e2d0b979a
4 changed files with 194 additions and 21 deletions

View file

@ -34,9 +34,15 @@ Content-Type: application/json
Auth optional. Allowed under the TOTP enrollment allow-list. Never returns cert or key material.
## `POST /enroll/csr` (scaffold — 501)
## `POST /enroll/csr` (scaffold — 501 + PEM shape gate)
Reserved for a future live mint. **Always** responds:
Reserved for a future live mint. **Never mints** a cert.
| 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` |
```http
HTTP/1.1 501 Not Implemented
@ -45,7 +51,7 @@ Content-Type: application/json
{ "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).
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).
## `GET /enroll/status`

View file

@ -12,7 +12,7 @@ Canonical behavior and header contract: [ENROLL.md](../ENROLL.md). Service overv
|---------|----------------|---------------------|
| `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/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 |
| `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 |
| `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,11 +33,15 @@ 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.**
Reserved path for a future live mint. **Today it must never succeed (no mint).**
- Method/path: `POST /enroll/csr`
- Response: **HTTP 501**
- Body (JSON only):
- **PEM header-shape gate (TODO-7):** if JSON body includes a non-empty `csr` value:
- **Invalid** PEM armor (missing `BEGIN`/`END CERTIFICATE REQUEST`, empty body between armor, private-key labels, non-string) → **HTTP 400** `{ error: "invalid_csr_pem", message, nextSteps }`
- **Valid-looking** PEM shape only (headers + non-empty middle; **no** ASN.1/crypto verify) → **HTTP 501** scaffold below
- Missing `csr` / empty body → **HTTP 501** scaffold (discoverability unchanged)
- Audit: every attempt appends `action: "csr_attempt"` with `detail` flags only (`outcome=…; pemShape=absent|ok|invalid; signedIn=…`). **PEM text is never stored** in audit, logs, or user records.
- Scaffold **501** body (JSON only):
```json
{
@ -53,13 +57,24 @@ Reserved path for a future live mint. **Today it must never succeed.**
}
```
- Request body is drained and **ignored** (no CSR parse, no storage, no side effects).
- 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.
- Auth optional; same 501 whether anonymous or signed-in.
- Allowed under the TOTP enrollment allow-list so a pending-2FA session can still discover the 501.
- 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.
### TODO-7 slice (MULTI-AGENT-TODO-7 A4)
Shipped this pass (still **no mint**):
- [x] `POST /enroll/csr` PEM header-shape validation → **400** `invalid_csr_pem` / **501** `not_implemented`
- [x] Audit event `csr_attempt` without storing PEM
- [x] Tests: invalid PEM → 400; valid-looking PEM → 501; audit flags only
- [x] This backlog section
Still left for live PKI: full checklist under “Left for live PKI” below.
### Operator path (current)
1. Issue workstation client certs in your **external** reverse-proxy / internal PKI (not this app). Do not call `POST /enroll/csr` expecting a cert — it returns 501.
@ -87,9 +102,10 @@ 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)
- [x] ~~PEM header-shape reject on `POST /enroll/csr` (400) + `csr_attempt` audit without PEM~~ — TODO-7 scaffold only; still 501 on valid shape (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)
- [ ] AuthN/AuthZ for `POST /enroll/csr` (session + role/permission; rate limits beyond audit)
- [ ] CSR validation (full PEM/ASN.1 parse, key type/size, CN/SAN policy, proof-of-possession)
- [ ] Bind issued cert identity to IAM user (and optional device id) without trusting client-supplied CN alone
- [ ] Issue path: sign → return cert chain (PEM) and/or PKCS#12; never log private keys
- [ ] Persist serial / fingerprint / notAfter for status and renew gates (store metadata only)

View file

@ -208,6 +208,43 @@ const ENROLL_CSR_NOT_IMPLEMENTED = {
],
};
/**
* PEM *header shape* only for PKCS#10 CSR — no ASN.1 parse, no sign, no store.
* Accepts traditional BEGIN CERTIFICATE REQUEST and OpenSSL NEW CERTIFICATE REQUEST.
* Rejects private-key armor and missing/mismatched END lines.
* @param {unknown} csr
* @returns {boolean}
*/
function csrPemShapeOk(csr) {
if (typeof csr !== 'string') return false;
const s = csr.trim();
if (!s || s.length > 65536) return false;
if (/BEGIN\s+(RSA\s+|EC\s+|ENCRYPTED\s+)?PRIVATE\s+KEY/i.test(s)) return false;
const beginRe = /-----BEGIN (NEW )?CERTIFICATE REQUEST-----/;
const endRe = /-----END (NEW )?CERTIFICATE REQUEST-----/;
const bm = s.match(beginRe);
const em = s.match(endRe);
if (!bm || !em) return false;
const bi = s.indexOf(bm[0]);
const ei = s.indexOf(em[0]);
if (bi < 0 || ei < 0 || bi >= ei) return false;
/* Require non-empty body between armor lines (base64 content not verified). */
const mid = s.slice(bi + bm[0].length, ei).replace(/\s+/g, '');
return mid.length > 0;
}
const ENROLL_CSR_INVALID_PEM = {
error: 'invalid_csr_pem',
message:
'csr must be a PEM-shaped PKCS#10 request (BEGIN/END CERTIFICATE REQUEST). Body is not stored; mint remains unimplemented.',
nextSteps: [
'Send JSON { "csr": "-----BEGIN CERTIFICATE REQUEST-----\\n…\\n-----END CERTIFICATE REQUEST-----" }.',
'GET /enroll/csr/schema lists future body fields (mint: false).',
'Even with a valid PEM shape, POST /enroll/csr returns 501 — staff-iam never mints device certs.',
'Issue workstation certs at the reverse-proxy / internal PKI.',
],
};
const PORT = Number(process.env.PORT || 3028);
const PUBLIC = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'public');
const iam = loadIam();
@ -411,9 +448,45 @@ const server = http.createServer(async (req, res) => {
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);
return json(501, ENROLL_CSR_NOT_IMPLEMENTED);
/*
* Scaffold + PEM header-shape gate only.
* - invalid csr string → 400 (shape reject)
* - missing csr or valid-looking PEM → 501 (never mint)
* Audit csr_attempt without storing PEM body.
*/
const body = await readBody(req);
const me = actorOf(req);
const actor = me?.username || 'anonymous';
const hasCsrField = Object.prototype.hasOwnProperty.call(body || {}, 'csr');
const csrVal = hasCsrField ? body.csr : undefined;
let pemShape = 'absent';
let outcome = 'scaffold_501';
let status = 501;
let resp = ENROLL_CSR_NOT_IMPLEMENTED;
if (hasCsrField && csrVal != null && csrVal !== '') {
if (typeof csrVal !== 'string' || !csrPemShapeOk(csrVal)) {
pemShape = 'invalid';
outcome = 'invalid_pem_400';
status = 400;
resp = ENROLL_CSR_INVALID_PEM;
} else {
pemShape = 'ok';
outcome = 'scaffold_501';
status = 501;
resp = ENROLL_CSR_NOT_IMPLEMENTED;
}
}
/* detail: flags only — never the PEM text */
iam.log(
actor,
'csr_attempt',
me?.username || undefined,
`outcome=${outcome}; pemShape=${pemShape}; signedIn=${!!me}`,
);
saveIam(iam);
return json(status, resp);
}
if (req.method === 'GET' && url.pathname === '/enroll/status') {
const me = actorOf(req);

View file

@ -148,7 +148,7 @@ test('iam login, permission check, user CRUD', async () => {
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 */
/* POST /enroll/csr: valid-looking PEM shape → 501 scaffold, never mints */
const csrAnon = await fetch(`http://127.0.0.1:${port}/enroll/csr`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
@ -163,15 +163,93 @@ test('iam login, permission check, user CRUD', async () => {
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`, {
/* invalid PEM shape → 400 (no mint) */
const csrBad = await fetch(`http://127.0.0.1:${port}/enroll/csr`, {
method: 'POST',
headers: hdr,
body: JSON.stringify({ csr: 'not-a-real-csr' }),
});
assert.equal(csrAuth.status, 501);
const csrAuthBody = await csrAuth.json();
assert.equal(csrAuthBody.error, 'not_implemented');
assert.ok(Array.isArray(csrAuthBody.nextSteps) && csrAuthBody.nextSteps.length >= 3);
assert.equal(csrBad.status, 400);
const csrBadBody = await csrBad.json();
assert.equal(csrBadBody.error, 'invalid_csr_pem');
assert.ok(!('certificate' in csrBadBody) && !('pem' in csrBadBody) && !('cert' in csrBadBody));
/* empty body → 501 scaffold (no csr field) */
const csrEmpty = await fetch(`http://127.0.0.1:${port}/enroll/csr`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({}),
});
assert.equal(csrEmpty.status, 501);
assert.equal((await csrEmpty.json()).error, 'not_implemented');
/* audit: csr_attempt without PEM body */
const auditRes = await fetch(`http://127.0.0.1:${port}/audit`, { headers: hdr });
assert.equal(auditRes.status, 200);
const { audit } = await auditRes.json();
assert.ok(Array.isArray(audit));
const csrAttempts = audit.filter((a) => a.action === 'csr_attempt');
assert.ok(csrAttempts.length >= 3);
for (const a of csrAttempts) {
const d = String(a.detail || '');
assert.ok(/outcome=/.test(d));
assert.ok(/pemShape=/.test(d));
assert.ok(!/BEGIN CERTIFICATE REQUEST/i.test(d));
assert.ok(!/not-a-real-csr/.test(d));
assert.ok(!/FAKE/.test(JSON.stringify(a)));
}
assert.ok(csrAttempts.some((a) => /outcome=scaffold_501/.test(String(a.detail))));
assert.ok(csrAttempts.some((a) => /outcome=invalid_pem_400/.test(String(a.detail))));
} finally {
child.kill('SIGTERM');
}
});
test('POST /enroll/csr PEM shape: invalid → 400; valid-looking → 501; never mints', async () => {
const port = 18038;
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'iam-csr-pem-'));
const child = spawn(process.execPath, ['src/server.js'], {
cwd: root,
env: { ...process.env, PORT: String(port), STAFF_IAM_PATH: path.join(dir, 'iam.json') },
stdio: ['ignore', 'pipe', 'pipe'],
});
await new Promise((r) => setTimeout(r, 500));
try {
const cases400 = [
'not-a-real-csr',
'-----BEGIN CERTIFICATE-----\nABC\n-----END CERTIFICATE-----',
'-----BEGIN CERTIFICATE REQUEST-----\n-----END CERTIFICATE REQUEST-----',
'-----BEGIN PRIVATE KEY-----\nABC\n-----END PRIVATE KEY-----',
'-----BEGIN CERTIFICATE REQUEST-----\nABC',
42,
];
for (const csr of cases400) {
const r = await fetch(`http://127.0.0.1:${port}/enroll/csr`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ csr }),
});
assert.equal(r.status, 400, `expected 400 for ${JSON.stringify(csr)}`);
const b = await r.json();
assert.equal(b.error, 'invalid_csr_pem');
assert.ok(!('certificate' in b) && !('pem' in b) && !('pkcs12' in b));
}
const okPems = [
'-----BEGIN CERTIFICATE REQUEST-----\nMIIB\n-----END CERTIFICATE REQUEST-----',
'-----BEGIN NEW CERTIFICATE REQUEST-----\nQUJD\n-----END NEW CERTIFICATE REQUEST-----',
];
for (const csr of okPems) {
const r = await fetch(`http://127.0.0.1:${port}/enroll/csr`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ csr }),
});
assert.equal(r.status, 501, 'valid-looking PEM must still be scaffold 501');
const b = await r.json();
assert.equal(b.error, 'not_implemented');
assert.ok(!('certificate' in b) && !('pem' in b) && !('cert' in b) && !('pkcs12' in b));
}
} finally {
child.kill('SIGTERM');
}