LocalAuthority / scripts / ca_manager.sh
1 contributor
211 lines | 6.325kb
#!/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 = 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