feat: lab-test-ca.sh openssl lab CA (not wired to enroll)

LAB ONLY self-signed root + sign CSR; POST /enroll/csr still 501.
This commit is contained in:
George Lambert 2026-10-14 01:00:38 -04:00
commit 7a211356a7
4 changed files with 358 additions and 24 deletions

2
.gitignore vendored
View file

@ -1 +1,3 @@
node_modules/
# Offline lab CA material from scripts/lab-test-ca.sh (never commit keys)
lab-pki/

View file

@ -75,6 +75,24 @@ Shipped this pass (still **no mint**):
Still left for live PKI: full checklist under “Left for live PKI” below.
### Lab offline CA script (P0 Issue 4 advance — still **no HTTP mint**)
Shipped for **lab/dev disk fixtures only**:
- [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] **Not** wired into `POST /enroll/csr`, server boot, or any default env flag — HTTP path stays **501**
- [x] Documented relationship to future live mint in this file + [TEST-CA-DESIGN.md](./TEST-CA-DESIGN.md)
**How the lab script relates to future live mint**
| Layer | Role of `lab-test-ca.sh` | Live mint (backlog below) |
|-------|--------------------------|---------------------------|
| Today | Manual offline OpenSSL CA for local experiments / future fixture prep | Not shipped; CSR route is 501 |
| When live mint ships | May generate throwaway leaves for **dev proxy** trust tests and unit fixtures | Production `Signer` (HSM/EST/ACME/delegated) issues real certs via AuthZ’d `POST /enroll/csr` |
| Never | Become the default server mint path, production trust anchor, or silent 200 on `/enroll/csr` | Must not call the lab script from production `NODE_ENV` |
Operators continue to issue workstation certs at external reverse-proxy / internal PKI. Developers who need a PEM on disk run the script themselves; they still must not expect staff-iam HTTP to return cert material.
### 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.
@ -112,21 +130,23 @@ Checklist for a real in-app mint (product + security design required before codi
- [ ] `GET /enroll/status` (or successor) surfaces real cert metadata when present
- [ ] Renew / re-key endpoint and expiry warnings; revoke on offboard
- [ ] CRL/OCSP or proxy-side revocation story documented
- [ ] Remove 501 scaffold; keep “no fake crypto” rule for tests (fixtures may use a test CA only — see [TEST-CA-DESIGN.md](./TEST-CA-DESIGN.md))
- [ ] 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
If product later needs in-app mint/renew, that is a **new** design (real CA integration or delegated signer), not an extension of the current status strings or a softened 501. Until then, keep mint/renew copy pointing at the proxy/PKI, keep `/enroll/status` read-only with respect to crypto, keep `GET /enroll/csr/schema` fields-only, and keep `POST /enroll/csr` as 501-only.
## Hard rules
1. **No fake crypto** — do not add endpoints, fixtures, or UI that return made-up certs, keys, or signed CSRs. The CSR scaffold’s only success criteria is an honest **501**.
1. **No fake crypto** — do not add endpoints, fixtures, or UI that return made-up certs, keys, or signed CSRs over HTTP. The CSR scaffold’s only success criteria is an honest **501**. Offline lab PEMs from `scripts/lab-test-ca.sh` stay on disk and must not be served as mint responses.
2. **Proxy is source of truth** for “cert present on this request”; IAM flag is source of truth for “user is enrolled for enforce.”
3. Do not turn on `STAFF_MTLS_ENFORCE` until the proxy actually verifies clients (see [ENROLL.md](../ENROLL.md) “Do not”).
4. TOTP remains orthogonal ([TOTP.md](../TOTP.md)); authenticator enroll is real crypto (RFC 6238) and is unrelated to device-cert mint.
4. **Never enable lab CA in the default server path** — no import of `lab-test-ca.sh`, no boot-time sign, no implicit trust of `lab-pki/`.
5. TOTP remains orthogonal ([TOTP.md](../TOTP.md)); authenticator enroll is real crypto (RFC 6238) and is unrelated to device-cert mint.
## Related docs
- [TEST-CA-DESIGN.md](./TEST-CA-DESIGN.md) — **future only** design for a dev/CI test CA (not implemented; no 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; not wired to `/enroll/csr`)
- [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)
- [TOTP.md](../TOTP.md) — authenticator 2FA (orthogonal gate)

View file

@ -1,10 +1,32 @@
# Test CA design (future only)
# Test CA design (future only + lab offline script)
**Status: design only — not implemented.**
`verae-staff-iam` does **not** run a CA, sign CSRs, or return cert material today. `POST /enroll/csr` remains **501**. This document describes how a **future** offline/dev **test CA** *could* support automated tests once live mint is approved. It is not a license to add mint endpoints, demo certs, or softened 501 responses now.
**Status: design for in-process/CI test CA — not implemented in the server.**
Offline lab helper **shipped:** [`scripts/lab-test-ca.sh`](../scripts/lab-test-ca.sh) (OpenSSL self-signed root + CSR→PEM sign on disk only).
`verae-staff-iam` does **not** run a CA, sign CSRs, or return cert material over HTTP today. `POST /enroll/csr` remains **501**. This document describes (1) the **lab script** (manual/offline) and (2) how a **future** gated test CA *could* support automated tests once live mint is approved. It is not a license to add mint endpoints, demo certs, or softened 501 responses now.
Live mint product backlog and hard “no fake crypto” rules: [PKI-MINT-BACKLOG.md](./PKI-MINT-BACKLOG.md). Current proxy-only enroll contract: [ENROLL.md](../ENROLL.md).
## Lab script (shipped — offline only)
| Item | Detail |
|------|--------|
| Path | [`scripts/lab-test-ca.sh`](../scripts/lab-test-ca.sh) |
| What it does | `init` → self-signed lab root under `lab-pki/` (gitignored); `sign --csr` → short-lived leaf PEM + chain on disk |
| What it does **not** | Call staff-iam HTTP; change `POST /enroll/csr`; set any server env; trust itself in production |
| Warnings | Prints a loud **LAB ONLY / NOT FOR PRODUCTION** banner on every run |
| Relation to live mint | Fixture toolchain only. When product ships a real `Signer` + live `POST /enroll/csr`, this script (or a successor) may feed **dev proxy** trust stores and unit fixtures — it must **never** become the default server mint path |
```sh
# example (lab machine only)
./scripts/lab-test-ca.sh init --dir ./lab-pki
openssl req -new -newkey rsa:2048 -nodes -keyout dev.key -out dev.csr -subj "/CN=lab-user@staff.local"
./scripts/lab-test-ca.sh sign --dir ./lab-pki --csr dev.csr --out ./lab-pki/leaf.crt.pem
# Still: curl -X POST …/enroll/csr → 501 (server untouched)
```
Do **not** point production or shared staging reverse proxies at `lab-pki/lab-root.crt.pem`.
## Purpose
When (and only when) product ships real CSR intake + signing, CI and local dev need a **throwaway** certificate authority that:
@ -29,21 +51,37 @@ Until live mint ships, the only honest test of enroll CSR is: schema **200** fie
## Relationship to live PKI backlog
[PKI-MINT-BACKLOG.md](./PKI-MINT-BACKLOG.md) lists what production mint needs (CA model, AuthZ, CSR policy, bind to user, renew, revoke). This design covers **only** the test-side companion:
[PKI-MINT-BACKLOG.md](./PKI-MINT-BACKLOG.md) lists what production mint needs (CA model, AuthZ, CSR policy, bind to user, renew, revoke). Lab/test tooling is a **companion**, not a substitute:
```
Product mint (future) Test CA (future, this doc)
Product mint (future) Lab / test CA
───────────────────── ──────────────────────────
External/HSM/EST signer ←→ Local OpenSSL/step-ca fixture
POST /enroll/csr succeeds Same handler, TEST_CA_* env only
Proxy trusts corp roots Dev proxy trusts test root
External/HSM/EST signer ←→ scripts/lab-test-ca.sh (offline NOW)
POST /enroll/csr succeeds Future: same handler, TEST_CA_* env only
(never default; never production NODE_ENV)
Proxy trusts corp roots Dev proxy may trust lab-pki root (lab only)
```
Checklist items that mention “fixtures may use a test CA only” refer here. Do not implement the test CA ahead of choosing the production CA model.
| Stage | Lab script | HTTP `POST /enroll/csr` |
|-------|------------|-------------------------|
| **Today** | Offline init/sign on disk | **501** scaffold (unchanged) |
| **Live mint ships** | Still offline fixture / CI helper | Real production `Signer` |
| **Gated test signer (future)** | May share profile defaults with script | Only with `STAFF_TEST_CA=1` and non-production |
## Proposed layout (when implemented)
Checklist items that mention “fixtures may use a test CA only” refer here. **Do not** wire `lab-test-ca.sh` into the default server path or soften 501 because the script exists.
Suggested paths under the repo (names indicative; nothing exists yet):
## Layout
**Shipped now (offline script only):**
```
platform/verae-staff-iam/
scripts/
lab-test-ca.sh # LAB ONLY: init root + sign CSR → PEM on disk
lab-pki/ # default output dir (gitignored; keys never committed)
```
**Proposed later (when live mint + gated test signer are scheduled):**
```
platform/verae-staff-iam/
@ -52,13 +90,15 @@ platform/verae-staff-iam/
root-ca.crt # test root (public)
intermediate-ca.crt # optional
scripts/
test-ca-init.sh # generate root + intermediate into a local dir
test-ca-sign-csr.sh # sign one PEM CSR → client cert + chain
lab-test-ca.sh # keep as offline fallback / pre-HTTP fixture
test-ca-init.sh # optional rename/split once CI profile solidifies
test-ca-sign-csr.sh
src/pki/ # only if in-process signer is chosen later
testCa.js # gated by STAFF_TEST_CA=1; absent from prod image
# MUST NOT call lab-test-ca.sh from default server boot
```
**Private keys** for the test CA stay on the developer machine or CI secret store (`TEST_CA_KEY_DIR`), never in the IAM JSON user store, never in Docker images tagged for production, never logged.
**Private keys** for the lab/test CA stay on the developer machine or CI secret store (`LAB_PKI_DIR` / future `TEST_CA_KEY_DIR`), never in the IAM JSON user store, never in Docker images tagged for production, never logged. The Node process must not read `lab-pki/` unless a future explicit non-production gate is designed — **default server path stays 501**.
## Trust and environment gates
@ -106,8 +146,9 @@ Exact algorithms should match whatever production CA model is selected in [PKI-M
## Operator vs developer story
- **Operators (production):** unchanged — issue workstation certs in corporate PKI; proxy terminates mTLS; IAM flag + enforce. See [ENROLL.md](../ENROLL.md).
- **Developers (future):** run `scripts/test-ca-init.sh`, point dev proxy at `test/pki/root-ca.crt`, enable `STAFF_TEST_CA` only on localhost, run integration suite.
- **Operators (production):** unchanged — issue workstation certs in corporate PKI; proxy terminates mTLS; IAM flag + enforce. See [ENROLL.md](../ENROLL.md). Never run `lab-test-ca.sh` for real staff devices.
- **Developers (lab now):** run `scripts/lab-test-ca.sh init|sign`, keep material under gitignored `lab-pki/`, optionally point a *local* dev proxy at `lab-root.crt.pem`. HTTP mint still 501.
- **Developers (future mint):** same offline script or gated `STAFF_TEST_CA` signer; never enable lab CA in default server boot.
- **CI (future):** generate or restore test CA into a job-local volume; tear down after; never publish leaves as release artifacts.
## Implementation order (blocked on product)
@ -120,10 +161,11 @@ Exact algorithms should match whatever production CA model is selected in [PKI-M
## Hard rules
1. **This file is not an implementation.** No code path should claim test-CA support until the backlog mint work is intentionally scheduled.
2. **No fake crypto in production API** — 501 stays until real sign; tests that need certs use offline scripts or a gated signer, not a lying success response in default config.
3. **Test roots never trust-anchor production.**
4. Cross-link changes: any future mint PR must update [PKI-MINT-BACKLOG.md](./PKI-MINT-BACKLOG.md), this doc’s status line, and [ENROLL.md](../ENROLL.md) together.
1. **Server does not implement mint.** `lab-test-ca.sh` is offline only; no HTTP code path should claim test-CA or lab-CA support until live mint is intentionally scheduled and gated.
2. **No fake crypto in production API** — 501 stays until real sign; tests that need certs use the offline lab script or a future gated signer, not a lying success response in default config.
3. **Lab/test roots never trust-anchor production.**
4. **Never enable lab CA in the default server path** — no boot-time hook, no implicit `LAB_PKI_DIR` read, no `STAFF_TEST_CA` default-on.
5. Cross-link changes: any future mint PR must update [PKI-MINT-BACKLOG.md](./PKI-MINT-BACKLOG.md), this doc’s status line, and [ENROLL.md](../ENROLL.md) together.
## Related docs

270
scripts/lab-test-ca.sh Executable file
View file

@ -0,0 +1,270 @@
#!/usr/bin/env bash
# lab-test-ca.sh — offline OpenSSL lab CA for local/dev experiments ONLY.
#
# Creates a self-signed lab root CA and signs a PKCS#10 CSR → leaf PEM.
# Material stays on disk under a lab directory. This script is NEVER imported
# by the Node server and is NOT wired into POST /enroll/csr (still 501).
#
# NOT FOR PRODUCTION. NOT A STAFF-IAM RUNTIME FEATURE.
# Do not trust this root on real workstations, staging staff fleets, or prod.
#
# Usage:
# 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 warn
#
# Defaults: DIR=./lab-pki (gitignored). Requires openssl on PATH.
set -euo pipefail
SCRIPT_NAME="$(basename "$0")"
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
DEFAULT_DIR="${ROOT_DIR}/lab-pki"
DAYS_DEFAULT=7
warn_banner() {
cat >&2 <<'EOF'
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
LAB ONLY — verae-staff-iam scripts/lab-test-ca.sh
Self-signed throwaway CA. NOT for production, staging staff, or
any reverse-proxy trust store that faces real operators.
Does NOT call or enable POST /enroll/csr (remains 501 scaffold).
Do NOT ship lab-pki/ keys in images or commit private keys.
Live mint stays on the product backlog (docs/PKI-MINT-BACKLOG.md).
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
EOF
}
usage() {
cat <<EOF
Usage:
$SCRIPT_NAME init [--dir DIR]
Create lab root CA key + self-signed cert under DIR (default: lab-pki/).
$SCRIPT_NAME sign --csr PATH [--dir DIR] [--out PATH] [--days N] [--cn CN]
Sign a PEM CSR with the lab CA → leaf certificate PEM.
--out defaults to DIR/leaf-<timestamp>.crt.pem
--days leaf notAfter (default ${DAYS_DEFAULT}, max 30 for lab)
--cn optional subject CN override in the issued cert (lab only)
$SCRIPT_NAME warn
Print the lab-only warning and exit 0.
Environment:
LAB_PKI_DIR Override default DIR when --dir omitted.
Exit codes: 0 ok · 1 usage/input · 2 missing openssl/CA · 3 sign failure
EOF
}
need_openssl() {
if ! command -v openssl >/dev/null 2>&1; then
echo "error: openssl not found on PATH" >&2
exit 2
fi
}
resolve_dir() {
local d="${1:-}"
if [[ -z "$d" ]]; then
d="${LAB_PKI_DIR:-$DEFAULT_DIR}"
fi
# Absolute path
if [[ "$d" != /* ]]; then
d="$(pwd)/$d"
fi
echo "$d"
}
cmd_init() {
local dir=""
while [[ $# -gt 0 ]]; do
case "$1" in
--dir) dir="${2:-}"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "error: unknown init arg: $1" >&2; usage >&2; exit 1 ;;
esac
done
need_openssl
warn_banner
dir="$(resolve_dir "$dir")"
mkdir -p "$dir"
chmod 700 "$dir" 2>/dev/null || true
local key="$dir/lab-root.key.pem"
local crt="$dir/lab-root.crt.pem"
local conf="$dir/lab-root.cnf"
local serial="$dir/lab-root.srl"
if [[ -f "$key" || -f "$crt" ]]; then
echo "error: lab CA already exists under $dir (refusing to overwrite)" >&2
echo " remove lab-root.key.pem / lab-root.crt.pem to re-init" >&2
exit 1
fi
cat >"$conf" <<'EOF'
[req]
distinguished_name = dn
x509_extensions = v3_ca
prompt = no
[dn]
CN = Verae staff-iam LAB root (NOT FOR PRODUCTION)
O = Verae Lab Only
OU = throwaway-test-ca
[v3_ca]
basicConstraints = critical,CA:true,pathlen:0
keyUsage = critical,keyCertSign,cRLSign
subjectKeyIdentifier = hash
EOF
openssl genrsa -out "$key" 4096 2>/dev/null
chmod 600 "$key"
openssl req -new -x509 -days 3650 -key "$key" -out "$crt" -config "$conf"
# Fresh serial file for leaf issuance
openssl rand -hex 8 | tr '[:lower:]' '[:upper:]' >"$serial"
chmod 600 "$serial" 2>/dev/null || true
cat >"$dir/README.txt" <<EOF
LAB PKI material — NOT FOR PRODUCTION
Generated by scripts/lab-test-ca.sh on $(date -u +%Y-%m-%dT%H:%M:%SZ)
Root cert: lab-root.crt.pem (public)
Root key: lab-root.key.pem (PRIVATE — never commit, never production trust)
staff-iam server does not read this directory. POST /enroll/csr stays 501.
See docs/TEST-CA-DESIGN.md and docs/PKI-MINT-BACKLOG.md.
EOF
echo "lab CA created:"
echo " dir: $dir"
echo " cert: $crt"
echo " key: $key (keep private)"
openssl x509 -in "$crt" -noout -subject -dates 2>/dev/null || true
warn_banner
}
cmd_sign() {
local dir="" csr="" out="" days="$DAYS_DEFAULT" cn=""
while [[ $# -gt 0 ]]; do
case "$1" in
--dir) dir="${2:-}"; shift 2 ;;
--csr) csr="${2:-}"; shift 2 ;;
--out) out="${2:-}"; shift 2 ;;
--days) days="${2:-}"; shift 2 ;;
--cn) cn="${2:-}"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "error: unknown sign arg: $1" >&2; usage >&2; exit 1 ;;
esac
done
if [[ -z "$csr" ]]; then
echo "error: --csr PATH is required" >&2
usage >&2
exit 1
fi
if [[ ! -f "$csr" ]]; then
echo "error: CSR not found: $csr" >&2
exit 1
fi
if ! [[ "$days" =~ ^[0-9]+$ ]] || [[ "$days" -lt 1 || "$days" -gt 30 ]]; then
echo "error: --days must be 1..30 (lab short-lived leaves)" >&2
exit 1
fi
need_openssl
warn_banner
dir="$(resolve_dir "$dir")"
local key="$dir/lab-root.key.pem"
local ca="$dir/lab-root.crt.pem"
local serial="$dir/lab-root.srl"
local ext="$dir/lab-leaf-ext.cnf"
if [[ ! -f "$key" || ! -f "$ca" ]]; then
echo "error: lab CA missing under $dir — run: $SCRIPT_NAME init --dir $dir" >&2
exit 2
fi
if [[ ! -f "$serial" ]]; then
openssl rand -hex 8 | tr '[:lower:]' '[:upper:]' >"$serial"
fi
# Light shape check (headers only); openssl does the real parse on sign.
if ! grep -q "BEGIN CERTIFICATE REQUEST" "$csr" || ! grep -q "END CERTIFICATE REQUEST" "$csr"; then
echo "error: CSR must be PEM PKCS#10 (BEGIN/END CERTIFICATE REQUEST)" >&2
exit 1
fi
if [[ -z "$out" ]]; then
out="$dir/leaf-$(date -u +%Y%m%dT%H%M%SZ).crt.pem"
fi
cat >"$ext" <<EOF
basicConstraints = critical,CA:false
keyUsage = critical,digitalSignature,keyEncipherment
extendedKeyUsage = clientAuth
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid,issuer
EOF
# Optional CN override via -subj would replace whole subject; keep CSR subject
# unless --cn set, then use openssl -subj for lab convenience only.
local subj_args=()
if [[ -n "$cn" ]]; then
subj_args=(-subj "/CN=${cn}/O=Verae Lab Only/OU=lab-leaf")
fi
if ! openssl x509 -req \
-in "$csr" \
-CA "$ca" \
-CAkey "$key" \
-CAserial "$serial" \
-out "$out" \
-days "$days" \
-sha256 \
-extfile "$ext" \
"${subj_args[@]+"${subj_args[@]}"}" 2>"$dir/last-sign.err"
then
echo "error: openssl sign failed:" >&2
cat "$dir/last-sign.err" >&2 || true
exit 3
fi
rm -f "$dir/last-sign.err"
# Chain file (leaf + lab root) for convenience — still lab only.
local chain="${out%.crt.pem}.chain.pem"
if [[ "$chain" == "$out" ]]; then
chain="${out}.chain.pem"
fi
cat "$out" "$ca" >"$chain"
echo "lab leaf signed (NOT FOR PRODUCTION):"
echo " leaf: $out"
echo " chain: $chain"
openssl x509 -in "$out" -noout -subject -issuer -dates -serial 2>/dev/null || true
echo
echo "Next (lab/dev only): point a *dev* reverse proxy trust store at:"
echo " $ca"
echo "staff-iam still does not mint; POST /enroll/csr remains 501."
echo "See docs/TEST-CA-DESIGN.md — this script is offline fixture only."
warn_banner
}
main() {
local cmd="${1:-}"
if [[ -z "$cmd" ]]; then
warn_banner
usage >&2
exit 1
fi
shift || true
case "$cmd" in
init) cmd_init "$@" ;;
sign) cmd_sign "$@" ;;
warn) warn_banner; exit 0 ;;
-h|--help|help) usage; exit 0 ;;
*)
echo "error: unknown command: $cmd" >&2
usage >&2
exit 1
;;
esac
}
main "$@"