#!/usr/bin/env bash # # RELAY-PHASE1 · Native mTLS tunnel · V2 — Issue a client-auth LEAF from the device CA. # # STAGING TEMPLATE. Signs a P-256 client-certificate (EKU=clientAuth, 825d) with the CA minted by # deploy/scripts/gen-device-ca.sh and emits BOTH delivery formats: # .p12 — PKCS#12, `-legacy` (3DES/RC2) for iOS / Android import compatibility (V2) # .cert.pem — leaf cert ┐ # .key.pem — leaf private key (0600) ├─ desktop / curl --cert (M1 acceptance) # .fullchain.pem— leaf + CA cert ┘ # The leaf is signed via `openssl ca`, so it is recorded in the CA's index.txt and can later be # revoked (CRL) by deploy/scripts/revoke-device.sh. CN + serial are appended to /issued.log. # # NOTE: the SAME script issues the per-host frp-client control certs (V1b) — just point CA_DIR at the # frp-client CA: CA_DIR=/etc/relay/frp-client-ca bash issue-device-cert.sh # (frpc uses the .cert.pem/.key.pem; ignore the .p12 for that use.) # # Usage: issue-device-cert.sh [OUT_DIR] # required — the device/host common name (also the filename stem); [A-Za-z0-9._-]+ # [OUT_DIR] optional — where the leaf artifacts land (default /issued/) # # Config via ENV or flags (no hardcoded secrets): # CA_DIR which CA signs (default /etc/relay/device-ca) — or --ca-dir # P12_PASSWORD .p12 export passphrase (REQUIRED via env/arg; auto-generated + printed ONCE if # unset) — or --pass # LEAF_DAYS leaf lifetime (default 825) # # Verify (syntax): bash -n deploy/scripts/issue-device-cert.sh set -euo pipefail CA_DIR="${CA_DIR:-/etc/relay/device-ca}" LEAF_DAYS="${LEAF_DAYS:-825}" CN="" OUT_DIR="" # ---- args ---------------------------------------------------------------------------------------- while [[ $# -gt 0 ]]; do case "$1" in --ca-dir) CA_DIR="${2:?--ca-dir needs a value}"; shift 2 ;; --pass) P12_PASSWORD="${2:?--pass needs a value}"; shift 2 ;; -h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; -*) echo "FATAL: unknown flag '$1' (see --help)" >&2; exit 2 ;; *) if [[ -z "${CN}" ]]; then CN="$1"; elif [[ -z "${OUT_DIR}" ]]; then OUT_DIR="$1"; else echo "FATAL: too many positional args at '$1'" >&2; exit 2; fi; shift ;; esac done [[ -n "${CN}" ]] || { echo "FATAL: is required. usage: issue-device-cert.sh [OUT_DIR]" >&2; exit 2; } # Boundary validation: CN becomes a filename — reject anything that could traverse or surprise. [[ "${CN}" =~ ^[A-Za-z0-9._-]+$ ]] || { echo "FATAL: CN '${CN}' must match [A-Za-z0-9._-]+ (it is used as a filename)." >&2; exit 2; } command -v openssl >/dev/null 2>&1 || { echo "FATAL: openssl not found on PATH." >&2; exit 3; } CA_CERT="${CA_DIR}/$(basename "${CA_DIR}").cert.pem" # gen-device-ca names the CA cert after its dir (device-ca.cert.pem / frp-client-ca.cert.pem); if the # dir was renamed, fall back to the single *.cert.pem that is not a leaf. Keep it explicit + validated. if [[ ! -f "${CA_CERT}" ]]; then CA_CERT="$(ls "${CA_DIR}"/*-ca.cert.pem 2>/dev/null | head -1 || true)" fi [[ -f "${CA_DIR}/openssl.cnf" && -f "${CA_CERT}" ]] || { echo "FATAL: no CA db in ${CA_DIR}. Run gen-device-ca.sh first (expected openssl.cnf + *-ca.cert.pem)." >&2 exit 3; } OUT_DIR="${OUT_DIR:-${CA_DIR}/issued/${CN}}" umask 077 mkdir -p "${OUT_DIR}" OUT_DIR="$(cd "${OUT_DIR}" && pwd)" # absolute — we cd into CA_DIR before `openssl ca` # ---- passphrase (never hardcoded; auto-generate + print once if not supplied) --------------------- P12_GENERATED=0 if [[ -z "${P12_PASSWORD:-}" ]]; then P12_PASSWORD="$(openssl rand -base64 18 | tr -dc 'A-Za-z0-9' | head -c 24)" P12_GENERATED=1 fi KEY="${OUT_DIR}/${CN}.key.pem" CERT="${OUT_DIR}/${CN}.cert.pem" CHAIN="${OUT_DIR}/${CN}.fullchain.pem" P12="${OUT_DIR}/${CN}.p12" CSR="${OUT_DIR}/${CN}.csr.pem" echo "== Issue client-auth leaf CN=${CN} (${LEAF_DAYS}d) signed by ${CA_CERT} → ${OUT_DIR} ==" # ---- 1. leaf keypair + CSR (P-256) ---------------------------------------------------------------- openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out "${KEY}" openssl req -new -key "${KEY}" -subj "/O=web-terminal-relay/OU=device/CN=${CN}" -out "${CSR}" # ---- 2. sign via `openssl ca` (tracks the leaf in index.txt → revocable) --------------------------- ( cd "${CA_DIR}" && openssl ca -config openssl.cnf -batch -notext \ -extensions client_cert -days "${LEAF_DAYS}" \ -in "${CSR}" -out "${CERT}" ) cat "${CERT}" "${CA_CERT}" > "${CHAIN}" # ---- 3. PKCS#12 for iOS / Android (legacy 3DES/RC2 — imports reliably on mobile) ------------------- openssl pkcs12 -export -legacy \ -inkey "${KEY}" -in "${CERT}" -certfile "${CA_CERT}" \ -name "${CN}" -passout "pass:${P12_PASSWORD}" -out "${P12}" # ---- 4. bookkeeping ------------------------------------------------------------------------------- SERIAL="$(openssl x509 -in "${CERT}" -noout -serial | cut -d= -f2)" echo "$(date -u '+%Y-%m-%dT%H:%M:%SZ') CN=${CN} serial=${SERIAL} out=${OUT_DIR}" >> "${CA_DIR}/issued.log" rm -f "${CSR}" chmod 600 "${KEY}" "${P12}" chmod 644 "${CERT}" "${CHAIN}" cat <