server/setup-ca.sh
hightrusted 2450171ce3
Initial public release
Open TSA — Free European RFC 3161 Timestamp Authority.

- 4-tier CA hierarchy via setup-ca.sh (Root → TSA Root → Intermediate → Signing)
- Node.js service (service.js): RFC 3161 endpoint, health, info, stats,
  /now, Prometheus metrics, rate limiting, audit log, admin endpoints
- All identities and paths configurable via environment variables
- Documented in README, including reverse-proxy setup and operating notes

MIT licensed. See https://open-tsa.eu for the live service.
2026-05-15 18:29:51 +02:00

526 lines
21 KiB
Bash
Executable file

#!/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 ""