LocalAuthority / scripts / ca_manager.sh
Newer Older
264 lines | 8.141kb
Xdev Host Manager authored a week ago
1
#!/usr/bin/env bash
2
#
3
# ca_manager.sh - Local host certificate authority helper.
4
#
5

            
6
set -euo pipefail
7

            
8
CA_DIR="${HOST_MANAGER_CA_DIR:-/usr/local/xdev-host-manager/var/ca}"
9
OPENSSL="${OPENSSL:-openssl}"
10
DEFAULT_SUBJECT="/O=Xdev/OU=Madagascar/CN=Xdev Madagascar Local Host CA"
11
DEFAULT_DAYS=3650
12
HOST_CERT_DAYS=825
13

            
14
usage() {
15
    cat <<EOF
16
Usage:
17
  $0 init [subject]
18
  $0 status-json
19
  $0 list-json
20
  $0 export-ca
Bogdan Timofte authored 4 days ago
21
  $0 issue name dns-name [dns-name...]
Xdev Host Manager authored a week ago
22
  $0 sign-csr name csr-file dns-name [dns-name...]
23

            
24
Notes:
Bogdan Timofte authored 4 days ago
25
  - Run init/issue/sign-csr as root or as the CA directory owner.
Xdev Host Manager authored a week ago
26
  - CA private key is stored outside git in \$HOST_MANAGER_CA_DIR/private.
27
  - The web app only reads status, issued cert metadata, and the public CA cert.
28
EOF
29
}
30

            
31
die() {
32
    printf '[ERROR] %s\n' "$*" >&2
33
    exit 1
34
}
35

            
36
need_openssl() {
37
    command -v "$OPENSSL" >/dev/null 2>&1 || die "openssl not found"
38
}
39

            
40
json_escape() {
41
    perl -Mstrict -Mwarnings -e '
Xdev Host Manager authored a week ago
42
        my $s = $ARGV[0] // "";
Xdev Host Manager authored a week ago
43
        $s =~ s/\\/\\\\/g;
44
        $s =~ s/"/\\"/g;
45
        $s =~ s/\n/\\n/g;
46
        $s =~ s/\r/\\r/g;
47
        $s =~ s/\t/\\t/g;
48
        print qq{"$s"};
Xdev Host Manager authored a week ago
49
    ' "${1:-}"
Xdev Host Manager authored a week ago
50
}
51

            
52
safe_name() {
53
    local name="${1:-}"
54
    [[ "$name" =~ ^[A-Za-z0-9_.-]+$ ]] || die "unsafe name: $name"
55
    printf '%s' "$name"
56
}
57

            
58
ca_cert="$CA_DIR/certs/ca.cert.pem"
59
ca_key="$CA_DIR/private/ca.key.pem"
60
serial_file="$CA_DIR/serial.srl"
61

            
62
init_ca() {
63
    need_openssl
64
    local subject="${1:-$DEFAULT_SUBJECT}"
65
    [[ ! -e "$ca_key" ]] || die "CA key already exists: $ca_key"
66

            
67
    install -d -m 0755 "$CA_DIR" "$CA_DIR/certs" "$CA_DIR/csr" "$CA_DIR/issued" "$CA_DIR/requests"
68
    install -d -m 0700 "$CA_DIR/private"
69

            
70
    "$OPENSSL" genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out "$ca_key"
71
    chmod 0600 "$ca_key"
72

            
73
    "$OPENSSL" req -new -x509 -days "$DEFAULT_DAYS" -sha256 \
74
        -key "$ca_key" \
75
        -out "$ca_cert" \
76
        -subj "$subject" \
77
        -addext "basicConstraints=critical,CA:TRUE,pathlen:0" \
78
        -addext "keyUsage=critical,keyCertSign,cRLSign" \
79
        -addext "subjectKeyIdentifier=hash"
80

            
81
    chmod 0644 "$ca_cert"
82
    chown -R root:host-manager "$CA_DIR" 2>/dev/null || true
83
    chown root:root "$ca_key" 2>/dev/null || true
84
    chmod 0600 "$ca_key"
85
}
86

            
87
cert_field() {
88
    local cert="$1"
89
    local field="$2"
90
    case "$field" in
91
        subject) "$OPENSSL" x509 -in "$cert" -noout -subject | sed 's/^subject=//' ;;
