#!/usr/bin/env bash # # RELAY-PHASE1 · Native mTLS tunnel · V2 — Device client-CA (and frp-client control-CA). # # STAGING TEMPLATE. Mints a PRIVATE client-authentication CA whose leaves the native clients (iOS / # Android / desktop) present at nginx :8470 (`ssl_verify_client on`). This CA is the ONE AND ONLY # security gate for the tunnelled base app (the base app has no login of its own — PLAN §1), so it # MUST be a SEPARATE trust root from Let's Encrypt (the public :8470 SERVER cert) and from the # agent-enrollment CA (deploy/scripts/gen-agent-ca.sh). Mixing them would let a foreign leaf enroll. # # *** P-256 (prime256v1), NEVER Ed25519. *** nginx `ssl_verify_client` + iOS client-certificate # selection require ECDSA-P256/RSA; Ed25519 client certs are not universally accepted (PLAN V2). # # Two KINDs (same structure, different name/subject/default dir): # device → /etc/relay/device-ca (device-ca.cert.pem) leaves = client devices (V2/V4) # frp-client → /etc/relay/frp-client-ca (frp-client-ca.cert.pem) leaves = per-host frpc control mTLS (V1/V1b) # # Produces an OpenSSL `ca` database (index.txt / serial / crlnumber / newcerts / openssl.cnf) so that # deploy/scripts/issue-device-cert.sh can sign tracked leaves and deploy/scripts/revoke-device.sh can # revoke by serial and regenerate the CRL. An EMPTY crl.pem is initialised NOW so V4's `ssl_crl` has a # file to reference from day one. # # Outputs (0600 key, 0644 cert) under CA_DIR: # .key.pem .cert.pem crl.pem # index.txt serial crlnumber index.txt.attr newcerts/ openssl.cnf (the `openssl ca` db) # # Config via ENV or flags (no hardcoded secrets — this CA key is generated in place, never committed): # KIND device | frp-client (default device) — or --kind # CA_DIR output dir (default per-KIND above) — or --dir # CA_DAYS CA lifetime in days (default 3650) # # Idempotent: an existing CA (key+cert) is REUSED, never overwritten (delete the dir to rotate). # # Verify (syntax): bash -n deploy/scripts/gen-device-ca.sh set -euo pipefail KIND="${KIND:-device}" CA_DIR="${CA_DIR:-}" CA_DAYS="${CA_DAYS:-3650}" # ---- args (flags override env) -------------------------------------------------------------------- while [[ $# -gt 0 ]]; do case "$1" in --kind) KIND="${2:?--kind needs a value}"; shift 2 ;; --dir) CA_DIR="${2:?--dir needs a value}"; shift 2 ;; -h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; *) echo "FATAL: unknown argument '$1' (see --help)" >&2; exit 2 ;; esac done case "${KIND}" in device) CA_NAME="device-ca"; CA_CN="web-terminal Device CA"; CA_OU="device-ca" CA_DIR="${CA_DIR:-/etc/relay/device-ca}" ;; frp-client) CA_NAME="frp-client-ca"; CA_CN="web-terminal frp-client CA"; CA_OU="frp-client-ca" CA_DIR="${CA_DIR:-/etc/relay/frp-client-ca}" ;; *) echo "FATAL: KIND must be 'device' or 'frp-client' (got '${KIND}')." >&2; exit 2 ;; esac command -v openssl >/dev/null 2>&1 || { echo "FATAL: openssl not found on PATH." >&2; exit 3; } umask 077 mkdir -p "${CA_DIR}/newcerts" # Resolve to an absolute path so `openssl ca` (which we run cd'd into CA_DIR with dir=.) is stable. CA_DIR="$(cd "${CA_DIR}" && pwd)" KEY="${CA_DIR}/${CA_NAME}.key.pem" CERT="${CA_DIR}/${CA_NAME}.cert.pem" echo "== Device/frp-client CA (${KIND}, PRIVATE — not Let's Encrypt) into ${CA_DIR} ==" # ---- 1. CA keypair + self-signed cert (P-256, CA:TRUE, keyCertSign+cRLSign) ----------------------- if [[ -f "${KEY}" && -f "${CERT}" ]]; then echo ">> CA already exists — reusing (delete ${CA_DIR} to rotate)." else openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out "${KEY}" openssl req -x509 -new -key "${KEY}" -days "${CA_DAYS}" \ -subj "/O=web-terminal-relay/OU=${CA_OU}/CN=${CA_CN}" \ -addext "basicConstraints=critical,CA:TRUE,pathlen:0" \ -addext "keyUsage=critical,keyCertSign,cRLSign" \ -addext "subjectKeyIdentifier=hash" \ -out "${CERT}" fi # ---- 2. OpenSSL `ca` database (so leaves are tracked → revocable via the CRL) ---------------------- [[ -f "${CA_DIR}/index.txt" ]] || : > "${CA_DIR}/index.txt" [[ -f "${CA_DIR}/serial" ]] || echo 1000 > "${CA_DIR}/serial" [[ -f "${CA_DIR}/crlnumber" ]] || echo 1000 > "${CA_DIR}/crlnumber" # unique_subject=no allows re-issuing a leaf for the same CN (device/host cert rotation). [[ -f "${CA_DIR}/index.txt.attr" ]] || echo "unique_subject = no" > "${CA_DIR}/index.txt.attr" if [[ ! -f "${CA_DIR}/openssl.cnf" ]]; then # dir = . → the issue/revoke scripts `cd` into CA_DIR before invoking `openssl ca`, so the db is # relocatable (works in a mktemp test dir). $dir is expanded by openssl; ${CA_NAME} by bash here. cat > "${CA_DIR}/openssl.cnf" </dev/null 2>&1 ) fi # ---- 4. Permissions + summary --------------------------------------------------------------------- chmod 700 "${CA_DIR}" chmod 600 "${KEY}" chmod 644 "${CERT}" "${CA_DIR}/crl.pem" cat < revoke a leaf : CA_DIR=${CA_DIR} bash deploy/scripts/revoke-device.sh SUMMARY