#!/usr/bin/env bash # # ca_manager.sh - Local host certificate authority helper. # set -euo pipefail CA_DIR="${HOST_MANAGER_CA_DIR:-/usr/local/xdev-host-manager/var/ca}" OPENSSL="${OPENSSL:-openssl}" DEFAULT_SUBJECT="/O=Xdev/OU=Madagascar/CN=Xdev Madagascar Local Host CA" DEFAULT_DAYS=3650 HOST_CERT_DAYS=825 usage() { cat <&2 exit 1 } need_openssl() { command -v "$OPENSSL" >/dev/null 2>&1 || die "openssl not found" } json_escape() { perl -Mstrict -Mwarnings -e ' my $s = join("", <>); $s =~ s/\\/\\\\/g; $s =~ s/"/\\"/g; $s =~ s/\n/\\n/g; $s =~ s/\r/\\r/g; $s =~ s/\t/\\t/g; print qq{"$s"}; ' <<< "${1:-}" } safe_name() { local name="${1:-}" [[ "$name" =~ ^[A-Za-z0-9_.-]+$ ]] || die "unsafe name: $name" printf '%s' "$name" } ca_cert="$CA_DIR/certs/ca.cert.pem" ca_key="$CA_DIR/private/ca.key.pem" serial_file="$CA_DIR/serial.srl" init_ca() { need_openssl local subject="${1:-$DEFAULT_SUBJECT}" [[ ! -e "$ca_key" ]] || die "CA key already exists: $ca_key" install -d -m 0755 "$CA_DIR" "$CA_DIR/certs" "$CA_DIR/csr" "$CA_DIR/issued" "$CA_DIR/requests" install -d -m 0700 "$CA_DIR/private" "$OPENSSL" genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out "$ca_key" chmod 0600 "$ca_key" "$OPENSSL" req -new -x509 -days "$DEFAULT_DAYS" -sha256 \ -key "$ca_key" \ -out "$ca_cert" \ -subj "$subject" \ -addext "basicConstraints=critical,CA:TRUE,pathlen:0" \ -addext "keyUsage=critical,keyCertSign,cRLSign" \ -addext "subjectKeyIdentifier=hash" chmod 0644 "$ca_cert" chown -R root:host-manager "$CA_DIR" 2>/dev/null || true chown root:root "$ca_key" 2>/dev/null || true chmod 0600 "$ca_key" } cert_field() { local cert="$1" local field="$2" case "$field" in subject) "$OPENSSL" x509 -in "$cert" -noout -subject | sed 's/^subject=//' ;; issuer) "$OPENSSL" x509 -in "$cert" -noout -issuer | sed 's/^issuer=//' ;; not_before) "$OPENSSL" x509 -in "$cert" -noout -startdate | sed 's/^notBefore=//' ;; not_after) "$OPENSSL" x509 -in "$cert" -noout -enddate | sed 's/^notAfter=//' ;; fingerprint) "$OPENSSL" x509 -in "$cert" -noout -fingerprint -sha256 | sed 's/^sha256 Fingerprint=//' ;; serial) "$OPENSSL" x509 -in "$cert" -noout -serial | sed 's/^serial=//' ;; *) return 1 ;; esac } status_json() { need_openssl if [[ ! -f "$ca_cert" ]]; then printf '{"initialized":false,"ca_dir":' json_escape "$CA_DIR" printf ',"certificates":0}\n' return fi local count=0 shopt -s nullglob local cert for cert in "$CA_DIR"/issued/*.cert.pem; do count=$((count + 1)) done shopt -u nullglob printf '{"initialized":true' printf ',"ca_dir":'; json_escape "$CA_DIR" printf ',"subject":'; json_escape "$(cert_field "$ca_cert" subject)" printf ',"not_before":'; json_escape "$(cert_field "$ca_cert" not_before)" printf ',"not_after":'; json_escape "$(cert_field "$ca_cert" not_after)" printf ',"fingerprint_sha256":'; json_escape "$(cert_field "$ca_cert" fingerprint)" printf ',"certificates":%s' "$count" printf '}\n' } list_json() { need_openssl printf '[' local first=1 cert name shopt -s nullglob for cert in "$CA_DIR"/issued/*.cert.pem; do name="$(basename "$cert" .cert.pem)" if [[ "$first" -eq 0 ]]; then printf ',' fi first=0 printf '{"name":'; json_escape "$name" printf ',"subject":'; json_escape "$(cert_field "$cert" subject)" printf ',"issuer":'; json_escape "$(cert_field "$cert" issuer)" printf ',"serial":'; json_escape "$(cert_field "$cert" serial)" printf ',"not_before":'; json_escape "$(cert_field "$cert" not_before)" printf ',"not_after":'; json_escape "$(cert_field "$cert" not_after)" printf ',"fingerprint_sha256":'; json_escape "$(cert_field "$cert" fingerprint)" printf '}' done shopt -u nullglob printf ']\n' } export_ca() { [[ -f "$ca_cert" ]] || die "CA is not initialized" cat "$ca_cert" } sign_csr() { need_openssl [[ -f "$ca_key" && -f "$ca_cert" ]] || die "CA is not initialized" local name csr days ext cert name="$(safe_name "${1:-}")" csr="${2:-}" shift 2 || true [[ -f "$csr" ]] || die "missing CSR file: $csr" [[ "$#" -ge 1 ]] || die "at least one DNS SAN is required" cert="$CA_DIR/issued/$name.cert.pem" [[ ! -e "$cert" ]] || die "certificate already exists: $cert" ext="$(mktemp)" { printf 'basicConstraints=critical,CA:FALSE\n' printf 'keyUsage=critical,digitalSignature,keyEncipherment\n' printf 'extendedKeyUsage=serverAuth,clientAuth\n' printf 'subjectAltName=' local first=1 dns for dns in "$@"; do [[ "$dns" =~ ^[A-Za-z0-9_.-]+$ ]] || die "unsafe DNS SAN: $dns" if [[ "$first" -eq 0 ]]; then printf ',' fi first=0 printf 'DNS:%s' "$dns" done printf '\n' } > "$ext" cp "$csr" "$CA_DIR/csr/$name.csr.pem" "$OPENSSL" x509 -req -sha256 \ -in "$csr" \ -CA "$ca_cert" \ -CAkey "$ca_key" \ -CAserial "$serial_file" \ -CAcreateserial \ -days "$HOST_CERT_DAYS" \ -extfile "$ext" \ -out "$cert" rm -f "$ext" chmod 0644 "$cert" "$CA_DIR/csr/$name.csr.pem" chown root:host-manager "$cert" "$CA_DIR/csr/$name.csr.pem" 2>/dev/null || true printf '%s\n' "$cert" } cmd="${1:-}" case "$cmd" in init) shift; init_ca "$@" ;; status-json) status_json ;; list-json) list_json ;; export-ca) export_ca ;; sign-csr) shift; sign_csr "$@" ;; -h|--help|help|'') usage ;; *) die "unknown command: $cmd" ;; esac