92
        issuer) "$OPENSSL" x509 -in "$cert" -noout -issuer | sed 's/^issuer=//' ;;
93
        not_before) "$OPENSSL" x509 -in "$cert" -noout -startdate | sed 's/^notBefore=//' ;;
94
        not_after) "$OPENSSL" x509 -in "$cert" -noout -enddate | sed 's/^notAfter=//' ;;
95
        fingerprint) "$OPENSSL" x509 -in "$cert" -noout -fingerprint -sha256 | sed 's/^sha256 Fingerprint=//' ;;
96
        serial) "$OPENSSL" x509 -in "$cert" -noout -serial | sed 's/^serial=//' ;;
97
        *) return 1 ;;
98
    esac
99
}
100

            
Bogdan Timofte authored 5 days ago
101
cert_dns_names_json() {
102
    local cert="$1"
103
    local names name first=1
104
    names="$("$OPENSSL" x509 -in "$cert" -noout -ext subjectAltName 2>/dev/null \
105
        | sed -n '/DNS:/,$p' \
106
        | tr ',' '\n' \
107
        | sed -n 's/^[[:space:]]*DNS://p')"
108

            
109
    printf '['
110
    while IFS= read -r name; do
111
        [[ -n "$name" ]] || continue
112
        if [[ "$first" -eq 0 ]]; then
113
            printf ','
114
        fi
115
        first=0
116
        json_escape "$name"
117
    done <<< "$names"
118
    printf ']'
