docs: CRL-OCSP-DESIGN + lab-test-ca crl subcommand

Future real-CA revocation design; lab empty CRL generator only.
This commit is contained in:
George Lambert 2026-10-14 01:16:53 -04:00
commit a973431935
4 changed files with 250 additions and 8 deletions

160
docs/CRL-OCSP-DESIGN.md Normal file
View file

@ -0,0 +1,160 @@
# CRL / OCSP design (future real CA)
**Status:** design only — **not implemented** in staff-iam.
- No CRL distribution endpoint
- No OCSP responder
- No revocation API on enroll/mint paths
- Lab helper may emit an **empty** CRL file for local proxy experiments only ([`scripts/lab-test-ca.sh`](../scripts/lab-test-ca.sh) `crl`)
Production device-cert **mint** is still out of scope ([PKI-MINT-BACKLOG.md](./PKI-MINT-BACKLOG.md)). Lab mint (`LAB_CA_ENABLE=1`) is never a production CA and must not be treated as a revocation authority.
Canonical enroll contract: [ENROLL.md](../ENROLL.md). Lab CA: [TEST-CA-DESIGN.md](./TEST-CA-DESIGN.md). Platform revocation baseline: short lifetimes first; CRL/OCSP for intermediates and offboard bulk revoke (security-reference §6.4).
## Purpose
When (and only when) product ships a **real** production `Signer` for staff device certificates, operators need a documented revocation story:
1. How a lost/stolen workstation cert is invalidated before natural expiry.
2. How reverse proxies and mTLS terminators learn about revocations.
3. What staff-iam stores (metadata only) vs what the CA / edge owns.
4. What stays **out** of the IAM Node process (no in-process OCSP, no fake “revoked” demo PEMs).
This document is that story. It does **not** authorize implementing CRL/OCSP inside staff-iam today.
## Non-goals (now)
| Non-goal | Why |
|----------|-----|
| Ship CRL or OCSP HTTP from staff-iam | IAM is not a CA; default mint stays **501** |
| Soften mTLS with a stub “revoked” flag that is not backed by CA state | Violates “no fake crypto” |
| Require CRL checks for lab-only leaves | Lab leaves are short-lived (≤30d script cap; default 7d); throwaway trust |
| Put private CA keys or OCSP signing keys in the IAM user store | Keys stay in HSM / external PKI / `LAB_PKI_DIR` (lab only) |
| Browser “Revoke cert” that only flips `mtlsEnrolled` | Admin flag is enrollment policy, not cryptographic revocation |
## Recommended production model
Align with Verae security-reference provisioning:
| Control | Role |
|---------|------|
| **Short leaf lifetime** | Primary control (hours–days for staff device certs; renew via real mint path) |
| **CRL and/or OCSP on issuing intermediate** | Secondary control for early revoke and offboard |
| **Proxy / edge termination** | Performs chain verify **and** revocation check; forwards verify/DN headers to IAM |
| **staff-iam** | Stores enrollment flag + optional cert **metadata** (serial, fingerprint, notAfter); never signs CRL/OCSP itself unless product later splits a dedicated PKI service |
```
Corporate / HSM CA (or EST/SCEP/ACME delegate)
│ signs leaves
│ publishes CRL URI and/or OCSP URL in AIA/CRLDP
▼
Reverse proxy (mTLS terminate)
│ verify + CRL/OCSP (or short-lived-only policy)
│ forward ssl-client-verify / DN / optional PEM presence
▼
staff-iam GET /enroll/status · STAFF_MTLS_ENFORCE
│ mtlsEnrolled flag + future serial metadata
▼
Login / My security UI
```
**Preferred default for staff workstations:** short-lived client certs + automated renew, with OCSP stapling or CRL fetch at the **proxy**, not inside the IAM app.
## Options (choose at live-mint design time)
### A — Proxy-side only (recommended first)
- Issuing CA publishes CRL Distribution Points and/or OCSP in leaf/intermediate extensions.
- nginx / Caddy / Envoy / corporate WAF enables client-cert verify + revocation.
- staff-iam continues to trust `proxy.verifyOk` / DN headers only.
- Offboard: admin sets `mtlsEnrolled: false` **and** CA revokes serial (runbook both).
**Pros:** no new IAM crypto surface; matches today’s header contract.
**Cons:** revoke latency depends on CRL TTL / OCSP cache; operators must wire proxy correctly.
### B — CA-adjacent PKI service (not in IAM process)
- Dedicated signer service owns issue + revoke + CRL/OCSP endpoints.
- staff-iam calls `Signer.issue` / `Signer.revoke(serial)` over mTLS; never holds CA key.
- Status UI may show “revoked at CA” from metadata poll, not by serving CRL bytes.
**Pros:** clean AuthZ boundary; scalable OCSP.
**Cons:** new service + ops; still need proxy to enforce at TLS.
### C — In-process staff-iam OCSP/CRL (discouraged)
- Only if product explicitly requires IAM to be the issuing CA (unlikely).
- Would need HSM, AIA URLs, cache, audit, rate limits, and production hardening.
- **Do not** start here; backlog assumes external/HSM/EST signer first.
## Data staff-iam may hold later (metadata only)
When live mint ships, persist **at most**:
| Field | Use |
|-------|-----|
| `serial` (hex) | Correlate revoke + support |
| `fingerprint` (SHA-256 of DER) | Display / audit |
| `notAfter` | Renew warnings |
| `revokedAt` / `revokeReason` | UI + audit after CA confirms |
| `deviceId` (optional) | Bind to workstation record |
Never store leaf private keys, PKCS#12 passwords, or full cert PEM in the user JSON unless a separate encrypted secret store is designed. Audit events may log serial/fingerprint flags only (same rule as today’s `csr_attempt` — no PEM bodies).
## Revoke / offboard runbook (future)
1. Confirm identity (staff user + device).
2. Call CA revoke (CRL entry and/or OCSP revoked status) with reason (`keyCompromise` / `cessationOfOperation`).
3. Set `mtlsEnrolled: false` (and any device record) in IAM.
4. Optionally kill active sessions (orthogonal to cert crypto).
5. Wait for proxy CRL/OCSP cache TTL or force cache flush.
6. Verify: client cert no longer yields `proxy.verifyOk`; login enforce path denies.
Bulk tenant/staff offboard: revoke **all** serials bound to that principal at the CA, then clear IAM flags.
## Lab only — empty CRL helper
[`scripts/lab-test-ca.sh`](../scripts/lab-test-ca.sh) optional subcommand:
```sh
./scripts/lab-test-ca.sh crl [--dir DIR] [--out PATH]
```
- Requires an initialized lab CA (`init`).
- Writes a **valid, empty** CRL (no revoked serials) signed by the lab root, default `DIR/lab-empty.crl.pem`.
- Purpose: let a **local** dev proxy load a CRL file path so verify+CRL config can be smoke-tested without implying production revocation.
- **NOT FOR PRODUCTION.** Empty CRL does not revoke anything; lab root must not be in real trust stores.
- Does **not** add HTTP CRL/OCSP routes to staff-iam.
## HTTP surfaces (not shipped)
| Surface | Today | Future (product decision) |
|---------|--------|---------------------------|
| `POST /enroll/csr` | 501 default; lab 201 only | Real sign; still no CRL body |
| `POST /enroll/revoke` (name TBD) | absent | Optional: AuthZ’d request that calls external `Signer.revoke` |
| `GET /crl/...` or OCSP | absent | Prefer CA/proxy hostnames in cert AIA/CRLDP — not staff-iam origin |
| `GET /enroll/status` | proxy headers + flags | May add serial / notAfter / revokedAt metadata |
## Implementation order (blocked on product)
1. Choose production CA model in [PKI-MINT-BACKLOG.md](./PKI-MINT-BACKLOG.md) (external ACME/EST/HSM/delegated).
2. Decide Option A vs B for revocation enforcement (default **A**).
3. Document proxy verify+CRL/OCSP settings in operator ENROLL runbook.
4. Add `Signer.revoke` + IAM metadata fields when mint is real.
5. Only then consider bulk offboard automation and status UI revoke state.
6. **Never** enable lab empty-CRL or lab root as a production trust anchor.
## Hard rules
1. **No fake revocation** — do not return success on a revoke API that only flips a boolean without CA action.
2. **Proxy remains TLS source of truth** for “cert valid on this request.”
3. **Lab CRL is empty + lab-signed only** — experiments, not fleet security.
4. **Short lifetime remains the primary control**; CRL/OCSP are complementary.
5. Cross-link live-mint PRs: this doc + [PKI-MINT-BACKLOG.md](./PKI-MINT-BACKLOG.md) + [ENROLL.md](../ENROLL.md) + [TEST-CA-DESIGN.md](./TEST-CA-DESIGN.md).
## Related docs
- [PKI-MINT-BACKLOG.md](./PKI-MINT-BACKLOG.md) — live mint checklist; CRL/OCSP item
- [TEST-CA-DESIGN.md](./TEST-CA-DESIGN.md) — lab CA init/sign; never production
- [ENROLL.md](../ENROLL.md) — mTLS states, proxy headers, CSR scaffold
- [README.md](../README.md) — service overview and security gates

