#!/bin/sh # Warden installer, served at https://wardensec.ai/install (distribution design 3.7). # # It fetches only from https://wardensec.ai/releases/, checks every record against the built-in # root 1 with the shell verifier below, and installs one file, bin/warden, with its trust files # under ${XDG_DATA_HOME:-$HOME/.local/share}/warden, plus at most one symlink in ~/.local/bin. # No sudo, no package manager, no shell start-up file, no eval, nothing in the background, and # nothing about this machine is sent anywhere. No byte runs before its hash matched a signed record. # # v1.0: the release log has no witnesses yet. This script requires the release signatures only and # refuses a root that asks for witness cosignatures. It prints the root fingerprints so you can # compare them with the ones published outside this website. # # The whole body runs from main(), called on the last line, so a cut download runs nothing. set -eu # >>> sshsig_ok from launcher/sshsig-ok.sh sha256=2e7b190a2b9d252664f92d1afd3a2a1216be4f164721f1902e0efcc3e47e39bf (generated; edit the source, not this copy) # shellcheck shell=sh # sshsig_ok: the shell's one verdict on an SSHSIG signature (distribution 3.7 step 2, 12.3 L5). # # This file is the one source. tools/launcher.ts embeds it, byte for byte, in the launcher and the one-line # installer; never edit an embedded copy. POSIX sh: it runs under dash and bash --posix, and needs only ssh-keygen, # base64 (-d or -D), od, tr, wc, awk and mktemp. # # ssh-keygen -Y verify checks the cryptography, the namespace and the principal, but enforces neither user presence, # user verification nor the signature counter, and tolerates armour and fields the TypeScript verifier # (src/release/sshsig.ts) refuses. So this function also reads the signature itself: strict armour, the exact blob # structure for the named key and namespace (version 1, empty reserved field, sha512, nothing trailing), and then the # five bytes the authenticator signed after the signature: flags must be exactly UP|UV (0x05) and the counter above 0 # and above the stored one. src/release/sshsig.differential.test.ts holds this verdict equal to the TypeScript one. # # sshsig_ok NAMESPACE KID ALLOWED_SIGNERS SIG MESSAGE [STORED_COUNTER] # # ALLOWED_SIGNERS holds one line per key, ` namespaces="" `, built by the caller # from verified roots; KID names the one line whose key must have made SIG over the bytes of MESSAGE. # STORED_COUNTER (decimal, default 0) is the counter already seen for KID; an index signature must exceed it. # 0 the signature counts. SSHSIG_COUNTER holds its counter (empty for an ssh-ed25519 key, which has none), and # SSHSIG_CLASS the key's class from its type alone (amendment 02 B4.2): `software` for ssh-ed25519 (a password # manager's SSH agent, a file, a witness), `security_key` for sk-ssh-ed25519@openssh.com. Empty on any refusal. # 1 it does not. SSHSIG_REFUSED holds one closed code: TOOL ARMOUR OVERSIZE STRUCTURE KEY VERIFY FLAGS COUNTER # COUNTER_STALE. # 2 the caller's arguments are wrong (SSHSIG_REFUSED=ARGS): bad namespace or kid shape, no single matching # allowed line, a stored counter that is not plain decimal, or a stored counter for a key that has none. # It writes nothing to stdout or stderr and never evaluates what it reads. SSHSIG_COUNTER= SSHSIG_CLASS= SSHSIG_REFUSED= _wd_b64_flag= # Chooses the decoder once per shell: `base64 -d` (GNU, BusyBox, recent macOS) or `base64 -D` (older macOS). _wd_b64_init() { [ -n "$_wd_b64_flag" ] && return 0 if [ "$(printf 'QQ==' | base64 -d 2>/dev/null)" = A ]; then _wd_b64_flag=-d elif [ "$(printf 'QQ==' | base64 -D 2>/dev/null)" = A ]; then _wd_b64_flag=-D else return 1 fi } _wd_b64d() { base64 "$_wd_b64_flag" 2>/dev/null } _wd_hex() { od -An -tx1 -v | tr -d ' \t\n' } sshsig_ok() { SSHSIG_COUNTER= SSHSIG_CLASS= SSHSIG_REFUSED=ARGS [ "$#" -eq 5 ] || [ "$#" -eq 6 ] || return 2 _wd_ns=$1 _wd_kid=$2 _wd_as=$3 _wd_sig=$4 _wd_msg=$5 _wd_min=${6-0} case $_wd_ns in '' | *[!a-z0-9-]*) return 2 ;; esac case $_wd_kid in wd[rw]-[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]) ;; *) return 2 ;; esac case $_wd_min in '' | *[!0-9]* | 0?*) return 2 ;; esac [ "${#_wd_min}" -le 10 ] && [ "$_wd_min" -le 4294967295 ] || return 2 [ -f "$_wd_as" ] && [ -r "$_wd_as" ] && [ -f "$_wd_msg" ] && [ -r "$_wd_msg" ] || return 2 # The one allowed line for the kid, with exactly this namespace option. _wd_line=$(LC_ALL=C awk -v k="$_wd_kid" -v o="namespaces=\"$_wd_ns\"" ' $1 == k { n++; if (NF == 4 && $2 == o) t = $3 " " $4; else t = "" } END { if (n == 1 && t != "") print t }' "$_wd_as") || return 2 [ -n "$_wd_line" ] || return 2 _wd_type=${_wd_line%% *} _wd_kb64=${_wd_line#* } case $_wd_type in sk-ssh-ed25519@openssh.com) _wd_sk=1 ;; ssh-ed25519) _wd_sk=0 [ "$_wd_min" = 0 ] || return 2 ;; *) return 2 ;; esac case $_wd_kb64 in '' | *[!A-Za-z0-9+/=]*) return 2 ;; esac _wd_b64_init || { SSHSIG_REFUSED=TOOL return 1 } _wd_khex=$(printf '%s' "$_wd_kb64" | _wd_b64d | _wd_hex) || return 2 _wd_thex=$(printf '%08x' "${#_wd_type}")$(printf '%s' "$_wd_type" | _wd_hex) || return 2 case $_wd_khex in "$_wd_thex"00000020*) ;; *) return 2 ;; esac if [ "$_wd_sk" = 1 ]; then # string(type) string(32-byte key) string(application) [ "${#_wd_khex}" -ge 140 ] || return 2 _wd_al=$(printf '%s' "$_wd_khex" | cut -c 133-140) || return 2 [ "${#_wd_khex}" -eq $((140 + 2 * 0x$_wd_al)) ] || return 2 else [ "${#_wd_khex}" -eq 102 ] || return 2 fi # Strict armour: printable ASCII and LF only, exact header and footer, body lines of 1 to 76 base64 characters, # canonical base64, at most 4096 bytes of text and 1024 bytes of blob. [ -f "$_wd_sig" ] && [ -r "$_wd_sig" ] || { SSHSIG_REFUSED=ARMOUR return 1 } _wd_size=$(wc -c <"$_wd_sig" | tr -d ' \t') || return 1 [ "$_wd_size" -le 4096 ] || { SSHSIG_REFUSED=OVERSIZE return 1 } SSHSIG_REFUSED=ARMOUR _wd_bad=$(LC_ALL=C tr -d '\n\040-\176' <"$_wd_sig" | wc -c | tr -d ' \t') || return 1 [ "$_wd_bad" = 0 ] || return 1 _wd_b64=$(LC_ALL=C awk ' { l[NR] = $0 } END { if (NR < 3 || l[1] != "-----BEGIN SSH SIGNATURE-----" || l[NR] != "-----END SSH SIGNATURE-----") exit 1 s = "" for (i = 2; i < NR; i++) { if (length(l[i]) > 76 || l[i] !~ /^[A-Za-z0-9+\/=]+$/) exit 1 s = s l[i] } if (s !~ /^[A-Za-z0-9+\/]+(=|==)?$/ || length(s) % 4 != 0) exit 1 print s }' "$_wd_sig") || return 1 [ -n "$_wd_b64" ] || return 1 case $_wd_b64 in *==) _wd_pad=2 ;; *=) _wd_pad=1 ;; *) _wd_pad=0 ;; esac [ $((${#_wd_b64} / 4 * 3 - _wd_pad)) -le 1024 ] || { SSHSIG_REFUSED=OVERSIZE return 1 } _wd_re=$(printf '%s' "$_wd_b64" | _wd_b64d | base64 | tr -d '\r\n') || return 1 [ "$_wd_re" = "$_wd_b64" ] || return 1 _wd_shex=$(printf '%s' "$_wd_b64" | _wd_b64d | _wd_hex) || return 1 # The exact structure: "SSHSIG", version 1, the named key, the namespace, an empty reserved field, sha512, then a # signature blob of the key's type holding a 64-byte signature and, for an sk key, the flags and counter. _wd_head=53534853494700000001 case $_wd_shex in "$_wd_head"*) ;; *) SSHSIG_REFUSED=STRUCTURE return 1 ;; esac _wd_want=$_wd_head$(printf '%08x' $((${#_wd_khex} / 2)))$_wd_khex case $_wd_shex in "$_wd_want"*) ;; *) SSHSIG_REFUSED=KEY return 1 ;; esac if [ "$_wd_sk" = 1 ]; then _wd_slen=103 _wd_tail=10 else _wd_slen=83 _wd_tail=0 fi _wd_nshex=$(printf '%s' "$_wd_ns" | _wd_hex) || return 1 _wd_want=$_wd_want$(printf '%08x' "${#_wd_ns}")$_wd_nshex _wd_want=${_wd_want}00000000 _wd_want=${_wd_want}00000006736861353132 _wd_want=$_wd_want$(printf '%08x' "$_wd_slen")$_wd_thex _wd_want=${_wd_want}00000040 SSHSIG_REFUSED=STRUCTURE case $_wd_shex in "$_wd_want"*) ;; *) return 1 ;; esac [ "${#_wd_shex}" -eq $((${#_wd_want} + 128 + _wd_tail)) ] || return 1 SSHSIG_REFUSED=VERIFY ssh-keygen -Y verify -n "$_wd_ns" -I "$_wd_kid" -f "$_wd_as" -s "$_wd_sig" <"$_wd_msg" >/dev/null 2>&1 || return 1 if [ "$_wd_sk" = 1 ]; then # The last five bytes, covered by the signature ssh-keygen has just checked: flags, then the counter (big-endian). _wd_fc=${_wd_shex#"${_wd_shex%??????????}"} _wd_flags=${_wd_fc%????????} _wd_ctr=$((0x${_wd_fc#??})) SSHSIG_REFUSED=FLAGS [ "$_wd_flags" = 05 ] || return 1 SSHSIG_REFUSED=COUNTER [ "$_wd_ctr" -gt 0 ] || return 1 SSHSIG_REFUSED=COUNTER_STALE [ "$_wd_ctr" -gt "$_wd_min" ] || return 1 SSHSIG_COUNTER=$_wd_ctr SSHSIG_CLASS=security_key else SSHSIG_CLASS=software fi SSHSIG_REFUSED= return 0 } # The self-test (3.7 step 2): four built-in vectors over one built-in sk-ssh-ed25519 key. Flags 0x05 with counter 7 # must count; flags 0x01, 0x04 and 0x00 must each be refused by the flag reader after ssh-keygen accepted them. It # runs in a subshell, in a private mktemp directory removed on exit, and touches nothing else. sshsig_selftest() ( _wd_d=$(mktemp -d 2>/dev/null) || exit 1 [ -n "$_wd_d" ] && [ -d "$_wd_d" ] || exit 1 trap 'rm -rf "$_wd_d"' EXIT trap 'exit 1' HUP INT TERM _wd_k=wdr-9e8cce6a9b10ecd3 _wd_n=wardensec-release-v1 printf '%s\n' 'wardensec sshsig fixture' >"$_wd_d/m" printf '%s\n' 'wdr-9e8cce6a9b10ecd3 namespaces="wardensec-release-v1" sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29tAAAAIEysq9Gc/YYK19IM3fbpkZiRbWrijl3PAT7M8/9oWGrFAAAAFXNzaDp3YXJkZW5zZWMtcmVsZWFzZQ==' >"$_wd_d/a" cat >"$_wd_d/05" <<'EOF' -----BEGIN SSH SIGNATURE----- U1NIU0lHAAAAAQAAAFsAAAAac2stc3NoLWVkMjU1MTlAb3BlbnNzaC5jb20AAAAgTKyr0Z z9hgrX0gzd9umRmJFtauKOXc8BPszz/2hYasUAAAAVc3NoOndhcmRlbnNlYy1yZWxlYXNl AAAAFHdhcmRlbnNlYy1yZWxlYXNlLXYxAAAAAAAAAAZzaGE1MTIAAABnAAAAGnNrLXNzaC 1lZDI1NTE5QG9wZW5zc2guY29tAAAAQElqHUFI4HBSkqQuiH+MeU8jmt+rsezYGO5iWVbK KttPpsOyQSVkJDzVgvKYua9OrA9balbsdZvoyonkZ0u0NAsFAAAABw== -----END SSH SIGNATURE----- EOF cat >"$_wd_d/01" <<'EOF' -----BEGIN SSH SIGNATURE----- U1NIU0lHAAAAAQAAAFsAAAAac2stc3NoLWVkMjU1MTlAb3BlbnNzaC5jb20AAAAgTKyr0Z z9hgrX0gzd9umRmJFtauKOXc8BPszz/2hYasUAAAAVc3NoOndhcmRlbnNlYy1yZWxlYXNl AAAAFHdhcmRlbnNlYy1yZWxlYXNlLXYxAAAAAAAAAAZzaGE1MTIAAABnAAAAGnNrLXNzaC 1lZDI1NTE5QG9wZW5zc2guY29tAAAAQGoAvHa9KTIPNMEHmgIFdFZRs4wrp/zqaU1t1twK zc6zCQKetXEzWvZm5KHH2iu6cnypoGqG3fL2sit3mhu1zwcBAAAABw== -----END SSH SIGNATURE----- EOF cat >"$_wd_d/04" <<'EOF' -----BEGIN SSH SIGNATURE----- U1NIU0lHAAAAAQAAAFsAAAAac2stc3NoLWVkMjU1MTlAb3BlbnNzaC5jb20AAAAgTKyr0Z z9hgrX0gzd9umRmJFtauKOXc8BPszz/2hYasUAAAAVc3NoOndhcmRlbnNlYy1yZWxlYXNl AAAAFHdhcmRlbnNlYy1yZWxlYXNlLXYxAAAAAAAAAAZzaGE1MTIAAABnAAAAGnNrLXNzaC 1lZDI1NTE5QG9wZW5zc2guY29tAAAAQJCRBV+eguDZ1AhSn9E+6QJfI41M/uQWtJbNoDFX wAxG4xa5tzvb/vFDdRNno2QlPpphIYwInW2L3vlgHAtGdwAEAAAABw== -----END SSH SIGNATURE----- EOF cat >"$_wd_d/00" <<'EOF' -----BEGIN SSH SIGNATURE----- U1NIU0lHAAAAAQAAAFsAAAAac2stc3NoLWVkMjU1MTlAb3BlbnNzaC5jb20AAAAgTKyr0Z z9hgrX0gzd9umRmJFtauKOXc8BPszz/2hYasUAAAAVc3NoOndhcmRlbnNlYy1yZWxlYXNl AAAAFHdhcmRlbnNlYy1yZWxlYXNlLXYxAAAAAAAAAAZzaGE1MTIAAABnAAAAGnNrLXNzaC 1lZDI1NTE5QG9wZW5zc2guY29tAAAAQBgq3N54/uRW7Q+pwBac/UxvHVGPhW+RBP3Sbs3E fgBObYOf2Ov+5lXMN5d489FMaXHp7jpmdul9NKyVJImoTwYAAAAABw== -----END SSH SIGNATURE----- EOF sshsig_ok "$_wd_n" "$_wd_k" "$_wd_d/a" "$_wd_d/05" "$_wd_d/m" || exit 1 [ "$SSHSIG_COUNTER" = 7 ] && [ "$SSHSIG_CLASS" = security_key ] || exit 1 for _wd_v in 01 04 00; do if sshsig_ok "$_wd_n" "$_wd_k" "$_wd_d/a" "$_wd_d/$_wd_v" "$_wd_d/m"; then exit 1; fi [ "$SSHSIG_REFUSED" = FLAGS ] || exit 1 done exit 0 ) # The self-test, with the one sentence a person sees when this machine cannot verify the way Warden needs. sshsig_require_selftest() { if sshsig_selftest; then return 0 fi printf '%s\n' "This machine's OpenSSH cannot verify security-key signatures the way Warden needs (8.2 or later is needed). Nothing was installed." >&2 return 1 } # <<< sshsig_ok # The software-key self-test (amendment 02 B4.5), beside the four sk vectors above: one ssh-ed25519 signature made by # ssh-keygen must count, with no counter, and the same signature over other bytes must not. in_selftest_soft() ( in_d=$(mktemp -d 2>/dev/null) && [ -n "$in_d" ] && [ -d "$in_d" ] || exit 1 trap 'rm -rf "$in_d"' EXIT trap 'exit 1' HUP INT TERM in_k=wdr-6d24359181322aa5 printf '%s\n' 'wdr-6d24359181322aa5 namespaces="wardensec-release-v1" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIqGvIUM8VUb89lbUHwgmX/Ybinik40SSRgATq1gthvn' >"$in_d/a" printf '%s\n' 'wardensec sshsig fixture' >"$in_d/m" cat >"$in_d/s" <<'EOF' -----BEGIN SSH SIGNATURE----- U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgioa8hQzxVRvz2VtQfCCZf9huKe KTjRJJGABOrWC2G+cAAAAUd2FyZGVuc2VjLXJlbGVhc2UtdjEAAAAAAAAABnNoYTUxMgAA AFMAAAALc3NoLWVkMjU1MTkAAABA2YQ/J+tXD9hjDRC6ZX86i02d3uyKXX46Sl9v7ktc28 2QjONF+v6ynGmB/zqbL2UluU7lCphu0T9wHv4PHoTvAw== -----END SSH SIGNATURE----- EOF sshsig_ok wardensec-release-v1 "$in_k" "$in_d/a" "$in_d/s" "$in_d/m" && [ -z "$SSHSIG_COUNTER" ] || exit 1 printf '%s\n' 'wardensec sshsig fixturf' >"$in_d/m" if sshsig_ok wardensec-release-v1 "$in_k" "$in_d/a" "$in_d/s" "$in_d/m"; then exit 1; fi [ "$SSHSIG_REFUSED" = VERIFY ] ) # Root 1, built in. Every later root, index and release must chain to it. in_root1() { cat <<'WD_ROOT_1' wardensec-root 1 test serial 1 issued 2026-10-05T00:00:00Z threshold root 2 threshold release 2 threshold witness 0 key wdr-df18676db2150330 root ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAekynh+WcrEEoSO5aS5nNn5Uvp1k6ya9EEJ6fomJCUM key wdr-257f477deccc1286 root ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMLtiqK05/jdzrgypah5g2DBQjOhA0X5J2KwhZDH9TdR key wdr-00d3c71c2c712a77 root ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINRGgzGrDOS9AbuqpDsMx8nfSCsUJ+lIpZ3huUi6DvVd key wdr-6503078db33d4978 release ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKkxCv/gMMEqA1NLq3CC1x+tuHT//Kd2SAPivlCFSYvy key wdr-b4db3216513e668e release ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGrIlJv91W1ZVKxzlWaJEVyADx+nDOKWPQHBr6TU3oax key wdr-de7692dcc227c632 release ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMGL4Gf9lm4uiYX8tevNRXBFFOXexLofAfdjNP7E8NDo end WD_ROOT_1 } in_say() { printf '%s\n' "$@"; } # Every refusal is a closed code and a fixed sentence; nothing fetched is ever printed. in_die() { printf '%s\n' "Warden install stopped ($1): $2" "Nothing was installed." >&2; exit 1; } in_sha() { if command -v sha256sum >/dev/null 2>&1; then sha256sum <"$1"; else shasum -a 256 <"$1"; fi | awk '{ print $1 }' } in_hex() { printf '%s\n' "$1" | LC_ALL=C grep -Eqx '[0-9a-f]{64}'; } in_int() { case $1 in '' | *[!0-9]* | 0*) return 1 ;; esac && [ "${#1}" -le 9 ]; } in_iso() { case $1 in [0-9][0-9][0-9][0-9]-[0-1][0-9]-[0-3][0-9]T[0-2][0-9]:[0-5][0-9]:[0-5][0-9]Z) ;; *) return 1 ;; esac; } in_bad() { in_die "$1" "the $2 is not well formed."; } # GET one release file into the work directory: one host, HTTPS only, no redirect, no query. in_get() { rm -f "$in_w/$2" curl --proto '=https' --tlsv1.2 --max-redirs 0 --max-filesize 400000000 -fsS -A warden-installer \ -o "$in_w/$2" "https://wardensec.ai/releases/$1" 2>/dev/null } # A text record: ASCII, LF only, single spaces, no blank line, at most 64 KiB, and `end` last. in_record() { [ -f "$1" ] && in_sz=$(wc -c <"$1" | tr -d ' ') && [ "$in_sz" -gt 0 ] && [ "$in_sz" -le 65536 ] || return 1 [ "$(LC_ALL=C tr -d '\n -~' <"$1" | wc -c | tr -d ' ')" = 0 ] || return 1 [ "$(tail -c 1 "$1" | od -An -tx1 | tr -d ' \n')" = 0a ] || return 1 LC_ALL=C awk '$0 == "" || /^ / || / $/ || / / { bad = 1 } { last = $0 } END { exit (bad || last != "end") }' "$1" } # The value of the one two-field line `KEY value`; fails when there is none or more than one. in_val() { LC_ALL=C awk -v k="$2" '$1 == k { n++; v = $2; f = NF } END { if (n != 1 || f != 2) exit 1; print v }' "$1"; } in_thr() { LC_ALL=C awk -v r="$2" '$1 == "threshold" && $2 == r { print $3 }' "$1"; } # A root file's shape (amendment 02 B4.3): prints ok, test (its `test` line: never trusted), below (a root or release # key under the class its `require` line asks), witness (it asks for cosignatures this installer cannot check yet) or # bad. Lines come in the product's order. A key's class is its type: ssh-ed25519 is a software key (0), # sk-ssh-ed25519@openssh.com a security key (1); no attestation is verified here, so none is ever class 2. The # advisory feed's keys (amendment 05 3.4) are accepted and never counted: zero to four `key curator` lines # after the witnesses, zero or two to four `spki feed ` lines after them, a `custody` line for # each feed key (at least two labels among them) and optionally each curator, in key order, and the `distrust` and # `retire feed-seq ` forms. The product's parity vectors (tests/fixtures/root-grammar-vectors.json) # hold this grammar to its parser. in_root_check() { if ! in_record "$1" || [ "$(head -n 1 "$1")" != "wardensec-root 1" ] || [ "$(in_val "$1" serial)" != "$2" ]; then echo bad && return 0 fi LC_ALL=C awk ' BEGIN { n = split("wardensec-root test serial issued threshold require distinct-custody delay key spki attest custody log distrust retire reset end", o, " ") for (i = 1; i <= n; i++) rk[o[i]] = i split("threshold key spki attest custody distrust retire", m, " "); for (i in m) multi[m[i]] = 1 split("root release witness", tr, " "); ro["root"] = 1; ro["release"] = 2; ro["witness"] = 3; ro["curator"] = 4 } !($1 in rk) || rk[$1] < last || (rk[$1] == last && !($1 in multi)) { bad = 1 } { last = rk[$1] } $1 == "test" || $1 == "distinct-custody" || $1 == "end" { if (NF != 1) bad = 1; f[$1] = 1 } $1 == "issued" { iss = 1; if (NF != 2 || $2 !~ /^[0-9][0-9][0-9][0-9]-[01][0-9]-[0-3][0-9]T[0-2][0-9]:[0-5][0-9]:[0-5][0-9]Z$/) bad = 1 } $1 == "threshold" { if (NF != 3 || $2 != tr[++nt] || $3 !~ /^(0|[1-9][0-9]?[0-9]?)$/) bad = 1; t[$2] = $3 + 0 } $1 == "require" { need = $2 == "security-key" ? 1 : $2 == "attested-security-key" ? 2 : 0; if (NF != 2 || !need) bad = 1 } $1 == "delay" { if (NF != 2 || $2 !~ /^[1-9][0-9]*$/ || length($2) > 3 || $2 + 0 > 168) bad = 1 } $1 == "key" { if (NF != 5 || ($2 in seen) || ($5 in blob) || length($2) != 20 || !($3 in ro) || ro[$3] < lr) bad = 1 seen[$2] = 1; blob[$5] = 1; c[$3]++; lr = ro[$3] if ($3 == "witness") { if ($2 !~ /^wdw-[0-9a-f]+$/ || $4 != "ssh-ed25519") bad = 1 } else if ($3 == "root" || $3 == "release") { if ($2 !~ /^wdr-[0-9a-f]+$/ || ($4 != "ssh-ed25519" && $4 != "sk-ssh-ed25519@openssh.com")) bad = 1 cls[$2] = $4 == "ssh-ed25519" ? 0 : 1; idx[$2] = ++ns; ci[$2] = ++nc; rl[$2] = $3; if (cls[$2] < 1) low = 1 } else if ($3 == "curator") { if ($2 !~ /^wdc-[0-9a-f]+$/ || ($4 != "ssh-ed25519" && $4 != "sk-ssh-ed25519@openssh.com")) bad = 1 ci[$2] = ++nc } else bad = 1 } $1 == "spki" { if (NF != 4 || $3 != "feed" || $2 !~ /^wdf-[0-9a-f]+$/ || length($2) != 20 || ($2 in seen) || ($4 in blob) || $4 !~ /^[A-Za-z0-9+\/]+=*$/) bad = 1 seen[$2] = 1; blob[$4] = 1; fd[$2] = 1; nf++; ci[$2] = ++nc } $1 == "attest" { if (NF != 3 || !($2 in cls) || cls[$2] != 1 || idx[$2] <= la || $3 !~ /^sha256:[0-9a-f]+$/ || length($3) != 71) bad = 1; la = idx[$2] } $1 == "custody" { if (NF != 3 || !($2 in ci) || ci[$2] <= lc || $3 !~ /^[a-z0-9][a-z0-9.-]*$/ || length($3) > 32) bad = 1 lc = ci[$2] if ($2 in cls) { cu[$2] = $3; if (!((rl[$2], $3) in dc)) { dc[rl[$2], $3] = 1; nd[rl[$2]]++ } } else if ($2 in fd) { fcu[$2] = 1; if (!($3 in fl)) { fl[$3] = 1; nfl++ } } } $1 == "distrust" { if (NF != 3 || $2 !~ /^wd[rwcf]-[0-9a-f]+$/ || length($2) != 20 || ($2 in dis)) bad = 1 if ($3 !~ /^[0-9][0-9][0-9][0-9]-[01][0-9]-[0-3][0-9]T[0-2][0-9]:[0-5][0-9]:[0-5][0-9]Z$/) bad = 1 dis[$2] = 1 } $1 == "retire" && $2 ~ /^wdf-/ { if ($2 !~ /^wdf-[0-9a-f]+$/ || length($2) != 20 || ($2 in seen) || ($2 in rt) || NF != 4 || $3 != "feed-seq" || $4 !~ /^[1-9][0-9]*$/ || length($4) > 15) bad = 1 rt[$2] = 1; next } $1 == "retire" { if ($2 !~ /^wdr-[0-9a-f]+$/ || length($2) != 20 || ($2 in seen) || ($2 in rt)) bad = 1 if (!(NF == 3 && $3 ~ /^(0|[1-9][0-9]*)$/ && length($3) <= 10 && $3 + 0 <= 4294967295) && !(NF == 6 && $3 == "serial" && $4 ~ /^[1-9][0-9]*$/ && $5 == "seq" && $6 ~ /^[1-9][0-9]*$/)) bad = 1 rt[$2] = 1 } END { if (nt != 3 || !iss) bad = 1 if (c["curator"] > 4 || nf == 1 || nf > 4 || (nf > 0 && nfl < 2)) bad = 1 for (k in fd) if (!(k in fcu)) bad = 1 for (i = 1; i <= 2; i++) { r = tr[i]; if (t[r] < (r == "release" && c[r] == 1 ? 1 : 2) || t[r] > c[r]) bad = 1 } if (t["witness"] > c["witness"] || (!f["test"] && c["witness"] > 0 && t["witness"] < 1)) bad = 1 if (f["distinct-custody"]) { for (k in cls) if (!(k in cu)) bad = 1; if (nd["root"] < t["root"] || nd["release"] < t["release"]) bad = 1 } print (bad ? "bad" : need > 1 || (need && low) ? "below" : f["test"] ? "test" : t["witness"] > 0 ? "witness" : "ok") }' "$1" } # Counts the distinct ROLE keys of ROOTFILE whose signature on MSG counts (sshsig_ok), dropping keys the newest root # distrusts and retired keys outside their retire line: a security key above its last counter, a software key on an # index serial or release seq above its bounds (AT is serial:, seq: or -, for a root). Sets in_got and writes # ` ` lines to $in_w/got. Args: NAMESPACE ROLE ROOTFILE SIGPATH # MSG NEWEST AT, STATE, whose `counter` lines each security key's counter must exceed (an index on a re-install), and # RETIRES, the root whose retire lines apply (NEWEST if empty): on a root, the previous one, as a root's own retire # lines bind from the next root on (verifyRootChain). in_count() { awk -v r="$2" -v ns="$1" '$1 == "key" && $3 == r { print $2 " namespaces=\"" ns "\" " $4 " " $5 }' "$3" >"$in_w/as" in_got=0 : >"$in_w/got" for in_k in $(awk '{ print $1 }' "$in_w/as"); do in_ret=$(awk -v k="$in_k" -v at="$7" 'BEGIN { split(at, a, ":") } f == 1 && $1 == "distrust" && $2 == k { x = 1 } f == 2 && $1 == "retire" && $2 == k { if (NF == 3) print $3; else if (!(a[1] == "serial" && a[2] + 0 <= $4 + 0) && !(a[1] == "seq" && a[2] + 0 <= $6 + 0)) x = 1 } END { exit x }' f=1 "$6" f=2 "${9:-$6}") || continue in_cl=$(awk -v k="$in_k" '$1 == k { print ($3 == "ssh-ed25519" ? "software" : "security") }' "$in_w/as") in_old= [ "$in_cl" = software ] || in_old=$(awk -v k="$in_k" '$1 == "counter" && $2 == k { print $3 }' "${8:-/dev/null}" 2>/dev/null) || in_old= in_get "$4.$in_k.sig" sig && sshsig_ok "$1" "$in_k" "$in_w/as" "$in_w/sig" "$5" "${in_old:-0}" || continue [ -z "$in_ret" ] || [ "$SSHSIG_COUNTER" -le "$in_ret" ] 2>/dev/null || continue in_got=$((in_got + 1)) printf '%s %s %s %s\n' "$in_k" "${SSHSIG_COUNTER:--}" \ "$(awk -v k="$in_k" '$1 == "custody" && $2 == k { c = $3 } END { print (c == "" ? "-" : c) }' "$3")" "$in_cl" >>"$in_w/got" done } # Whether the signatures in_count counted meet ROOTFILE's threshold for ROLE, or stop with CODE and SENTENCE; under # `distinct-custody` they must also come from that many distinct custodies (B-4: one stolen laptop is not enough). in_need() { in_t=$(in_thr "$1" "$2") [ "$in_got" -ge "$in_t" ] || in_die "$3" "$4" grep -qx distinct-custody "$1" || return 0 [ "$(awk '!s[$3]++ { n++ } END { print n + 0 }' "$in_w/got")" -ge "$in_t" ] || in_die ROOT_CUSTODY_DUPLICATE "the counted signatures come from too few distinct custodies." } # Root 1 to root N: each newer root needs `threshold root` of the previous root's root keys and # of its own, and carries every distrust and retire line forward. in_chain() { in_n=1 while [ "$in_n" -lt "$1" ]; do in_m=$((in_n + 1)) && in_r=$in_w/root-$in_m.txt in_get "root/$in_m.txt" "root-$in_m.txt" || in_die ROOT_FETCH "root $in_m could not be fetched." case $(in_root_check "$in_r" "$in_m") in ok) ;; witness) in_die WITNESS_REQUIRED "root $in_m asks for witness cosignatures, which this installer cannot check." ;; test) in_die ROOT_TEST "root $in_m is a test root." ;; below) in_die ROOT_KEY_CLASS_BELOW_REQUIRED "root $in_m holds a key below the class it requires." ;; *) in_bad ROOT_GRAMMAR "root $in_m" ;; esac awk 'NR == FNR { if ($1 == "distrust" || $1 == "retire") need[$0] = 1; next } { delete need[$0] } END { for (l in need) exit 1 }' "$in_w/root-$in_n.txt" "$in_r" || in_die ROOT_DISTRUST_DROPPED "root $in_m drops a distrust or retire line." in_count wardensec-root-v1 root "$in_w/root-$in_n.txt" "root/$in_m.txt" "$in_r" "$in_r" - "" "$in_w/root-$in_n.txt" in_need "$in_w/root-$in_n.txt" root ROOT_UNSIGNED_BY_PREVIOUS "root $in_m lacks the previous root's signatures." in_count wardensec-root-v1 root "$in_r" "root/$in_m.txt" "$in_r" "$in_r" - "" "$in_w/root-$in_n.txt" in_need "$in_r" root ROOT_UNSIGNED_BY_ITSELF "root $in_m lacks its own signatures." in_n=$in_m done } # The newest stable release that is neither revoked nor withdrawn, at or above `minimum` and, unless --now, 72 hours # old by its index date. Prints ` `, or `none `, or `held` while the index is # inside the root's publication delay (B-4: DELAY hours after ISSUED, which --now does not shorten). in_pick() { LC_ALL=C awk -v now="$(date -u +%s)" -v age="$1" -v min="$2" -v iss="$3" -v dl="$4" ' function ep(s, y, m) { y = substr(s, 1, 4) + 0; m = substr(s, 6, 2) + 0 if (m <= 2) { y--; m += 12 } return (365 * y + int(y / 4) - int(y / 100) + int(y / 400) + int((153 * (m - 3) + 2) / 5) + substr(s, 9, 2) - 719469) * 86400 + substr(s, 12, 2) * 3600 + substr(s, 15, 2) * 60 + substr(s, 18, 2) } $1 == "revoke" { gone[$2] = 1 } $1 == "withdraw" { withdrawn[$2] = 1 } $1 == "release" && NF == 6 { line[++n] = $0 } END { if (ep(iss) + dl * 3600 > now + 0) { print "held"; exit } for (i = n; i > 0; i--) { split(line[i], f, " ") if (f[6] != "stable" || (f[2] in gone) || (f[3] in withdrawn) || f[3] + 0 < min + 0) continue if (age && ep(f[5]) + 259200 > now + 0) { young++; continue } print f[2], f[3], f[4], young + 0 exit } print "none", young + 0 }' "$in_w/index.txt" } # Writes FILE into the data directory as NAME, through tmp/ and one rename. in_put() { cp -p "$1" "$in_data/tmp/f" && mv -f "$in_data/tmp/f" "$in_data/$2"; } main() { in_age=1 for in_a in "$@"; do case $in_a in --now) in_age= ;; *) in_die USAGE "the only option is --now." ;; esac done [ "$(id -u)" != 0 ] || in_die ROOT_USER "run this as yourself, not as root." in_data=${XDG_DATA_HOME:-$HOME/.local/share}/warden [ ! -L "$in_data" ] || in_die DATA_SYMLINK "the data directory $in_data is a symlink." umask 077 sshsig_require_selftest || exit 1 in_selftest_soft || in_die SSHSIG_SELFTEST "this machine's OpenSSH cannot verify software-key signatures the way Warden needs." in_w=$(mktemp -d) && [ -d "$in_w" ] || in_die TMP "no private temporary directory." trap 'rm -rf "$in_w"' EXIT trap 'exit 1' HUP INT TERM in_root1 >"$in_w/root-1.txt" case $(in_root_check "$in_w/root-1.txt" 1) in ok) ;; test) in_say "Warden is in early access and releases are rolling out. This installer found no release to verify, so it stopped before changing anything." \ "Read the install guide: https://wardensec.ai/docs/install" "Nothing was installed." >&2 exit 1 ;; *) in_bad ROOT_BUILT_IN "built-in root" ;; esac in_i=$in_w/index.txt in_get index.txt index.txt || in_die INDEX_FETCH "the release index could not be fetched." in_record "$in_i" && [ "$(head -n 1 "$in_i")" = "wardensec-index 1" ] || in_bad INDEX_GRAMMAR "release index" in_serial=$(in_val "$in_i" serial) || in_serial= in_rootn=$(in_val "$in_i" root) || in_rootn= in_exp=$(in_val "$in_i" expires) || in_exp= in_inst=$(in_val "$in_i" installer) || in_inst= in_min=$(in_val "$in_i" minimum) || in_min= in_iss=$(in_val "$in_i" issued) || in_iss= in_int "$in_serial" && in_int "$in_rootn" && [ "$in_rootn" -le 999 ] && in_hex "$in_inst" && in_iso "$in_exp" && in_iso "$in_iss" && { [ "$in_min" = 0 ] || in_int "$in_min"; } || in_bad INDEX_GRAMMAR "release index" in_chain "$in_rootn" in_top=$in_w/root-$in_rootn.txt in_st=$in_data/trust/state && [ -f "$in_st" ] || in_st=/dev/null in_count wardensec-index-v1 release "$in_top" index.txt "$in_i" "$in_top" "serial:$in_serial" "$in_st" in_need "$in_top" release INDEX_UNSIGNED "the release index lacks its signatures." mv "$in_w/got" "$in_w/index-got" # The seen state (B4.4), the counter of a key that has none: a key seen on this serial or a later one counts only # on the bytes it signed then. Other keys' well-formed lines are carried. LC_ALL=C awk -v s="$in_serial" -v h="$(in_sha "$in_i")" 'NR == FNR { k[$1] = 1; next } $1 == "seen" && NF == 4 && $2 ~ /^wdr-[0-9a-f]+$/ && length($2) == 20 && $3 ~ /^[1-9][0-9]*$/ && length($3) < 10 && $4 ~ /^[0-9a-f]+$/ && length($4) == 64 && !d[$2]++ { if (!($2 in k)) print; else if ($3 + 0 >= s + 0 && $4 != h) exit 3 } END { for (x in k) print "seen", x, s, h }' "$in_w/index-got" "$in_st" >"$in_w/seen" || in_die SIGNATURE_SERIAL_REGRESSED "a release key signed other bytes at an index serial it has already signed." in_now=$(date -u +%Y-%m-%dT%H:%M:%SZ) awk -v a="$in_exp" -v b="$in_now" 'BEGIN { exit !(a > b) }' || in_die INDEX_EXPIRED "the release index has expired." case $0 in sh | */sh | dash | */dash | bash | */bash | -*) in_file= ;; *) in_file=$0 ;; esac if [ -n "$in_file" ] && [ -f "$in_file" ]; then [ "$(in_sha "$in_file")" = "$in_inst" ] || in_die INSTALLER_MISMATCH "this script is not the installer the index names." else in_say "This script was piped to sh, so it could not check its own hash against the index." fi in_dl=$(in_val "$in_top" delay) || in_dl=0 in_pk=$(in_pick "$in_age" "$in_min" "$in_iss" "$in_dl") [ "$in_pk" != held ] || in_die INDEX_NOT_YET_ADOPTABLE "the release index was issued less than $in_dl hours ago; its root holds every release that long." # shellcheck disable=SC2086 set -f && set -- $in_pk && set +f [ "$#" -eq 4 ] || in_die NO_RELEASE "no stable release is old enough to install yet." in_rel=$1 in_seq=$2 in_rsha=$3 case $in_rel in v[0-9]*.[0-9]*.[0-9]*) ;; *) in_bad INDEX_GRAMMAR "release index" ;; esac case $in_rel in *[!0-9a-z.-]*) in_bad INDEX_GRAMMAR "release index" ;; esac in_int "$in_seq" && in_hex "$in_rsha" || in_bad INDEX_GRAMMAR "release index" [ "$4" = 0 ] || in_say "Skipped $4 newer stable release(s) signed less than 72 hours ago." [ -n "$in_age" ] || in_say "--now: the 72-hour age floor is off. A release signed in the last three days" \ "has had no time to be noticed if it is bad." in_f=$in_w/release.txt in_get "$in_rel/release.txt" release.txt || in_die RELEASE_FETCH "the release record could not be fetched." [ "$(in_sha "$in_f")" = "$in_rsha" ] || in_die RELEASE_HASH "the release record does not match the index." in_record "$in_f" && [ "$(head -n 1 "$in_f")" = "wardensec-release 2" ] && [ "$(in_val "$in_f" release)" = "$in_rel" ] && [ "$(in_val "$in_f" seq)" = "$in_seq" ] && [ "$(in_val "$in_f" channel)" = stable ] || in_bad RELEASE_GRAMMAR "release record" in_rr=$(in_val "$in_f" root) || in_rr= in_int "$in_rr" && [ "$in_rr" -le "$in_rootn" ] || in_bad RELEASE_GRAMMAR "release record" in_count wardensec-release-v1 release "$in_w/root-$in_rr.txt" "$in_rel/release.txt" "$in_f" "$in_top" "seq:$in_seq" in_need "$in_w/root-$in_rr.txt" release RELEASE_UNSIGNED "the release record lacks its signatures." in_fl=$(awk '$1 == "file" && $2 == "warden" { n++; h = $3; s = $4; f = NF } END { if (n == 1 && f == 4) print h, s }' "$in_f") in_fsha=${in_fl%% *} && in_fsha=${in_fsha#sha256:} && in_fsize=${in_fl#* } case $in_fl in sha256:*) ;; *) in_fsha= ;; esac in_hex "$in_fsha" && in_int "$in_fsize" || in_bad RELEASE_GRAMMAR "release record" in_get "sha256/$in_fsha" warden || in_die FILE_FETCH "the warden file could not be fetched." [ "$(wc -c <"$in_w/warden" | tr -d ' ')" = "$in_fsize" ] && [ "$(in_sha "$in_w/warden")" = "$in_fsha" ] || in_die FILE_HASH "the warden file does not match the signed release." # Install: every file is written under tmp/ and renamed into place. { printf 'root-serial %s\nindex-serial %s\nindex-sha256 %s\n' "$in_rootn" "$in_serial" "$(in_sha "$in_i")" printf 'seen-seq %s\n' "$(awk '$1 == "release" && $3 + 0 > m { m = $3 + 0 } END { print m + 0 }' "$in_i")" awk '$2 != "-" { print "counter", $1, $2 }' "$in_w/index-got" cat "$in_w/seen" printf 'first-seen %s %s\n' "$in_serial" "$in_now" } >"$in_w/state" chmod 0755 "$in_w/warden" mkdir -p "$in_data/bin" "$in_data/trust" "$in_data/tmp" in_n=0 while [ "$in_n" -lt "$in_rootn" ]; do in_n=$((in_n + 1)) && in_put "$in_w/root-$in_n.txt" "trust/root-$in_n.txt" done in_put "$in_i" trust/index.txt && in_put "$in_w/state" trust/state && in_put "$in_w/warden" bin/warden rmdir "$in_data/tmp" 2>/dev/null || true in_bin=$in_data/bin/warden in_lb=$HOME/.local/bin in_link= case ":$PATH:" in *":$in_lb:"*) if [ -d "$in_lb" ] && { [ -L "$in_lb/warden" ] || [ ! -e "$in_lb/warden" ]; }; then rm -f "$in_lb/warden" && ln -s "$in_bin" "$in_lb/warden" && in_link=$in_lb/warden fi ;; esac in_say "" "Installed Warden $in_rel at $in_bin" "" # The class of every key behind this release (B4.2): posture, printed, never a refusal. awk -v t="$(in_thr "$in_w/root-$in_rr.txt" release)" '{ printf "release signed by %s, %s key%s, %d of %s needed\n", $1, $4, ($3 == "-" ? "" : ", custody " $3), NR, t }' "$in_w/got" awk -v n="$in_rootn" 'function p(x) { return c[x] ? c[x] " " x (c[x] > 1 ? "s" : "") : "" } $1 == "key" && $3 == "root" { c[$4 == "ssh-ed25519" ? "software key" : "security key"]++ } $1 == "threshold" && $2 == "root" { t = $3 } $1 == "distinct-custody" { d = " from distinct custodies" } $1 == "require" { q = 1 } END { a = p("software key"); b = p("security key"); printf "root %s: %s%s%s, %s needed%s; this root %s security keys\n", n, a, (a != "" && b != "" ? " and " : ""), b, t, d, (q ? "requires" : "does not require") }' "$in_top" in_say "Root keys: $(awk '$1 == "key" && $3 == "root" { printf "%s ", $2 }' "$in_top")" for in_n in $(awk -v n="$in_rootn" 'BEGIN { for (i = 1; i <= n; i++) print i }'); do in_say " sha256 $(in_sha "$in_data/trust/root-$in_n.txt") trust/root-$in_n.txt" done in_say "Compare these with trust/roots.txt in the source repository or your license e-mail:" \ " shasum -a 256 $in_data/trust/root-*.txt" "" [ -n "$in_link" ] || in_say "To run it by name, add this line to your shell's start-up file:" \ " export PATH=\"$in_data/bin:\$PATH\"" in_cv=$(command -v warden 2>/dev/null || true) if [ -n "$in_cv" ] && [ "$in_cv" != "$in_bin" ] && [ "$in_cv" != "$in_link" ]; then in_say "Warning: another warden comes first on your PATH and shadows this one: $in_cv" fi in_say "Next: $in_bin setup" if [ -t 1 ] && [ -r /dev/tty ] && [ -z "${WARDEN_NO_START-}" ]; then rm -rf "$in_w" && trap - EXIT exec "$in_bin" setup