#!/bin/bash # ============================================================ # Open TSA — CA & Certificate Setup (4-tier hierarchy) # https://open-tsa.eu # # Builds a 4-tier RFC 3161 TSA CA hierarchy from scratch. # Designed to be re-used by independent operators, not only by # the Open TSA project itself. All names, organisations, paths, # and the policy OID are configurable via environment variables. # # The defaults below are deliberately neutral and project-agnostic # so that any operator can run this script unchanged for a basic # evaluation, and override only what is needed for production. # # CA Hierarchy: # 1. Root CA (self-signed, 25 years) ← OFFLINE/HSM # 2. TSA Root CA (15 years) ← OFFLINE # 3. TSA Intermediate CA (10 years, server) # 4. TSA Signing Cert (2 years, operational) # # OpenSSL 3.x compatible (AlmaLinux 9, RHEL 9, Debian 12) # MIT License — Open TSA Project # # Usage: # sudo bash setup-ca.sh # # Environment variables (override defaults): # COUNTRY Two-letter country code (default: DE) # CA_DIR Path for CA hierarchy (default: /opt/open-tsa/ca) # PUBLIC_DIR Path for public certificates (default: /opt/open-tsa/public/certs) # CONFIG_PATH Path for openssl-tsa.cnf (default: /opt/open-tsa/openssl-tsa.cnf) # # ROOT_ORG Root CA "O=" (default: Open TSA) # ROOT_CN Root CA "CN=" (default: Open TSA Root CA) # # TSA_ROOT_ORG TSA Root + lower tiers "O=" (default: Open TSA) # TSA_ROOT_CN TSA Root CA "CN=" (default: Open TSA TSA Root CA) # TSA_INTER_CN Intermediate CA "CN=" (default: Open TSA TSA Intermediate CA) # TSA_SIGN_CN Signing Cert "CN=" (default: Open TSA TSA Signing Certificate) # # DOMAIN Public service hostname (default: tsr.example.org) # (informational only — used in the summary) # # TSA_POLICY_OID Your policy OID (default: 1.3.6.1.4.1.99999.1.1) # Allocate your own under IANA PEN # (https://pen.iana.org/) — do not reuse # another project's OID in production. # # SERVICE_USER User the service runs as (default: nodejs) # SERVICE_GROUP Group the service runs as (default: nodejs) # # Example for an independent operator in Finland: # # sudo COUNTRY=FI \ # CA_DIR=/opt/example-tsa/ca \ # PUBLIC_DIR=/opt/example-tsa/public/certs \ # CONFIG_PATH=/opt/example-tsa/openssl-tsa.cnf \ # ROOT_ORG="example.fi" \ # ROOT_CN="example.fi Root CA" \ # TSA_ROOT_ORG="tsa.example.fi" \ # TSA_ROOT_CN="example.fi TSA Root CA" \ # TSA_INTER_CN="example.fi TSA Intermediate CA" \ # TSA_SIGN_CN="example.fi TSA Signing Certificate" \ # DOMAIN=tsr.example.fi \ # TSA_POLICY_OID="1.3.6.1.4.1.99999.1.1" \ # bash setup-ca.sh # ============================================================ set -euo pipefail # ── Paths ───────────────────────────────────────────────────── CA_DIR="${CA_DIR:-/opt/open-tsa/ca}" PUBLIC_DIR="${PUBLIC_DIR:-/opt/open-tsa/public/certs}" CONFIG_PATH="${CONFIG_PATH:-/opt/open-tsa/openssl-tsa.cnf}" # ── Country ─────────────────────────────────────────────────── COUNTRY="${COUNTRY:-DE}" # ── Identities ──────────────────────────────────────────────── # Neutral defaults — production operators should override these. ROOT_ORG="${ROOT_ORG:-Open TSA}" ROOT_CN="${ROOT_CN:-Open TSA Root CA}" TSA_ROOT_ORG="${TSA_ROOT_ORG:-Open TSA}" TSA_ROOT_CN="${TSA_ROOT_CN:-Open TSA TSA Root CA}" TSA_INTER_CN="${TSA_INTER_CN:-Open TSA TSA Intermediate CA}" TSA_SIGN_CN="${TSA_SIGN_CN:-Open TSA TSA Signing Certificate}" DOMAIN="${DOMAIN:-tsr.example.org}" # ── TSA Policy OID ──────────────────────────────────────────── # RFC 3161 requires a policy OID in every issued timestamp token. # The default below is a placeholder under the IANA PEN reserved range. # DO NOT use this OID in production — register your own at # https://pen.iana.org/ and substitute it here. TSA_POLICY_OID="${TSA_POLICY_OID:-1.3.6.1.4.1.99999.1.1}" # ── Service user (the user that the Node.js service runs as) ─ SERVICE_USER="${SERVICE_USER:-nodejs}" SERVICE_GROUP="${SERVICE_GROUP:-nodejs}" GREEN='\033[0;32m' BLUE='\033[0;34m' RED='\033[0;31m' YELLOW='\033[0;33m' NC='\033[0m' echo -e "${BLUE}=== Open TSA — 4-Tier CA Setup ===${NC}" echo -e "${BLUE}Hierarchy:${NC}" echo " 1. $ROOT_CN (25y, OFFLINE)" echo " 2. $TSA_ROOT_CN (15y, OFFLINE)" echo " 3. $TSA_INTER_CN (10y)" echo " 4. $TSA_SIGN_CN (2y)" echo "" echo -e "${BLUE}Paths:${NC}" echo " CA_DIR : $CA_DIR" echo " PUBLIC_DIR : $PUBLIC_DIR" echo " CONFIG_PATH : $CONFIG_PATH" echo "" echo -e "${BLUE}Service:${NC}" echo " user/group : $SERVICE_USER:$SERVICE_GROUP" echo " policy OID : $TSA_POLICY_OID" echo "" # Warn if the user is reusing the placeholder OID if [ "$TSA_POLICY_OID" = "1.3.6.1.4.1.99999.1.1" ]; then echo -e "${YELLOW}WARNING: You are using the placeholder policy OID.${NC}" echo -e "${YELLOW} Please allocate your own at https://pen.iana.org/ before production use.${NC}" echo "" fi # ── Pre-flight ──────────────────────────────────────────────── if [ "$EUID" -ne 0 ]; then echo -e "${RED}This script must be run as root (sudo).${NC}" exit 1 fi if ! id "$SERVICE_USER" >/dev/null 2>&1; then echo -e "${RED}Service user '$SERVICE_USER' does not exist.${NC}" echo "Create it first: useradd -r -m -s /sbin/nologin $SERVICE_USER" exit 1 fi if [ -d "$CA_DIR" ]; then echo -e "${RED}Warning: $CA_DIR already exists. It will be reset.${NC}" read -p "Continue? (yes/no): " -r CONFIRM if [ "$CONFIRM" != "yes" ]; then echo "Aborted." exit 0 fi rm -rf "$CA_DIR" fi # ── Directory structure ─────────────────────────────────────── for tier in root tsa-root intermediate tsa; do mkdir -p "$CA_DIR/$tier"/{private,certs,crl,newcerts} touch "$CA_DIR/$tier/index.txt" # IMPORTANT: OpenSSL parses the serial file as a hex string and requires # an EVEN number of characters. "1000" (4 chars) is fine, "10000" # (5 chars) would trigger "a2i_ASN1_INTEGER: odd number of chars". # If you change the start value later, always keep it even-length — # e.g. "010000" instead of "10000". echo "1000" > "$CA_DIR/$tier/serial" echo "1000" > "$CA_DIR/$tier/crlnumber" done chmod 700 "$CA_DIR/root/private" chmod 700 "$CA_DIR/tsa-root/private" chmod 700 "$CA_DIR/intermediate/private" chmod 750 "$CA_DIR/tsa/private" mkdir -p "$PUBLIC_DIR" mkdir -p "$(dirname "$CONFIG_PATH")" # ── openssl.cnf — Tier 1: Root CA ───────────────────────────── cat > "$CA_DIR/root/openssl.cnf" << EOF [ ca ] default_ca = CA_default [ CA_default ] dir = $CA_DIR/root certs = \$dir/certs crl_dir = \$dir/crl new_certs_dir = \$dir/newcerts database = \$dir/index.txt serial = \$dir/serial private_key = \$dir/private/root.key certificate = \$dir/certs/root.crt crlnumber = \$dir/crlnumber default_md = sha256 policy = policy_loose default_days = 3650 copy_extensions = copy [ policy_loose ] countryName = optional organizationName = optional commonName = supplied [ req ] default_bits = 4096 distinguished_name = req_distinguished_name string_mask = utf8only default_md = sha256 prompt = no [ req_distinguished_name ] C = $COUNTRY O = $ROOT_ORG CN = $ROOT_CN [ v3_root_ca ] subjectKeyIdentifier = hash authorityKeyIdentifier = keyid:always,issuer basicConstraints = critical, CA:true keyUsage = critical, digitalSignature, cRLSign, keyCertSign [ v3_sub_root_ca ] subjectKeyIdentifier = hash authorityKeyIdentifier = keyid:always,issuer basicConstraints = critical, CA:true, pathlen:1 keyUsage = critical, digitalSignature, cRLSign, keyCertSign EOF # ── openssl.cnf — Tier 2: TSA Root CA ───────────────────────── cat > "$CA_DIR/tsa-root/openssl.cnf" << EOF [ ca ] default_ca = CA_default [ CA_default ] dir = $CA_DIR/tsa-root certs = \$dir/certs crl_dir = \$dir/crl new_certs_dir = \$dir/newcerts database = \$dir/index.txt serial = \$dir/serial private_key = \$dir/private/tsa-root.key certificate = \$dir/certs/tsa-root.crt crlnumber = \$dir/crlnumber default_md = sha256 policy = policy_loose default_days = 3650 copy_extensions = copy [ policy_loose ] countryName = optional organizationName = optional commonName = supplied [ req ] default_bits = 4096 distinguished_name = req_distinguished_name string_mask = utf8only default_md = sha256 prompt = no [ req_distinguished_name ] C = $COUNTRY O = $TSA_ROOT_ORG CN = $TSA_ROOT_CN [ v3_intermediate_ca ] subjectKeyIdentifier = hash authorityKeyIdentifier = keyid:always,issuer basicConstraints = critical, CA:true, pathlen:0 keyUsage = critical, digitalSignature, cRLSign, keyCertSign EOF # ── openssl.cnf — Tier 3: TSA Intermediate CA ───────────────── cat > "$CA_DIR/intermediate/openssl.cnf" << EOF [ ca ] default_ca = CA_default [ CA_default ] dir = $CA_DIR/intermediate certs = \$dir/certs crl_dir = \$dir/crl new_certs_dir = \$dir/newcerts database = \$dir/index.txt serial = \$dir/serial private_key = \$dir/private/intermediate.key certificate = \$dir/certs/intermediate.crt crlnumber = \$dir/crlnumber default_md = sha384 policy = policy_loose default_days = 730 copy_extensions = copy [ policy_loose ] countryName = optional organizationName = optional commonName = supplied [ req ] default_bits = 4096 distinguished_name = req_distinguished_name string_mask = utf8only default_md = sha384 prompt = no [ req_distinguished_name ] C = $COUNTRY O = $TSA_ROOT_ORG CN = $TSA_INTER_CN [ v3_tsa ] subjectKeyIdentifier = hash authorityKeyIdentifier = keyid:always,issuer basicConstraints = critical, CA:false keyUsage = critical, digitalSignature extendedKeyUsage = critical, timeStamping EOF echo -e "${GREEN}✓ Configuration files created${NC}" # ── [1/4] Root CA ───────────────────────────────────────────── echo -e "\n${BLUE}[1/4] $ROOT_CN (25 years)...${NC}" openssl genrsa -out "$CA_DIR/root/private/root.key" 4096 chmod 400 "$CA_DIR/root/private/root.key" openssl req -new -x509 \ -config "$CA_DIR/root/openssl.cnf" \ -key "$CA_DIR/root/private/root.key" \ -out "$CA_DIR/root/certs/root.crt" \ -days 9125 \ -extensions v3_root_ca \ -subj "/C=$COUNTRY/O=$ROOT_ORG/CN=$ROOT_CN" echo -e "${GREEN}✓ Root CA created${NC}" openssl x509 -noout -subject -dates -in "$CA_DIR/root/certs/root.crt" # ── [2/4] TSA Root CA ───────────────────────────────────────── echo -e "\n${BLUE}[2/4] $TSA_ROOT_CN (15 years)...${NC}" openssl genrsa -out "$CA_DIR/tsa-root/private/tsa-root.key" 4096 chmod 400 "$CA_DIR/tsa-root/private/tsa-root.key" openssl req -new \ -key "$CA_DIR/tsa-root/private/tsa-root.key" \ -out "$CA_DIR/tsa-root/certs/tsa-root.csr" \ -subj "/C=$COUNTRY/O=$TSA_ROOT_ORG/CN=$TSA_ROOT_CN" openssl ca -batch \ -config "$CA_DIR/root/openssl.cnf" \ -extensions v3_sub_root_ca \ -days 5475 -notext \ -in "$CA_DIR/tsa-root/certs/tsa-root.csr" \ -out "$CA_DIR/tsa-root/certs/tsa-root.crt" echo -e "${GREEN}✓ TSA Root CA created${NC}" openssl x509 -noout -subject -dates -in "$CA_DIR/tsa-root/certs/tsa-root.crt" # ── [3/4] TSA Intermediate CA ───────────────────────────────── echo -e "\n${BLUE}[3/4] $TSA_INTER_CN (10 years)...${NC}" openssl genrsa -out "$CA_DIR/intermediate/private/intermediate.key" 4096 chmod 400 "$CA_DIR/intermediate/private/intermediate.key" openssl req -new \ -key "$CA_DIR/intermediate/private/intermediate.key" \ -out "$CA_DIR/intermediate/certs/intermediate.csr" \ -subj "/C=$COUNTRY/O=$TSA_ROOT_ORG/CN=$TSA_INTER_CN" openssl ca -batch \ -config "$CA_DIR/tsa-root/openssl.cnf" \ -extensions v3_intermediate_ca \ -days 3650 -notext \ -in "$CA_DIR/intermediate/certs/intermediate.csr" \ -out "$CA_DIR/intermediate/certs/intermediate.crt" echo -e "${GREEN}✓ TSA Intermediate CA created${NC}" # ── [4/4] TSA Signing Certificate ───────────────────────────── echo -e "\n${BLUE}[4/4] $TSA_SIGN_CN (2 years)...${NC}" openssl genrsa -out "$CA_DIR/tsa/private/tsa.key" 4096 chown "root:$SERVICE_GROUP" "$CA_DIR/tsa/private/tsa.key" chmod 440 "$CA_DIR/tsa/private/tsa.key" chown "root:$SERVICE_GROUP" "$CA_DIR/tsa/private" chmod 750 "$CA_DIR/tsa/private" openssl req -new \ -key "$CA_DIR/tsa/private/tsa.key" \ -out "$CA_DIR/tsa/certs/tsa.csr" \ -subj "/C=$COUNTRY/O=$TSA_ROOT_ORG/CN=$TSA_SIGN_CN" openssl ca -batch \ -config "$CA_DIR/intermediate/openssl.cnf" \ -extensions v3_tsa \ -days 730 -notext \ -in "$CA_DIR/tsa/certs/tsa.csr" \ -out "$CA_DIR/tsa/certs/tsa.crt" # chain.pem = Intermediate only (OpenSSL 3.x: ess_cert_id_chain = no) cp "$CA_DIR/intermediate/certs/intermediate.crt" "$CA_DIR/tsa/certs/chain.pem" echo -e "${GREEN}✓ TSA Signing Certificate created${NC}" # ── Public certs ────────────────────────────────────────────── # ca.crt = Root (trust anchor) cp "$CA_DIR/root/certs/root.crt" "$PUBLIC_DIR/ca.crt" cp "$CA_DIR/tsa-root/certs/tsa-root.crt" "$PUBLIC_DIR/tsa-root.crt" cp "$CA_DIR/intermediate/certs/intermediate.crt" "$PUBLIC_DIR/intermediate.crt" cp "$CA_DIR/tsa/certs/tsa.crt" "$PUBLIC_DIR/tsa.crt" cp "$CA_DIR/tsa/certs/chain.pem" "$PUBLIC_DIR/chain.pem" # fullchain.pem = all 3 CAs for easy verification cat "$CA_DIR/intermediate/certs/intermediate.crt" \ "$CA_DIR/tsa-root/certs/tsa-root.crt" \ "$CA_DIR/root/certs/root.crt" \ > "$PUBLIC_DIR/fullchain.pem" chmod 644 "$PUBLIC_DIR"/*.crt "$PUBLIC_DIR"/*.pem # ── openssl-tsa.cnf ─────────────────────────────────────────── cat > "$CONFIG_PATH" << EOF [ tsa ] default_tsa = tsa_config [ tsa_config ] dir = $CA_DIR/tsa serial = \$dir/serial crypto_device = builtin signer_cert = $CA_DIR/tsa/certs/tsa.crt signer_key = $CA_DIR/tsa/private/tsa.key signer_digest = sha256 default_policy = $TSA_POLICY_OID # Do NOT set other_policies — an empty value crashes OpenSSL 3.x digests = sha256, sha384, sha512 accuracy = secs:1 clock_precision_digits = 0 ordering = yes tsa_name = yes ess_cert_id_chain = no EOF # ────────────────────────────────────────────────────────────── # Set runtime permissions for the service user # # The service (service.js) runs as $SERVICE_USER:$SERVICE_GROUP # and must be able to write serial, index.txt, and newcerts/. # Only the TSA tier is writable at runtime — the upper CA tiers # (Root, TSA-Root, Intermediate) remain root:root. # ────────────────────────────────────────────────────────────── echo -e "\n${BLUE}Setting runtime permissions for service user...${NC}" chown "$SERVICE_USER:$SERVICE_GROUP" "$CA_DIR/tsa/serial" chown "$SERVICE_USER:$SERVICE_GROUP" "$CA_DIR/tsa/index.txt" chown "$SERVICE_USER:$SERVICE_GROUP" "$CA_DIR/tsa/newcerts" chmod 644 "$CA_DIR/tsa/serial" chmod 644 "$CA_DIR/tsa/index.txt" chmod 755 "$CA_DIR/tsa/newcerts" echo " serial: $(stat -c '%U:%G %a' "$CA_DIR/tsa/serial")" echo " index.txt: $(stat -c '%U:%G %a' "$CA_DIR/tsa/index.txt")" echo " newcerts/: $(stat -c '%U:%G %a' "$CA_DIR/tsa/newcerts")" # ────────────────────────────────────────────────────────────── # Smoke test: verify serial generation actually works # ────────────────────────────────────────────────────────────── echo -e "\n${BLUE}Smoke test — serial generation...${NC}" TESTFILE=$(mktemp) echo "smoke-test-$(date +%s)" > "$TESTFILE" # Generate TSQ as the service user (no root involvement) sudo -u "$SERVICE_USER" openssl ts -query \ -data "$TESTFILE" -cert -sha256 -no_nonce \ -out "${TESTFILE}.tsq" 2>/dev/null # Generate TSR. If permissions are wrong, this writes a non-Granted TSR # with exit code 0 — we MUST verify the status explicitly. sudo -u "$SERVICE_USER" openssl ts -reply \ -config "$CONFIG_PATH" \ -queryfile "${TESTFILE}.tsq" \ -signer "$CA_DIR/tsa/certs/tsa.crt" \ -inkey "$CA_DIR/tsa/private/tsa.key" \ -chain "$CA_DIR/tsa/certs/chain.pem" \ -out "${TESTFILE}.tsr" 2>/dev/null STATUS=$(openssl ts -reply -in "${TESTFILE}.tsr" -text 2>/dev/null | grep "Status:" || true) if ! echo "$STATUS" | grep -q "Granted"; then echo -e "${RED}✗ Smoke test FAILED:${NC} $STATUS" echo -e "${RED}Check filesystem permissions on $CA_DIR/tsa/${NC}" rm -f "$TESTFILE" "${TESTFILE}.tsq" "${TESTFILE}.tsr" exit 1 fi SERIAL1=$(openssl ts -reply -in "${TESTFILE}.tsr" -text 2>/dev/null | grep "Serial" | head -1) echo -e " ${GREEN}✓${NC} First request: $STATUS ($SERIAL1)" # Second request — serial MUST increment sudo -u "$SERVICE_USER" openssl ts -reply \ -config "$CONFIG_PATH" \ -queryfile "${TESTFILE}.tsq" \ -signer "$CA_DIR/tsa/certs/tsa.crt" \ -inkey "$CA_DIR/tsa/private/tsa.key" \ -chain "$CA_DIR/tsa/certs/chain.pem" \ -out "${TESTFILE}.tsr2" 2>/dev/null SERIAL2=$(openssl ts -reply -in "${TESTFILE}.tsr2" -text 2>/dev/null | grep "Serial" | head -1) if [ "$SERIAL1" = "$SERIAL2" ]; then echo -e "${RED}✗ Serial counter NOT incrementing: $SERIAL1 == $SERIAL2${NC}" echo -e "${RED}Check that $CA_DIR/tsa/serial is writable by $SERVICE_USER.${NC}" rm -f "$TESTFILE" "${TESTFILE}.tsq" "${TESTFILE}.tsr" "${TESTFILE}.tsr2" exit 1 fi echo -e " ${GREEN}✓${NC} Second request: $SERIAL2" echo -e " ${GREEN}✓ Serial counter increments correctly.${NC}" rm -f "$TESTFILE" "${TESTFILE}.tsq" "${TESTFILE}.tsr" "${TESTFILE}.tsr2" # ── Summary ─────────────────────────────────────────────────── echo "" echo -e "${BLUE}============================================${NC}" echo -e "${GREEN}Setup complete!${NC}" echo -e "${BLUE}============================================${NC}" echo "" echo "Certificate hierarchy:" echo "" openssl x509 -noout -subject -dates -in "$CA_DIR/root/certs/root.crt" openssl x509 -noout -subject -dates -in "$CA_DIR/tsa-root/certs/tsa-root.crt" openssl x509 -noout -subject -dates -in "$CA_DIR/intermediate/certs/intermediate.crt" openssl x509 -noout -subject -dates -in "$CA_DIR/tsa/certs/tsa.crt" echo "" echo "Public certificates: $PUBLIC_DIR" echo "TSA config: $CONFIG_PATH" echo "TSA endpoint (after service deployment): https://$DOMAIN" echo "" echo -e "${YELLOW}IMPORTANT — secure the root keys offline:${NC}" echo " $CA_DIR/root/private/root.key" echo " $CA_DIR/tsa-root/private/tsa-root.key" echo "" echo "Export them to a USB stick, verify the copy, then delete from this server." echo "These keys are only needed every 2 years to renew the TSA signing certificate" echo "and after disasters to recover the CA chain." echo ""