View file

@ -79,10 +79,10 @@ Still left for live PKI: full checklist under “Left for live PKI” below.
Shipped for **lab/dev only** (loud warnings; never production): Shipped for **lab/dev only** (loud warnings; never production):
- [x] [`scripts/lab-test-ca.sh`](../scripts/lab-test-ca.sh) — OpenSSL self-signed lab root (`init`) + sign CSR → leaf PEM/chain (`sign`); loud **NOT FOR PRODUCTION** warnings; default output `lab-pki/` (gitignored) - [x] [`scripts/lab-test-ca.sh`](../scripts/lab-test-ca.sh) — OpenSSL self-signed lab root (`init`) + sign CSR → leaf PEM/chain (`sign`); optional empty **`crl`**; loud **NOT FOR PRODUCTION** warnings; default output `lab-pki/` (gitignored)
- [x] **Opt-in HTTP:** `LAB_CA_ENABLE=1` + lab-pki ready → `POST /enroll/csr` shells out to `sign` → **201** + `certificate` PEM + `X-Lab-CA: 1` - [x] **Opt-in HTTP:** `LAB_CA_ENABLE=1` + lab-pki ready → `POST /enroll/csr` shells out to `sign` → **201** + `certificate` PEM + `X-Lab-CA: 1`
- [x] **Default off** → HTTP path stays **501**; `NODE_ENV=production` refuses lab path - [x] **Default off** → HTTP path stays **501**; `NODE_ENV=production` refuses lab path
- [x] Documented in [ENROLL.md](../ENROLL.md) + [TEST-CA-DESIGN.md](./TEST-CA-DESIGN.md) - [x] Documented in [ENROLL.md](../ENROLL.md) + [TEST-CA-DESIGN.md](./TEST-CA-DESIGN.md) + [CRL-OCSP-DESIGN.md](./CRL-OCSP-DESIGN.md)
| Layer | Lab path | Production mint (backlog below) | | Layer | Lab path | Production mint (backlog below) |
|-------|----------|----------------------------------| |-------|----------|----------------------------------|
@ -129,7 +129,7 @@ Checklist for a real in-app mint (product + security design required before codi
- [ ] Persist serial / fingerprint / notAfter for status and renew gates (store metadata only) - [ ] Persist serial / fingerprint / notAfter for status and renew gates (store metadata only)
- [ ] `GET /enroll/status` (or successor) surfaces real cert metadata when present - [ ] `GET /enroll/status` (or successor) surfaces real cert metadata when present
- [ ] Renew / re-key endpoint and expiry warnings; revoke on offboard - [ ] Renew / re-key endpoint and expiry warnings; revoke on offboard
- [ ] CRL/OCSP or proxy-side revocation story documented - [x] ~~CRL/OCSP or proxy-side revocation story documented~~ — [CRL-OCSP-DESIGN.md](./CRL-OCSP-DESIGN.md); lab empty CRL via `lab-test-ca.sh crl` (not production)
- [ ] Remove 501 scaffold; keep “no fake crypto” rule for tests (fixtures may use offline [`scripts/lab-test-ca.sh`](../scripts/lab-test-ca.sh) or a future gated test CA — see [TEST-CA-DESIGN.md](./TEST-CA-DESIGN.md); **never** default-on in server) - [ ] Remove 501 scaffold; keep “no fake crypto” rule for tests (fixtures may use offline [`scripts/lab-test-ca.sh`](../scripts/lab-test-ca.sh) or a future gated test CA — see [TEST-CA-DESIGN.md](./TEST-CA-DESIGN.md); **never** default-on in server)
- [ ] Operator runbooks + ENROLL.md / README updated for the live path - [ ] Operator runbooks + ENROLL.md / README updated for the live path
@ -146,7 +146,8 @@ If product later needs in-app production mint/renew, that is a **new** design (r
## Related docs ## Related docs
- [TEST-CA-DESIGN.md](./TEST-CA-DESIGN.md) — lab script (offline) + **future** gated test CA design; no HTTP fake mint - [TEST-CA-DESIGN.md](./TEST-CA-DESIGN.md) — lab script (offline) + **future** gated test CA design; no HTTP fake mint
- [`scripts/lab-test-ca.sh`](../scripts/lab-test-ca.sh) — OpenSSL lab CA init/sign (NOT production; optional `LAB_CA_ENABLE=1` HTTP shell-out) - [CRL-OCSP-DESIGN.md](./CRL-OCSP-DESIGN.md) — future real-CA revocation (proxy CRL/OCSP); lab empty CRL only
- [`scripts/lab-test-ca.sh`](../scripts/lab-test-ca.sh) — OpenSSL lab CA init/sign/crl (NOT production; optional `LAB_CA_ENABLE=1` HTTP shell-out)
- [ENROLL.md](../ENROLL.md) — mTLS states, `nextSteps`, `proxyHeadersSeen`, proxy header contract, admin workflow - [ENROLL.md](../ENROLL.md) — mTLS states, `nextSteps`, `proxyHeadersSeen`, proxy header contract, admin workflow
- [README.md](../README.md) — install, usage, staff security gates (mTLS row: proxy headers only, no device-cert mint) - [README.md](../README.md) — install, usage, staff security gates (mTLS row: proxy headers only, no device-cert mint)
- [TOTP.md](../TOTP.md) — authenticator 2FA (orthogonal gate) - [TOTP.md](../TOTP.md) — authenticator 2FA (orthogonal gate)

View file

@ -15,7 +15,7 @@ Live mint product backlog and hard “no fake crypto” rules: [PKI-MINT-BACKLOG
| Item | Detail | | Item | Detail |
|------|--------| |------|--------|
| Path | [`scripts/lab-test-ca.sh`](../scripts/lab-test-ca.sh) | | Path | [`scripts/lab-test-ca.sh`](../scripts/lab-test-ca.sh) |
| Offline | `init` → self-signed lab root under `lab-pki/` (gitignored); `sign --csr` → short-lived leaf PEM + chain on disk | | Offline | `init` → self-signed lab root under `lab-pki/` (gitignored); `sign --csr` → short-lived leaf PEM + chain on disk; optional `crl` → empty lab CRL |
| Opt-in HTTP | `LAB_CA_ENABLE=1` + lab-pki ready → staff-iam shells out to `sign` on `POST /enroll/csr` → **201** + `X-Lab-CA: 1` | | Opt-in HTTP | `LAB_CA_ENABLE=1` + lab-pki ready → staff-iam shells out to `sign` on `POST /enroll/csr` → **201** + `X-Lab-CA: 1` |
| Default | Flag **off** → `POST /enroll/csr` **501** (unchanged production scaffold) | | Default | Flag **off** → `POST /enroll/csr` **501** (unchanged production scaffold) |
| Production refuse | `NODE_ENV=production` → lab path off even if `LAB_CA_ENABLE=1` | | Production refuse | `NODE_ENV=production` → lab path off even if `LAB_CA_ENABLE=1` |
@ -92,7 +92,7 @@ Proxy trusts corp roots Dev proxy may trust lab-pki root (lab only)
``` ```
platform/verae-staff-iam/ platform/verae-staff-iam/
scripts/ scripts/
lab-test-ca.sh # LAB ONLY: init root + sign CSR → PEM on disk lab-test-ca.sh # LAB ONLY: init root + sign CSR + empty crl → PEM on disk
lab-pki/ # default output dir (gitignored; keys never committed) lab-pki/ # default output dir (gitignored; keys never committed)
``` ```
@ -186,6 +186,7 @@ Exact algorithms should match whatever production CA model is selected in [PKI-M
## Related docs ## Related docs
- [PKI-MINT-BACKLOG.md](./PKI-MINT-BACKLOG.md) — shipped proxy/schema/501 surface; live mint checklist; no-fake-crypto rules - [PKI-MINT-BACKLOG.md](./PKI-MINT-BACKLOG.md) — shipped proxy/schema/501 surface; live mint checklist; no-fake-crypto rules
- [CRL-OCSP-DESIGN.md](./CRL-OCSP-DESIGN.md) — future production CRL/OCSP; lab empty CRL helper
- [ENROLL.md](../ENROLL.md) — mTLS states, proxy headers, CSR schema/scaffold - [ENROLL.md](../ENROLL.md) — mTLS states, proxy headers, CSR schema/scaffold
- [README.md](../README.md) — service overview and security gates - [README.md](../README.md) — service overview and security gates
- [TOTP.md](../TOTP.md) — orthogonal authenticator 2FA (real crypto; unrelated to device-cert mint) - [TOTP.md](../TOTP.md) — orthogonal authenticator 2FA (real crypto; unrelated to device-cert mint)

View file

@ -16,6 +16,7 @@
# Usage: # Usage:
# scripts/lab-test-ca.sh init [--dir DIR] # scripts/lab-test-ca.sh init [--dir DIR]
# scripts/lab-test-ca.sh sign --csr PATH [--dir DIR] [--out PATH] [--days N] [--cn CN] # scripts/lab-test-ca.sh sign --csr PATH [--dir DIR] [--out PATH] [--days N] [--cn CN]
# scripts/lab-test-ca.sh crl [--dir DIR] [--out PATH]
# scripts/lab-test-ca.sh warn # scripts/lab-test-ca.sh warn
# #
# Defaults: DIR=./lab-pki (gitignored). Requires openssl on PATH. # Defaults: DIR=./lab-pki (gitignored). Requires openssl on PATH.
@ -53,13 +54,18 @@ Usage:
--days leaf notAfter (default ${DAYS_DEFAULT}, max 30 for lab) --days leaf notAfter (default ${DAYS_DEFAULT}, max 30 for lab)
--cn optional subject CN override in the issued cert (lab only) --cn optional subject CN override in the issued cert (lab only)
$SCRIPT_NAME crl [--dir DIR] [--out PATH]
Write a lab-only **empty** CRL (no revoked serials) signed by the lab root.
--out defaults to DIR/lab-empty.crl.pem
For local proxy CRL-path smoke tests only — NOT production revocation.
$SCRIPT_NAME warn $SCRIPT_NAME warn
Print the lab-only warning and exit 0. Print the lab-only warning and exit 0.
Environment: Environment:
LAB_PKI_DIR Override default DIR when --dir omitted. LAB_PKI_DIR Override default DIR when --dir omitted.
Exit codes: 0 ok · 1 usage/input · 2 missing openssl/CA · 3 sign failure Exit codes: 0 ok · 1 usage/input · 2 missing openssl/CA · 3 sign/CRL failure
EOF EOF
} }
@ -139,7 +145,7 @@ Root cert: lab-root.crt.pem (public)
Root key: lab-root.key.pem (PRIVATE — never commit, never production trust) Root key: lab-root.key.pem (PRIVATE — never commit, never production trust)
With LAB_CA_ENABLE=1 the staff-iam server may shell-out sign against this dir. With LAB_CA_ENABLE=1 the staff-iam server may shell-out sign against this dir.
Default (flag off) POST /enroll/csr stays 501. NEVER production. Default (flag off) POST /enroll/csr stays 501. NEVER production.
See docs/TEST-CA-DESIGN.md, ENROLL.md, docs/PKI-MINT-BACKLOG.md. See docs/TEST-CA-DESIGN.md, docs/CRL-OCSP-DESIGN.md, ENROLL.md, docs/PKI-MINT-BACKLOG.md.
EOF EOF
echo "lab CA created:" echo "lab CA created:"
@ -254,6 +260,79 @@ EOF
warn_banner warn_banner
} }
cmd_crl() {
local dir="" out=""
while [[ $# -gt 0 ]]; do
case "$1" in
--dir) dir="${2:-}"; shift 2 ;;
--out) out="${2:-}"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "error: unknown crl arg: $1" >&2; usage >&2; exit 1 ;;
esac
done
need_openssl
warn_banner
dir="$(resolve_dir "$dir")"
local key="$dir/lab-root.key.pem"
local ca="$dir/lab-root.crt.pem"
if [[ ! -f "$key" || ! -f "$ca" ]]; then
echo "error: lab CA missing under $dir — run: $SCRIPT_NAME init --dir $dir" >&2
exit 2
fi
if [[ -z "$out" ]]; then
out="$dir/lab-empty.crl.pem"
fi
# Empty CRL: no revoked serials. Lab smoke-test fixture only.
local crl_cnf="$dir/lab-crl.cnf"
local crl_idx="$dir/lab-crl-index.txt"
local crl_attr="$dir/lab-crl-index.txt.attr"
: >"$crl_idx"
echo "unique_subject = no" >"$crl_attr"
cat >"$crl_cnf" <<EOF
[ ca ]
default_ca = lab_ca
[ lab_ca ]
dir = $dir
database = $crl_idx
serial = $dir/lab-root.srl
certificate = $ca
private_key = $key
default_md = sha256
default_days = 30
default_crl_days = 30
crlnumber = $dir/lab-crlnumber
copy_extensions = none
policy = policy_any
[ policy_any ]
commonName = optional
EOF
if [[ ! -f "$dir/lab-crlnumber" ]]; then
echo "01" >"$dir/lab-crlnumber"
fi
if ! openssl ca -gencrl -config "$crl_cnf" -out "$out" 2>"$dir/last-crl.err"; then
echo "error: openssl gencrl failed:" >&2
cat "$dir/last-crl.err" >&2 || true
exit 3
fi
rm -f "$dir/last-crl.err"
echo "lab empty CRL written (NOT FOR PRODUCTION):"
echo " crl: $out"
echo " revoked entries: 0 (empty)"
openssl crl -in "$out" -noout -text 2>/dev/null | head -n 20 || true
echo
echo "Dev proxy only: point CRL path at this file if testing verify+CRL config."
echo "See docs/CRL-OCSP-DESIGN.md — empty lab CRL, never production revocation."
warn_banner
}
main() { main() {
local cmd="${1:-}" local cmd="${1:-}"
if [[ -z "$cmd" ]]; then if [[ -z "$cmd" ]]; then
@ -265,6 +344,7 @@ main() {
case "$cmd" in case "$cmd" in
init) cmd_init "$@" ;; init) cmd_init "$@" ;;
sign) cmd_sign "$@" ;; sign) cmd_sign "$@" ;;
crl) cmd_crl "$@" ;;
warn) warn_banner; exit 0 ;; warn) warn_banner; exit 0 ;;
-h|--help|help) usage; exit 0 ;; -h|--help|help) usage; exit 0 ;;
*) *)