#!/usr/bin/env bash # lab-test-ca.sh — 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 (default lab-pki/, gitignored). # # Optional HTTP path (LAB ONLY): when staff-iam is started with LAB_CA_ENABLE=1 # AND this directory has been initialized, POST /enroll/csr may shell-out to # `sign` and return 201 + cert PEM with header X-Lab-CA: 1. # Default (LAB_CA_ENABLE unset/off) → POST /enroll/csr stays 501. # NODE_ENV=production always refuses the lab HTTP path. # # NOT FOR PRODUCTION. Do not trust this root on real workstations, # staging staff fleets, or prod reverse proxies. # # 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. Opt-in HTTP: LAB_CA_ENABLE=1 + init'd lab-pki → POST /enroll/csr may shell-out here (201 + X-Lab-CA:1). Default off → 501 scaffold. NODE_ENV=production refuses lab HTTP. NOT a production CA. Do NOT ship lab-pki/ keys in images or commit private keys. Live production mint stays on the product backlog (docs/PKI-MINT-BACKLOG.md). !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! EOF } usage() { cat <.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" </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" <"$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 "Default POST /enroll/csr is still 501 unless LAB_CA_ENABLE=1." echo "See docs/TEST-CA-DESIGN.md — LAB ONLY, never production." 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 "$@"