#!/bin/sh set -eu # HitherGate there installer, served at https://get.hithergate.com. The portal's # "Add a there" shows the line that runs it, with the token in the # environment rather than on a command line: # # curl -fsSL https://get.hithergate.com | HITHER_ENROLL_TOKEN= sh -s -- --server= --name= # # What it does (docs/adr/0037-there-installer-verification.md): # # 1. Downloads two signed manifests from $HITHER_DOWNLOAD_URL (default # https://get.hithergate.com/there/latest): there.manifest.json (the # installer manifest) and there-runtime.manifest.json (the # runtime manifest). Both use the envelope that there-release # signs and the supervisor verifies (there/supervisor/release). # 2. Verifies each envelope's Ed25519 signature against the release keys # embedded below, with OpenSSL 3 (`openssl pkeyutl -verify -rawin`). # Without OpenSSL 3 it refuses, unless --insecure-skip-signature is # passed. That flag is never the default. # 3. Downloads there and there-runtime for this OS and # architecture from the URLs in the verified manifests, and checks each # file's size and SHA-256 against its manifest. # 4. Runs `there enroll`, with the token on standard input, which # installs the binaries and the system service (systemd or launchd). # From then on the supervisor verifies every runtime it downloads with # its own compiled-in keys. # # Nothing is installed unless every check passes. The script is one # function that is called on its last line, so a download cut short runs # nothing. # # Options: # --server=URL (required) the control plane # --name=NAME the there name (default: the host name) # --user-mode install for this user only, no root # --token=TOKEN the enrollment token, for scripts; it is then # visible in the process list. Otherwise the # token is read from HITHER_ENROLL_TOKEN, or # prompted for on the terminal # --insecure-skip-signature install without checking the release # signatures (hashes are still checked). Unsafe. # # A control plane at http://localhost is the dev stack (deploy/compose): the # there then runs with --dev=true and the stack's single self-signed relay at # localhost:8443. HITHER_DEV_RELAY_DERP_PORT and HITHER_DEV_RELAY_STUN_PORT # override the relay ports. Any other plain-HTTP control plane is refused. # The trusted release public keys (base64 Ed25519). They are the same two # keys compiled into there (THERE_RELEASE_PUBKEY_1 and _2, see # there/supervisor/release/keys.go), and the there-release workflow fills them # in when it publishes this script. Two keys allow one to be rotated. With # both empty, nothing is trusted. HITHER_RELEASE_KEY_1='9pBAw7pHsPx7q4PUbNJBR6y9Ag82IJy3MD/QpuqMlsg=' HITHER_RELEASE_KEY_2='' INSTALLER_CONTEXT='hithergate there installer manifest v1' RUNTIME_CONTEXT='hithergate there runtime manifest v1' die() { echo "hithergate install: $*" >&2 exit 1 } say() { echo "hithergate install: $*" >&2; } have() { command -v "$1" >/dev/null 2>&1; } # openssl3 is true when openssl is OpenSSL 3 or later (Ed25519 with -rawin). # LibreSSL, such as macOS's /usr/bin/openssl, does not qualify. openssl3() { have openssl || return 1 case "$(openssl version 2>/dev/null)" in "OpenSSL "[3-9].*|"OpenSSL "[1-9][0-9]*) return 0 ;; esac return 1 } b64decode() { # file_in file_out if have openssl; then openssl base64 -d -A -in "$1" -out "$2" elif base64 -d <"$1" >"$2" 2>/dev/null; then : else base64 -D <"$1" >"$2" fi } sha256_of() { if have sha256sum; then sha256sum "$1" | cut -d' ' -f1 elif have shasum; then shasum -a 256 "$1" | cut -d' ' -f1 elif have openssl; then openssl dgst -sha256 -r "$1" | cut -d' ' -f1 else die "sha256sum, shasum or openssl is required" fi } # fetch URL FILE downloads over HTTPS only. fetch() { curl -fsSL --proto '=https' --tlsv1.2 --max-filesize 209715200 -o "$2" "$1" } # key_id B64 prints the key's ID as release.KeyID does: the first 8 bytes # of the SHA-256 of the raw public key, in hex. key_id() { printf '%s' "$1" >"$tmp/k.b64" b64decode "$tmp/k.b64" "$tmp/k.raw" [ "$(wc -c <"$tmp/k.raw" | tr -d ' ')" = 32 ] || die "an embedded release key is not a 32-byte Ed25519 key" sha256_of "$tmp/k.raw" | cut -c1-16 } # verify_envelope ENVELOPE CONTEXT PAYLOAD_OUT checks that a signature by a # trusted key verifies over CONTEXT "\n" payload, and writes the payload. verify_envelope() { ve_file=$1 ve_context=$2 ve_out=$3 # there-release writes the envelope indented, one field per line, # and every value is base64 or hex, so no JSON escaping is involved. sed -n 's/^ *"payload": *"\([A-Za-z0-9+/=]*\)",\{0,1\} *$/\1/p' "$ve_file" >"$tmp/payload.b64" [ -s "$tmp/payload.b64" ] || die "$(basename "$ve_file") has no payload" b64decode "$tmp/payload.b64" "$ve_out" if [ -n "$skip_signature" ]; then return 0 fi printf '%s\n' "$ve_context" >"$tmp/msg" cat "$ve_out" >>"$tmp/msg" sed -n -e 's/^ *"key_id": *"\([0-9a-f]*\)",\{0,1\} *$/key_id \1/p' \ -e 's/^ *"sig": *"\([A-Za-z0-9+/=]*\)",\{0,1\} *$/sig \1/p' "$ve_file" >"$tmp/sigs" for ve_key in "$HITHER_RELEASE_KEY_1" "$HITHER_RELEASE_KEY_2"; do [ -n "$ve_key" ] || continue ve_kid=$(key_id "$ve_key") # The DER SubjectPublicKeyInfo of an Ed25519 key: a fixed 12-byte prefix # (302a300506032b6570032100) and the 32 raw bytes. printf '\060\052\060\005\006\003\053\145\160\003\041\000' >"$tmp/k.der" cat "$tmp/k.raw" >>"$tmp/k.der" ve_want='' while read -r ve_field ve_value; do if [ "$ve_field" = key_id ]; then ve_want=$ve_value elif [ "$ve_field" = sig ] && [ "$ve_want" = "$ve_kid" ]; then printf '%s' "$ve_value" >"$tmp/sig.b64" b64decode "$tmp/sig.b64" "$tmp/sig" if openssl pkeyutl -verify -pubin -keyform DER -inkey "$tmp/k.der" -rawin \ -in "$tmp/msg" -sigfile "$tmp/sig" >/dev/null 2>&1; then return 0 fi fi done <"$tmp/sigs" done die "$(basename "$ve_file") is not signed by a trusted HitherGate release key; nothing was installed" } # field PAYLOAD NAME prints a top-level string field of a manifest payload # (compact JSON from json.Marshal). field() { sed -n "s/.*\"$2\":\"\([^\"\\\\]*\)\".*/\1/p" "$1" } # get_artifact PAYLOAD PRODUCT VERSION NAME downloads NAME for this platform # from the URL in a verified payload and checks its size and SHA-256. # # POSIX sh has no local variables, so every helper's variables carry a # prefix of their own and never overwrite main's (such as name or version). get_artifact() { ga_payload=$1 ga_product=$2 ga_version=$3 ga_name=$4 grep -q '^{"schema":1,' "$ga_payload" || die "$ga_name manifest: unknown schema" [ "$(field "$ga_payload" product)" = "$ga_product" ] || die "$ga_name manifest: product is not $ga_product" [ "$(field "$ga_payload" version)" = "$ga_version" ] || die "$ga_name manifest: version is not $ga_version" ga_line=$(sed -n "s|.*\"$os/$arch\":{\"url\":\"\(https://[^\" \\\\]*\)\",\"sha256\":\"\([0-9a-f]\{64\}\)\",\"size\":\([0-9][0-9]*\)}.*|\1 \2 \3|p" "$ga_payload") [ -n "$ga_line" ] || die "$ga_name: no build for $os/$arch in release $ga_version" ga_url=${ga_line%% *} ga_rest=${ga_line#* } ga_sum=${ga_rest%% *} ga_size=${ga_rest#* } fetch "$ga_url" "$tmp/$ga_name" [ "$(wc -c <"$tmp/$ga_name" | tr -d ' ')" = "$ga_size" ] || die "$ga_name does not match its signed manifest (size); nothing was installed" [ "$(sha256_of "$tmp/$ga_name")" = "$ga_sum" ] || die "$ga_name does not match its signed manifest (SHA-256); nothing was installed" chmod 0755 "$tmp/$ga_name" } main() { server='' token='' name='' user_mode='' skip_signature='' for arg in "$@"; do case "$arg" in --server=*) server=${arg#--server=} ;; --token=*) token=${arg#--token=} ;; --name=*) name=${arg#--name=} ;; --user-mode) user_mode=1 ;; --insecure-skip-signature) skip_signature=1 ;; *) echo "hithergate install: unknown option: $arg" >&2; exit 2 ;; esac done [ -n "$server" ] || { echo "hithergate install: --server is required" >&2; exit 2; } case "$(uname -s)" in Linux) os=linux ;; Darwin) os=darwin ;; *) die "$(uname -s) is not supported yet" ;; esac case "$(uname -m)" in x86_64|amd64) arch=amd64 ;; aarch64|arm64) arch=arm64 ;; *) die "$(uname -m) is not supported" ;; esac dev_flags='' case "$server" in https://*) ;; http://localhost|http://localhost:*) dev_flags="--dev=true --relay-host=localhost --relay-derp-port=${HITHER_DEV_RELAY_DERP_PORT:-8443} --relay-stun-port=${HITHER_DEV_RELAY_STUN_PORT:-3478} --relay-insecure-for-tests=true" say "$server is a local dev control plane; using the dev relay" ;; *) echo "hithergate install: --server must be an https:// URL" >&2; exit 2 ;; esac # The token: --token, else HITHER_ENROLL_TOKEN, else a prompt on the # terminal (stdin is this script when it is piped from curl). if [ -z "$token" ]; then token=${HITHER_ENROLL_TOKEN:-} fi unset HITHER_ENROLL_TOKEN || true if [ -z "$token" ]; then if (: /dev/null; then printf 'HitherGate enrollment token: ' >/dev/tty stty -echo /dev/null || true IFS= read -r token /dev/null || true printf '\n' >/dev/tty fi [ -n "$token" ] || { echo "hithergate install: no enrollment token: set HITHER_ENROLL_TOKEN, or run from a terminal" >&2; exit 2; } fi sudo='' if [ -z "$user_mode" ] && [ "$(id -u)" != 0 ]; then have sudo || die "run as root, or pass --user-mode" sudo=sudo fi have curl || die "curl is required" if [ -n "$skip_signature" ]; then say "WARNING: --insecure-skip-signature: the release signatures are NOT checked; only use this on a network you trust" else openssl3 || die "OpenSSL 3 is required to check the release signatures (install openssl), or pass --insecure-skip-signature (unsafe)" [ -n "$HITHER_RELEASE_KEY_1$HITHER_RELEASE_KEY_2" ] || die "this installer has no release keys; download it from https://get.hithergate.com" fi base=${HITHER_DOWNLOAD_URL:-https://get.hithergate.com/there/latest} tmp=$(mktemp -d) trap 'rm -rf "${tmp:?}"' EXIT INT TERM say "checking the signed release manifests" fetch "$base/there.manifest.json" "$tmp/there.manifest" fetch "$base/there-runtime.manifest.json" "$tmp/runtime.manifest" verify_envelope "$tmp/there.manifest" "$INSTALLER_CONTEXT" "$tmp/there.payload" verify_envelope "$tmp/runtime.manifest" "$RUNTIME_CONTEXT" "$tmp/runtime.payload" version=$(field "$tmp/there.payload" version) [ -n "$version" ] || die "the installer manifest has no version" say "downloading HitherGate there $version for $os-$arch" get_artifact "$tmp/there.payload" there "$version" there get_artifact "$tmp/runtime.payload" there-runtime "$version" there-runtime set -- enroll - "--control-plane=$server" [ -z "$name" ] || set -- "$@" "--name=$name" [ -z "$user_mode" ] || set -- "$@" --user-mode # shellcheck disable=SC2086 # dev_flags is a list of flags without spaces set -- "$@" $dev_flags say "enrolling and installing the service" printf '%s\n' "$token" | $sudo "$tmp/there" "$@" } main "$@"