#!/bin/sh # Alagezia PKI - request a certificate FROM OUTSIDE THE LAN (Linux / macOS / BSD; needs openssl + curl; no root rights). # # curl -fsSO https://pki.hanei-no-choten.com/pki/request-cert-ext.sh && sh request-cert-ext.sh # sh request-cert-ext.sh --name laptop [--days N] [--pfx] [--out DIR] # # For every network that is not the Alagezia LAN, VPN peers included (inside the LAN: request-cert.sh). # Nothing goes over plain HTTP. The only server is https://pki.hanei-no-choten.com: TLS ends on the CA host itself # (the VDS on the way only passes the stream through), and this script accepts it only if the certificate is valid # for the name AND carries the CA host's own public key (pinned below). A forged certificate for the same name - even a # real Let's Encrypt one obtained by whoever controls the VDS - has another key and is refused. # # Confirmation: VK community - a CLIENT certificate. The script shows an 8-digit code. In a private chat with the # Alagezia VK community open "Sertifikaty" -> "Vypustit sertifikat" (certificates -> issue), send the code there and # compare the key fingerprint in the reply. Names: one word gives .external.alagezia.internal; a full name only # if it is reserved for your VK account. The manual approval queue is a LAN-only route. # The key is created here and never leaves this host. Plain files in ~/alagezia-certs//; nothing is installed. # The issued chain must lead to the Alagezia root built into this file. # # ASCII only, deliberately: scripts travel between Windows, Linux and network gear. set -eu EXT_HOST=pki.hanei-no-choten.com # SHA-256 of the CA host's public key (SubjectPublicKeyInfo) of its Let's Encrypt certificate "hanei-ext". # certbot keeps the key on renewal (reuse_key); if the key is ever replaced, this pin and the .ps1 one must follow. PIN='sha256//fB+pz4SeGb2VcOHd+4+Vsne3o+4Kg6ZI/Lq8hbIosVY=' ROOT_SHA256=a728875111fc347dc18e7d3418f38daa5c31edb867b1526d5eed0499deac725a VK_CHAT=https://vk.me/alagezia_al VK_GROUP='https://vk.ru/alagezia_al (@alagezia_al)' NAME=""; DAYS=""; OUT=""; PFX=""; INTERACTIVE=1 say() { printf ' %s\n' "$*"; } fail() { printf '\n %s\n' "$*" >&2; exit 1; } ask() { printf ' %s' "$1" >&2; read -r REPLY || REPLY=""; } while [ $# -gt 0 ]; do case "$1" in --vk) ;; --name) NAME=$2; shift ;; --days) DAYS=$2; shift ;; --out) OUT=$2; shift ;; --pfx) PFX=1 ;; -h|--help) sed -n 2,5p "$0"; exit 0 ;; --operator) fail "The manual approval queue works only inside the LAN (request-cert.sh)." ;; *) fail "unknown option $1" ;; esac shift done [ -n "$NAME" ] && INTERACTIVE="" command -v openssl >/dev/null && command -v curl >/dev/null || fail "openssl and curl are required." # Schannel builds of curl (Windows) may stall on revocation lookups; the server key is pinned anyway. NOREVOKE=""; curl --ssl-no-revoke --version >/dev/null 2>&1 && NOREVOKE=--ssl-no-revoke BASE=${OUT:-$HOME/alagezia-certs} mkdir -p "$BASE" W=$(mktemp -d); trap 'rm -rf "$W"' EXIT ROOT=$BASE/alagezia-root.crt cat > "$ROOT" <<'EOF' -----BEGIN CERTIFICATE----- MIIFiTCCA3GgAwIBAgIUNK5FWIcb/ZRATmMBRU/TtM55I1IwDQYJKoZIhvcNAQEL BQAwLjEZMBcGA1UEAwwQQWxhZ2V6aWEgUm9vdCBDQTERMA8GA1UECgwIQWxhZ2V6 aWEwHhcNMjYwOTExMjEyMjU5WhcNNDYwOTA2MjEyMjU4WjAuMRkwFwYDVQQDDBBB bGFnZXppYSBSb290IENBMREwDwYDVQQKDAhBbGFnZXppYTCCAiIwDQYJKoZIhvcN AQEBBQADggIPADCCAgoCggIBAMZ05y5jqC6TV6uuRWHtkEU2r2nD+IZARH7jyque CjLin1xmzWDUUnDupXUpdpEpGsQnf/zsncSciVWXjEuwIrh7Q1xVL0EHdq6xnc83 Z2lyiNj2NqTenko1qUDvph0GLdWtFs/10tY8FGXI+UHhIYPiwcGrJC0LWfKO8VnK iLu1j8BrxfvxA4S+K6RRjAOFU7pRZqmK0Ttzpa82SHvpBqKpnNuLEIUT2YaoDFg7 /7pkGs50s5HLwUAIR6L6nlCE3riSepTqsNpjz2lZFFKdlO/iFhP8IY/WWT/yrUu2 92YI/I5t75VaBTIGdnnS28g+vsiiVQ9FwV+o5cyh92l9Uz4gcR8lpGbit7b7Si0y lNqpLykdhFt9yUwBFM9mNiLlIYJRdzGRlSHVhJ2Ft+qnftr087ZsxzdYeUsLw+zp I+v71vQfcqg7JyGSVvMuNTL/tYuocAXIYHgBCdDm57YnGDT6rB3K3xArTkswZ+2r 3BqEtFUWZ1NPH4qxHNmJpxitiQPlH0V2LIPcHAD3LXgCQ0bSZ0GVlQNVJ2dl0CIL WhxWo3XQkN1bL5qy35jN1qFMnkH3lAGmN4pN3NmWEHC+dECmEKhdNgHLojxQoYn1 MZTGrhgcURmDy16npGmH6xu/X/xob6MW8QhWPiIjCodXSOpQE5SPLraJT2NcZuG1 g011AgMBAAGjgZ4wgZswDwYDVR0TAQH/BAUwAwEB/zAfBgNVHSMEGDAWgBQmJs6W FIsUoJ1jLBTEVTbaftze/zA4BgNVHR4EMTAvoC0wE4IRYWxhZ2V6aWEuaW50ZXJu YWwwCocIwKgAAP//AAAwCocIrBAgAP///wAwHQYDVR0OBBYEFCYmzpYUixSgnWMs FMRVNtp+3N7/MA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG9w0BAQsFAAOCAgEAOJD3 /ybFKJSyVvB7qyhcYXsrXLeMwQgi+X/dZlfo/XzvMpdMo9wwElmQAeO0uHxCfAb5 Lur39QDmDJ5kH0cwqvK6r3HdqwQckc32ZAGA0Qkp3FBkHmqwhPZEqJpwBz29k6DE tc6iJMjWjOz+6p/wImSmeAF1CyhGrp3q7AIJo48Ir0tdBQA/NSV6HgjLFjCIWrL4 e1obeCRY7Lks8tY9mgOEtZ34h/QixbydT28w464767VY9aoz5TXaR0tUKTEuniV4 /0/iPcb7yhCNn6i70igAwBrW4aVg21+AJOvvP3jMOgxZs2+IIEwNg31OCaOFORfH 3L9YPvaJfEUYHzxABXFJq7KBb5tyHmTNIY2rXSFC1GgOaou6wG8wd3h4SByK0Ndq cOn+4actwySWgZi/M3hI+W2psLdgLzUdDEA4oMmRL850ZSrmeksINFehCAZTe6Vx gvWJ5wQfv16hxpa00rUedJ1sb2Lxsxwa/MTw6i2izw1I5m6gqD1tVN596uoyagEF CApK5tbWvqLA7N6CxOxz/s93glTrJCIAeRGTV/d04hZzgQxvPWRqgFpaegr7rhsf ZCmPdtum9qq3xPRyVjyd7c566ReSmnsWbrKNBdkjOemcATwATrkoHCozr41muxBQ /IDs2XzS0sNC3Ruwt3esihhkJCIMMEnZ7sn64XE= -----END CERTIFICATE----- EOF der_sha() { openssl x509 -in "$1" -outform DER 2>/dev/null | openssl dgst -sha256 | awk '{print $NF}'; } [ "$(der_sha "$ROOT")" = "$ROOT_SHA256" ] || fail "The built-in root certificate does not match its own fingerprint: this copy of the script is damaged." API="https://$EXT_HOST" printf '\n'; say 'Alagezia PKI - certificate request from outside the LAN' say 'The key is created on this host and never leaves it. Nothing is installed.'; printf '\n' # ---------------------------------------------------------------- helpers api() { # api METHOD PATH [BODYFILE] [SECRET] -> body in $W/resp, prints the HTTP status ("000" = no answer) # never exits by itself: it runs inside $(...), where exit would end only the subshell _m=$1; _p=$2; _b=${3:-}; _s=${4:-} : > "$W/resp" # system CA store for the name (Let's Encrypt) + the pinned key of the CA host set -- curl -sS -o "$W/resp" -w '%{http_code}' --connect-timeout 10 --max-time 30 --proto =https --tlsv1.2 \ --pinnedpubkey "$PIN" -X "$_m" [ -n "$NOREVOKE" ] && set -- "$@" "$NOREVOKE" [ -n "$_b" ] && set -- "$@" -H 'Content-Type: application/json' --data-binary "@$_b" [ -n "$_s" ] && set -- "$@" -H "X-Ticket-Secret: $_s" "$@" "$API/ca/ext/$_p" 2>"$W/err" || true } jget() { # value of a top-level key of the CA's JSON (one key per line), unescaped awk -v k="\"$2\": " 'index($0, k) { v = substr($0, index($0, k) + length(k)); sub(/,[ \t\r]*$/, "", v) if (v ~ /^"/) v = substr(v, 2, length(v) - 2) gsub(/\\n/, "\n", v); gsub(/\\"/, "\"", v); if (v == "null") v = ""; print v; exit }' "$1" } refusal() { if [ "${1:-000}" = 000 ]; then if grep -qi 'pinned\|pubkey' "$W/err" 2>/dev/null; then fail "The server at $API did not show the CA host's key: someone on the way answers instead of it, or this script is outdated - get a fresh copy from https://$EXT_HOST/ ($(tail -1 "$W/err"))." fi fail "Cannot reach the Alagezia CA at $API ($(tail -1 "$W/err" 2>/dev/null))." fi e=$(jget "$W/resp" error); m=$(jget "$W/resp" message); fail "The CA refused: ${e:-HTTP $1}${m:+ - $m}" } transient() { case "$1" in 000|429|5??) return 0 ;; esac; return 1; } key_fp() { openssl pkey -in "$1" -pubout -outform DER | openssl dgst -sha256 | awk '{h=toupper($NF); print substr(h,1,4)" "substr(h,5,4)" "substr(h,9,4)" "substr(h,13,4)}'; } new_key() { # new_key DIR FILE CN ( umask 077; openssl ecparam -name prime256v1 -genkey -noout -out "$1/$2.key" ) # MSYS (Git-bash on Windows) would rewrite the leading "/" of -subj as a Windows path; only that argument is # excluded (the file paths still need the conversion). Elsewhere the variable does nothing. MSYS2_ARG_CONV_EXCL='/O=' openssl req -new -key "$1/$2.key" -subj "/O=Alagezia/CN=$3" -out "$1/$2.csr" } save() { # save DIR FILE CHAINFILE cp "$3" "$1/$2.chain.pem" openssl x509 -in "$1/$2.chain.pem" -out "$1/$2.crt" openssl verify -CAfile "$ROOT" -untrusted "$1/$2.chain.pem" "$1/$2.crt" >/dev/null || fail "The received certificate does not verify." [ "$(openssl x509 -in "$1/$2.crt" -noout -pubkey | openssl pkey -pubin -outform DER | openssl dgst -sha256)" = \ "$(openssl pkey -in "$1/$2.key" -pubout -outform DER | openssl dgst -sha256)" ] || fail "The received certificate is not for the key made here." printf '\n'; say "Issued: $(openssl x509 -in "$1/$2.crt" -noout -subject -nameopt RFC2253 | sed 's/^subject=//')" say "$(openssl x509 -in "$1/$2.crt" -noout -enddate -serial | tr '\n' ' ')" if [ -n "$PFX" ]; then openssl pkcs12 -export -inkey "$1/$2.key" -in "$1/$2.crt" -certfile "$1/$2.chain.pem" -name "$2" -out "$1/$2.pfx" fi printf '\n'; say "Files: $1"; for f in "$1"/*; do say " $(basename "$f")"; done } # ================================================================ VK confirmation while :; do if [ -z "$NAME" ]; then printf '\n'; say 'Certificate name: one word (a-z 0-9 -) gives .external.alagezia.internal;' say 'a full name (host.alagezia.internal) works only if it is reserved for your VK account.' ask 'Name: '; NAME=$(printf '%s' "$REPLY" | tr 'A-Z' 'a-z') fi case "$NAME" in *[!a-z0-9.-]*|'') say 'Name: a-z 0-9 . - only.'; NAME=""; [ -n "$INTERACTIVE" ] || exit 1; continue ;; esac case "$DAYS" in ''|*[!0-9]*) d=null ;; *) d=$DAYS ;; esac printf '{"name":"%s","days":%s}' "$NAME" "$d" > "$W/hello.json" st=$(api POST hello "$W/hello.json") [ "$st" = 200 ] && break { [ -n "$INTERACTIVE" ] && ! transient "$st"; } || refusal "$st" say "Not accepted: $(jget "$W/resp" message)"; NAME=""; DAYS="" done CN=$(jget "$W/resp" cn); say "Name: $CN" [ "$(jget "$W/resp" bot)" = online ] || fail "The VK confirmation service is offline right now: a code could not be confirmed. Try again later." if [ -z "$DAYS" ]; then DAYS=$(jget "$W/resp" days) if [ -n "$INTERACTIVE" ]; then ask "Days (1-$(jget "$W/resp" max_days)) [$DAYS]: "; case "$REPLY" in ''|*[!0-9]*) ;; *) DAYS=$REPLY ;; esac; fi fi D=$BASE/$CN; mkdir -p "$D" new_key "$D" "$CN" "$CN" FP=$(key_fp "$D/$CN.key") csr=$(awk '{printf "%s\\n", $0}' "$D/$CN.csr") printf '{"name":"%s","days":%s,"csr":"%s"}' "$CN" "$DAYS" "$csr" > "$W/open.json" st=$(api POST open "$W/open.json"); [ "$st" = 201 ] || refusal "$st" cp "$W/resp" "$W/ticket" ID=$(jget "$W/ticket" id); SECRET=$(jget "$W/ticket" secret); CODE=$(jget "$W/ticket" code) if [ "$(jget "$W/ticket" key_fp)" != "$FP" ]; then api POST "cancel/$ID" "" "$SECRET" >/dev/null fail "The CA saw a different key ($(jget "$W/ticket" key_fp)) than this host made ($FP): the request was altered on the way. Cancelled." fi DONE="" cancel() { [ -n "$DONE" ] || { api POST "cancel/$ID" "" "$SECRET" >/dev/null 2>&1 || true; printf '\n'; say 'Request cancelled.'; }; } trap 'cancel; rm -rf "$W"; exit 130' INT TERM printf '\n'; say '==================================================' say " Confirmation code: $(printf '%s' "$CODE" | cut -c1-4) $(printf '%s' "$CODE" | cut -c5-8)" say " Key fingerprint: $FP" say '==================================================' say "In a PRIVATE chat with the VK community Alagezia: $VK_GROUP" say "(direct link to the chat: $VK_CHAT) open" say '"Sertifikaty" -> "Vypustit sertifikat" (certificates -> issue) and send the code there' say 'within 5 minutes. A code sent anywhere else is ignored, group chats included.' say 'The reply shows a key fingerprint: it must be exactly the one above - otherwise press "Cancel" there.' say 'Ctrl+C here cancels the request.'; printf '\n' LAST=""; TROUBLE=""; END=$(( $(date +%s) + 660 )) while [ "$(date +%s)" -lt "$END" ]; do st=$(api GET "t/$ID" "" "$SECRET") # temporary trouble (no network, rate limit shared with neighbours behind one address, CA restarting) # must not cost the request: the code may already be on its way in VK. Only a final answer ends the wait. if transient "$st"; then [ -n "$TROUBLE" ] || say "Connection problem (HTTP $st), retrying..."; TROUBLE=1; sleep 10; continue fi [ "$st" = 200 ] || { cancel; refusal "$st"; } [ -z "$TROUBLE" ] || { say 'Connection restored.'; TROUBLE=""; } S=$(jget "$W/resp" state) if [ "$S" != "$LAST" ]; then case "$S" in open) say 'Waiting for the code in VK...' ;; claimed) say "Code received from VK id $(jget "$W/resp" vk_id). Waiting for 'issue' in VK..." ;; issuing) say 'Issuing...' ;; rejected) DONE=1; fail 'The request was cancelled.' ;; expired) DONE=1; fail 'Time is up - run the script again for a new code.' ;; failed) DONE=1; fail 'The CA could not issue the certificate.' ;; esac LAST=$S fi [ "$S" = issued ] && { DONE=1; break; } sleep 5 done [ -n "$DONE" ] || { cancel; fail 'Time is up - run the script again for a new code.'; } jget "$W/resp" chain > "$W/chain.pem" save "$D" "$CN" "$W/chain.pem" printf '\n'; say 'Services: https://hanei-no-choten.com/' exit 0