2 contributors
#!/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 <<EOF
Usage:
$0 init [subject]
$0 status-json
$0 list-json
$0 export-ca
$0 sign-csr name csr-file dns-name [dns-name...]
Notes:
- Run init/sign-csr as root.
- CA private key is stored outside git in \$HOST_MANAGER_CA_DIR/private.
- The web app only reads status, issued cert metadata, and the public CA cert.
EOF
}
die() {
printf '[ERROR] %s\n' "$*" >&2
exit 1
}
need_openssl() {
command -v "$OPENSSL" >/dev/null 2>&1 || die "openssl not found"
}
json_escape() {
perl -Mstrict -Mwarnings -e '
my $s = $ARGV[0] // "";
$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
}
cert_dns_names_json() {
local cert="$1"
local names name first=1
names="$("$OPENSSL" x509 -in "$cert" -noout -ext subjectAltName 2>/dev/null \
| sed -n '/DNS:/,$p' \
| tr ',' '\n' \
| sed -n 's/^[[:space:]]*DNS://p')"
printf '['
while IFS= read -r name; do
[[ -n "$name" ]] || continue
if [[ "$first" -eq 0 ]]; then
printf ','
fi
first=0
json_escape "$name"
done <<< "$names"
printf ']'
}
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 ',"dns_names":'; cert_dns_names_json "$cert"
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