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:
parent
f8d0a1249f
commit
5e2d0b979a
4 changed files with 194 additions and 21 deletions
12
ENROLL.md
12
ENROLL.md
|
|
@ -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`
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue