verae-staff-iam/ENROLL.md
George Lambert ca117eb458 feat: LAB_CA_ENABLE opt-in lab mint via lab-test-ca.sh (prod refused)
POST /enroll/csr returns 201 + X-Lab-CA when lab-pki ready; default 501.
2026-10-14 01:08:21 -04:00

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:

  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. Publishes GET /enroll/csr/schema (fields only) and keeps default POST /enroll/csr as 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_ENFORCE before the proxy actually verifies clients.
  • Build UI that pretends staff-iam mints device certs.

Admin workflow

  1. Issue workstation client certs in your PKI; configure the reverse proxy for mTLS on staff hosts.
  2. Confirm GET /enroll/status shows proxy.verifyOk or proxy.hasDn / proxy.hasPem when the browser presents the cert (proxyHeadersSeen.any === true).
  3. PUT /users/:username with { "mtlsEnrolled": true } (requires iam.users.write / owner).
  4. Optionally set STAFF_MTLS_ENFORCE=1 so enrolled users must present the cert at login.
  5. UI consumers read state + updatedAt + nextSteps[] + proxyHeadersSeen — no crypto UI required.
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.
  • TOTP.md — authenticator setup, STAFF_TOTP_REQUIRED, login gate
  • README.md — install, usage, seed users
  • SUMMARY.md — one-line service summary