12 KiB
Device certificate enrollment (mTLS)
staff-iam does not ship production device-cert mint. Default POST /enroll/csr is an honest 501 scaffold (error: "not_implemented"). There is no production CA in this service.
LAB ONLY exception (never production): when LAB_CA_ENABLE=1 and a lab PKI directory exists (lab-pki/ or LAB_PKI_DIR, initialized via scripts/lab-test-ca.sh init), POST /enroll/csr may shell-out to lab-test-ca.sh sign and return 201 with leaf cert PEM plus response headers X-Lab-CA: 1 and X-Lab-CA-Warning: NOT-FOR-PRODUCTION. NODE_ENV=production always refuses this path even if the flag is set. Do not trust lab roots on real staff devices or production proxies.
TLS client-auth for real operators still terminates at the reverse proxy (nginx, Caddy, Envoy, etc.). This service:
- Stores an enrollment flag per user (
mtlsEnrolled, set by IAM admin viaPUT /users/:username). - Reads proxy-forwarded headers to decide if a client cert was present on the request.
- Optionally enforces that pair at login when
STAFF_MTLS_ENFORCE=1. - Publishes
GET /enroll/csr/schema(fields only) and keeps defaultPOST /enroll/csras 501 until live production PKI ships (PKI-MINT-BACKLOG.md).
TOTP is orthogonal — see 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/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 (default 501 + PEM shape gate; optional lab 201)
Default (production path): scaffold only — never a production mint.
Body csr |
LAB_CA_ENABLE + lab-pki |
HTTP | Result |
|---|---|---|---|
| Missing / empty body | any | 501 | not_implemented |
| Invalid PEM shape | any | 400 | invalid_csr_pem |
| Valid-looking PEM | off (default) or lab-pki missing | 501 | not_implemented |
| Valid-looking PEM | on + lab-pki ready + not production | 201 | certificate PEM + X-Lab-CA: 1 (LAB ONLY) |
| Valid-looking PEM | on + lab-pki ready but sign fails | 502 | lab_ca_sign_failed + X-Lab-CA: 1 |
HTTP/1.1 501 Not Implemented
Content-Type: application/json
{ "error": "not_implemented", "nextSteps": [ /* proxy/PKI path + schema pointer */ ] }
Lab-only 201 (NEVER production)
# once per lab machine
./scripts/lab-test-ca.sh init --dir ./lab-pki
LAB_CA_ENABLE=1 npm start
# POST JSON { "csr": "-----BEGIN CERTIFICATE REQUEST-----..." }
# → 201 + X-Lab-CA: 1 + { ok, lab:true, warning, certificate, chain? }
NODE_ENV=production refuses lab mint even when LAB_CA_ENABLE=1. Shape check is headers only before lab sign (openssl does real parse on sign). Every attempt writes audit csr_attempt with outcome/pemShape/labCa flags — CSR PEM text is never stored. Details: TEST-CA-DESIGN.md, PKI-MINT-BACKLOG.md.
GET /enroll/status
Unauthenticated-friendly status for login banners and My security / admin panels. Cookie/Bearer optional (signed-in users get their own mtlsEnrolled and TOTP flags).
Response fields
| Field | Type | Meaning |
|---|---|---|
ok |
boolean | Always true on 200 |
updatedAt |
string | ISO-8601 timestamp when this response was generated |
state |
string | One of not_enrolled, pending_proxy, enrolled, enforce_on |
stateLabel |
string | Short human label for UI chips |
note |
string | Operator-facing status sentence |
mint |
string | Copy only: where minting happens (proxy/PKI — not here) |
renew |
string | Copy only: how to renew (proxy only) |
nextSteps |
string[] |
Actionable checklist for this state (admin/login UX; no PKI mint) |
csrSchemaUrl |
string | Always "/enroll/csr/schema" — path to future CSR body field map (GET → 200, no mint) |
csrPostStatus |
number | 501 by default (scaffold). 201 only when LAB_CA_ENABLE=1 and lab-pki is ready (lab path may mint; still not production) |
labCa |
object | { enabled, ready, warning } — LAB ONLY probe; enabled false under NODE_ENV=production |
mtlsEnrolled |
boolean | User flag (false if not signed in). Deprecated alias of enrollment; prefer state |
mtlsPresent |
boolean | Proxy reported a client cert on this request |
mtlsEnforce |
boolean | STAFF_MTLS_ENFORCE is on |
proxyHeadersSeen |
object | Boolean summary only — no PEM/DN strings: verify, verifyOk, dn, pem, any |
proxy |
object | Header probe: verify, verifyOk, hasPem, hasDn, subjectDn (never full PEM) |
signedIn |
boolean | Request carried a valid staff session |
totpEnabled |
boolean | User has authenticator 2FA on (false if anonymous) |
totpRequired |
boolean | STAFF_TOTP_REQUIRED policy is on |
totpEnrollmentRequired |
boolean | Policy on and this user still needs /2fa enroll |
csrSchemaUrl and csrPostStatus are present on every /enroll/status response (all states, signed-in or anonymous) so UIs can link the schema and show “mint not implemented” without probing POST.
proxyHeadersSeen
Pure flags for UI chips (login + My security). Never includes cert bodies or DN text:
| Key | True when |
|---|---|
verify |
Any verify-result header was present (value may still be fail) |
verifyOk |
Verify header is SUCCESS / OK / PASSED |
dn |
Subject DN header non-empty |
pem |
Forwarded cert/PEM header non-empty (presence only — body not parsed) |
any |
Same as mtlsPresent (verifyOk || dn || pem) |
States
state |
When | Login impact |
|---|---|---|
not_enrolled |
User not marked mtlsEnrolled (or anonymous) |
Password (+ TOTP) only |
pending_proxy |
Enrolled, enforce off, no cert headers on this request | Password still works; show “present cert via proxy” |
enrolled |
Enrolled, enforce off, cert headers present | Password works; cert seen |
enforce_on |
Enrolled and STAFF_MTLS_ENFORCE=1 |
Login requires proxy cert headers or 401 {error:"cert"} |
nextSteps by state
Ordered checklist strings for UI panels. Always proxy/PKI + IAM flag actions — never a CSR or cert body from this API.
state |
Typical next steps (summary) |
|---|---|
not_enrolled |
Issue cert at proxy/PKI → note POST /enroll/csr is 501 scaffold only → confirm proxy.* on /enroll/status → admin PUT mtlsEnrolled: true → only then consider STAFF_MTLS_ENFORCE=1 |
pending_proxy |
Present cert via reverse proxy → forward verify/DN headers → re-check until mtlsPresent / enrolled → mint/renew only at proxy if missing |
enrolled |
No login gate while enforce off → renew at proxy before expiry → optional enforce → new devices need fresh proxy cert |
enforce_on |
Sign in only via mTLS proxy → on 401 {error:"cert"} fix proxy verify (not this API) → renew at proxy → relax via clear enforce or mtlsEnrolled: false |
Example (anonymous / not enrolled):
{
"ok": true,
"updatedAt": "2026-10-13T20:00:00.000Z",
"state": "not_enrolled",
"stateLabel": "Not enrolled",
"csrSchemaUrl": "/enroll/csr/schema",
"csrPostStatus": 501,
"proxyHeadersSeen": {
"verify": false,
"verifyOk": false,
"dn": false,
"pem": false,
"any": false
},
"nextSteps": [
"Issue a workstation client certificate in your reverse-proxy / internal PKI (not this service).",
"POST /enroll/csr is scaffold only and returns 501 {error:\"not_implemented\"} — no live cert mint here.",
"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.",
"Only after the proxy verifies clients, optionally set STAFF_MTLS_ENFORCE=1."
],
"totpEnabled": false,
"totpRequired": false,
"totpEnrollmentRequired": false
}
How the login UI uses these
| Surface | File | Behavior |
|---|---|---|
| Sign-in card | public/login.html |
On load, GET /enroll/status. Renders stateLabel + note, mono updatedAt, proxyHeadersSeen yes/no chips, and an ordered nextSteps list under the form. Sets data-state on the banner. Does not offer cert mint. |
| Login errors | same | ?error=cert or JSON 401 {error:"cert"} → “No valid device certificate…”. ?error=totp / totp_required → show authenticator field (see TOTP.md). |
| My security | public/app.html |
Device-cert panel loads the same endpoint: state pill, note, updatedAt, proxyHeadersSeen chips, nextSteps checklist. TOTP gate is separate (totpEnrollmentRequired). |
| Edge admin | zappier-edge | Proxies or falls back copy-only enroll status; still no PKI mint. |
Proxy header contract
Forward at least one of these after successful client TLS auth. Values are case-insensitive where noted.
Verify result (preferred)
| Header | Success values |
|---|---|
X-SSL-Client-Verify |
SUCCESS, OK, PASSED |
SSL-Client-Verify |
same |
X-Client-Verify |
same |
nginx example after ssl_verify_client on:
proxy_set_header X-SSL-Client-Verify $ssl_client_verify;
proxy_set_header X-SSL-Client-DN $ssl_client_s_dn;
Subject DN
| Header | Notes |
|---|---|
X-SSL-Client-DN |
Non-empty DN ⇒ present |
SSL-Client-Subject-DN |
same |
PEM / forwarded cert (presence only)
| Header | Notes |
|---|---|
X-Forwarded-Tls-Client-Cert |
Any non-empty value ⇒ present (body not parsed) |
SSL-Client-Cert |
same |
staff-iam never parses the PEM, checks expiry, or matches CN to username. Binding is the IAM mtlsEnrolled flag plus “proxy said verify succeeded.”
Do not
- Send spoofable client-cert headers from the public internet without stripping them at the edge.
- Expect this service to return a certificate or CSR.
- Turn on
STAFF_MTLS_ENFORCEbefore the proxy actually verifies clients. - Build UI that pretends staff-iam mints device certs.
Admin workflow
- Issue workstation client certs in your PKI; configure the reverse proxy for mTLS on staff hosts.
- Confirm
GET /enroll/statusshowsproxy.verifyOkorproxy.hasDn/proxy.hasPemwhen the browser presents the cert (proxyHeadersSeen.any === true). PUT /users/:usernamewith{ "mtlsEnrolled": true }(requiresiam.users.write/ owner).- Optionally set
STAFF_MTLS_ENFORCE=1so enrolled users must present the cert at login. - UI consumers read
state+updatedAt+nextSteps[]+proxyHeadersSeen— no crypto UI required.
Related env
| Variable | Effect |
|---|---|
STAFF_MTLS_ENFORCE=1 |
Login requires proxy cert headers when user.mtlsEnrolled |
STAFF_TOTP_REQUIRED=1 |
Orthogonal TOTP enroll gate — TOTP.md |
LAB_CA_ENABLE=1 |
LAB ONLY. Allows POST /enroll/csr to shell-out to scripts/lab-test-ca.sh sign when lab-pki exists → 201 + X-Lab-CA: 1. Default off → 501. Refused when NODE_ENV=production. Never production. |
LAB_PKI_DIR |
Optional path to lab CA dir (default ./lab-pki under the service root). Must contain lab-root.key.pem + lab-root.crt.pem from lab-test-ca.sh init. |
Related docs
- TOTP.md — authenticator setup,
STAFF_TOTP_REQUIRED, login gate - README.md — install, usage, seed users
- SUMMARY.md — one-line service summary