119
}
120

            
Xdev Host Manager authored a week ago
121
status_json() {
122
    need_openssl
123
    if [[ ! -f "$ca_cert" ]]; then
124
        printf '{"initialized":false,"ca_dir":'
125
        json_escape "$CA_DIR"
126
        printf ',"certificates":0}\n'
127
        return
128
    fi
129

            
130
    local count=0
131
    shopt -s nullglob
132
    local cert
133
    for cert in "$CA_DIR"/issued/*.cert.pem; do
134
        count=$((count + 1))
135
    done
136
    shopt -u nullglob
137

            
138
    printf '{"initialized":true'
139
    printf ',"ca_dir":'; json_escape "$CA_DIR"
140
    printf ',"subject":'; json_escape "$(cert_field "$ca_cert" subject)"
141
    printf ',"not_before":'; json_escape "$(cert_field "$ca_cert" not_before)"
142
    printf ',"not_after":'; json_escape "$(cert_field "$ca_cert" not_after)"
143
    printf ',"fingerprint_sha256":'; json_escape "$(cert_field "$ca_cert" fingerprint)"
144
    printf ',"certificates":%s' "$count"
145
    printf '}\n'
146
}
147

            
148
list_json() {
149
    need_openssl
150
    printf '['
151
    local first=1 cert name
152
    shopt -s nullglob
153
    for cert in "$CA_DIR"/issued/*.cert.pem; do
154
        name="$(basename "$cert" .cert.pem)"
155
        if [[ "$first" -eq 0 ]]; then
156
            printf ','
157
        fi
158
        first=0
159
        printf '{"name":'; json_escape "$name"
160
        printf ',"subject":'; json_escape "$(cert_field "$cert" subject)"
Bogdan Timofte authored 5 days ago
161
        printf ',"dns_names":'; cert_dns_names_json "$cert"
Xdev Host Manager authored a week ago
162
        printf ',"issuer":'; json_escape "$(cert_field "$cert" issuer)"
163
        printf ',"serial":'; json_escape "$(cert_field "$cert" serial)"
164
        printf ',"not_before":'; json_escape "$(cert_field "$cert" not_before)"
165
        printf ',"not_after":'; json_escape "$(cert_field "$cert" not_after)"
166
        printf ',"fingerprint_sha256":'; json_escape "$(cert_field "$cert" fingerprint)"
Bogdan Timofte authored 4 days ago
167
        if [[ -f "$CA_DIR/issued/$name.key.pem" ]]; then
168
            printf ',"has_private_key":true'
169
        else
170
            printf ',"has_private_key":false'
171
        fi
Xdev Host Manager authored a week ago
172
        printf '}'
173
    done
174
    shopt -u nullglob
175
    printf ']\n'
176
}
177

            
178
export_ca() {
179
    [[ -f "$ca_cert" ]] || die "CA is not initialized"
180
    cat "$ca_cert"
181
}
182

            
183
sign_csr() {
184
    need_openssl
185
    [[ -f "$ca_key" && -f "$ca_cert" ]] || die "CA is not initialized"
186
    local name csr days ext cert
187
    name="$(safe_name "${1:-}")"
188
    csr="${2:-}"
189
    shift 2 || true
190
    [[ -f "$csr" ]] || die "missing CSR file: $csr"
191
    [[ "$#" -ge 1 ]] || die "at least one DNS SAN is required"
192

            
193
    cert="$CA_DIR/issued/$name.cert.pem"
194
    [[ ! -e "$cert" ]] || die "certificate already exists: $cert"
195
    ext="$(mktemp)"
196
    {
197
        printf 'basicConstraints=critical,CA:FALSE\n'
198
        printf 'keyUsage=critical,digitalSignature,keyEncipherment\n'
199
        printf 'extendedKeyUsage=serverAuth,clientAuth\n'
200
        printf 'subjectAltName='
201
        local first=1 dns
202
        for dns in "$@"; do
203
            [[ "$dns" =~ ^[A-Za-z0-9_.-]+$ ]] || die "unsafe DNS SAN: $dns"
204
            if [[ "$first" -eq 0 ]]; then
205
                printf ','
206
            fi
207
            first=0
208
            printf 'DNS:%s' "$dns"
209
        done
210
        printf '\n'
211
    } > "$ext"
212

            
213
    cp "$csr" "$CA_DIR/csr/$name.csr.pem"
214
    "$OPENSSL" x509 -req -sha256 \
215
        -in "$csr" \
216
        -CA "$ca_cert" \
217
        -CAkey "$ca_key" \
218
        -CAserial "$serial_file" \
219
        -CAcreateserial \
220
        -days "$HOST_CERT_DAYS" \
221
        -extfile "$ext" \
222
        -out "$cert"
223
    rm -f "$ext"
224
    chmod 0644 "$cert" "$CA_DIR/csr/$name.csr.pem"
225
    chown root:host-manager "$cert" "$CA_DIR/csr/$name.csr.pem" 2>/dev/null || true
226
    printf '%s\n' "$cert"
227
}
228

            
Bogdan Timofte authored 4 days ago
229
issue_cert() {
230
    need_openssl
231
    [[ -f "$ca_key" && -f "$ca_cert" ]] || die "CA is not initialized"
232
    local name key csr primary
233
    name="$(safe_name "${1:-}")"
234
    shift || true
235
    [[ "$#" -ge 1 ]] || die "at least one DNS SAN is required"
236
    primary="${1:-}"
237
    [[ "$primary" =~ ^[A-Za-z0-9_.-]+$ ]] || die "unsafe DNS SAN: $primary"
238

            
239
    key="$CA_DIR/issued/$name.key.pem"
240
    csr="$CA_DIR/requests/$name.csr.pem"
241
    [[ ! -e "$key" ]] || die "private key already exists: $key"
242
    [[ ! -e "$CA_DIR/issued/$name.cert.pem" ]] || die "certificate already exists: $CA_DIR/issued/$name.cert.pem"
243
    install -d -m 0755 "$CA_DIR/issued" "$CA_DIR/requests" "$CA_DIR/csr"
244

            
245
    "$OPENSSL" genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out "$key"
246
    chmod 0640 "$key"
247
    "$OPENSSL" req -new -sha256 -key "$key" -out "$csr" -subj "/CN=$primary"
248
    chmod 0644 "$csr"
249
    chown root:host-manager "$key" "$csr" 2>/dev/null || true
250

            
251
    sign_csr "$name" "$csr" "$@"
252
}
253

            
Xdev Host Manager authored a week ago
254
cmd="${1:-}"
255
case "$cmd" in
256
    init) shift; init_ca "$@" ;;
257
    status-json) status_json ;;
258
    list-json) list_json ;;
259
    export-ca) export_ca ;;
Bogdan Timofte authored 4 days ago
260
    issue) shift; issue_cert "$@" ;;
Xdev Host Manager authored a week ago
261
    sign-csr) shift; sign_csr "$@" ;;
262
    -h|--help|help|'') usage ;;
263
    *) die "unknown command: $cmd" ;;
264
esac