From 8a75baf58f58dbf74009c4b1c0733ec3a9033479 Mon Sep 17 00:00:00 2001 From: Aswin Date: Fri, 28 Aug 2026 07:21:15 +0000 Subject: [PATCH] Pin key fingerprints so a corrupted key cannot deploy ssh-keygen -l validates structure, not authenticity. Flipping one bit in a key's material leaves a line that still parses, still reports 256-bit ED25519, and still passes every check we had -- only the fingerprint moves. Demonstrated: both the original and the tampered line exit 0 under ssh-keygen -lf. A key corrupted that way would have deployed, reached a new machine's authorized_keys and silently not worked, because no private key matches it. You would believe you had four ways into that box and have three. fingerprints.txt now pins the SHA256 of every key and validate.sh refuses to build while it and keys.txt disagree. Regenerate with scripts/fingerprints.sh and commit both together. The pin also buys reviewability, which may matter more: a reviewer skims past a 68-character base64 blob, but a changed SHA256 line is legible. Key material cannot change without the change appearing in a readable form in the same diff. Verified against the bit-flip, an added key, a removed key and a missing fingerprints.txt -- all blocked -- and that regenerating unblocks. An earlier attempt at the last case failed because the key I appended reused an existing blob and tripped the duplicate guard instead; the test was wrong, not the code. --- README.md | 29 ++++++++++++++++++++++++++--- fingerprints.txt | 15 +++++++++++++++ scripts/fingerprints.sh | 27 +++++++++++++++++++++++++++ scripts/validate.sh | 29 +++++++++++++++++++++++++++++ 4 files changed, 97 insertions(+), 3 deletions(-) create mode 100644 fingerprints.txt create mode 100755 scripts/fingerprints.sh diff --git a/README.md b/README.md index b324def..bda4aef 100644 --- a/README.md +++ b/README.md @@ -42,9 +42,13 @@ images where `/bin/sh` is dash. Edit `keys.txt` and deploy. That is the entire workflow — nothing else references the list. - vi keys.txt # add: ssh-ed25519 AAAA... aswin@NewLaptop + vi keys.txt # add: ssh-ed25519 AAAA... aswin@NewLaptop + sh scripts/fingerprints.sh # regenerate fingerprints.txt npm run deploy - npm run check # verifies production + npm run check # verifies production + +Commit `keys.txt` and `fingerprints.txt` together — `validate.sh` refuses to build +while they disagree. Or edit `keys.txt` on GitHub and let Workers Builds deploy it — useful precisely because this endpoint exists to set up *other* machines, and you may not be at the @@ -75,6 +79,24 @@ the SSH wire format inside it holds just the algorithm name and the raw public key, with no field for a comment. Renaming one changes nothing about who can log in; the fingerprint is identical. +### Pinned fingerprints + +`fingerprints.txt` lists the SHA256 of every key in `keys.txt`, and `validate.sh` +refuses to build if the two disagree. + +This exists because `ssh-keygen -l` validates *structure*, not authenticity. Flip a +single bit in a key's material and the line still parses, still reports +`256 ... (ED25519)`, and still passes every other check — only the fingerprint +moves. A key corrupted that way would deploy, land in a new machine's +`authorized_keys`, and silently not work, because no private key matches it. You +would believe you had four ways into that box and have three. + +The pin also makes key changes reviewable. A reviewer skims past a 68-character +base64 blob; a changed `SHA256:` line is legible. Any change to key material has to +appear in `fingerprints.txt` in the same commit or the build fails. + + sh scripts/fingerprints.sh # after any change to keys.txt + ### A bad key cannot be deployed `scripts/validate.sh` runs as wrangler's `build.command`, so it fires on @@ -151,7 +173,8 @@ offline — no secrets, no Cloudflare token — so there is nothing to gate it o **Checks** -- `validate.sh` — the same script the deploy runs, but now *before* merge +- `validate.sh` — the same script the deploy runs, but now *before* merge, + including the pinned-fingerprint comparison - `check-readme.sh` — the README table must match `keys.txt` - `tsc --noEmit` - `wrangler deploy --dry-run` — config and bundling, without deploying diff --git a/fingerprints.txt b/fingerprints.txt new file mode 100644 index 0000000..158ad38 --- /dev/null +++ b/fingerprints.txt @@ -0,0 +1,15 @@ +# Fingerprints of every key in keys.txt. Generated -- do not hand-edit. +# Regenerate with: sh scripts/fingerprints.sh +# +# These exist so a corrupted key cannot deploy. ssh-keygen -l validates +# structure, not authenticity: flip one bit in the key material and it still +# parses, still reports 256-bit ED25519, and still passes -- only the +# fingerprint changes. Pinning them turns 'these look like keys' into 'these +# are exactly the keys we intend'. + +SHA256:dxiyBcPf+pkSLO/WNrEYnGEKkXpu67jKaS47Ky8ECFs aswin@AswinPC +SHA256:SvP/8B22PR2Q4rCGcQmvF8yUsSGWBcIKAuCD+P6oYDg aswin@Aswin-Laptop +SHA256:cCz8aDx6zovq4vWWcEUEmRzkdf7LLgNbzM3BKUqpeJ4 aswin@Aswins-MacBook-Air.local +SHA256:J0Xs4eAxSVzI/XhTmESrauM48xEMtFmN78GxEc0CkV4 aswin@Aswin-Macbook-Pro +SHA256:ubg9S7gxhwKqTvmgRxS/SRIvfEo1ZayOa+FqfVl+qJ8 mail@ubuntu +SHA256:gWAoI59eLhaNK6FGvQDobXNmxPZUCkf+HUNh329gZAg aswin@truenas-host diff --git a/scripts/fingerprints.sh b/scripts/fingerprints.sh new file mode 100755 index 0000000..512917c --- /dev/null +++ b/scripts/fingerprints.sh @@ -0,0 +1,27 @@ +#!/bin/sh +# Regenerates fingerprints.txt from keys.txt. +# +# sh scripts/fingerprints.sh +# +# Run this after changing keys.txt and commit both files together. validate.sh +# refuses to build while the two disagree, so the fingerprint of any key that +# changed lands in the same diff as the change -- which is the point. A reviewer +# skims past a 68-character base64 blob; a changed SHA256 line is legible. +set -eu +cd "$(dirname "$0")/.." + +{ + echo "# Fingerprints of every key in keys.txt. Generated -- do not hand-edit." + echo "# Regenerate with: sh scripts/fingerprints.sh" + echo "#" + echo "# These exist so a corrupted key cannot deploy. ssh-keygen -l validates" + echo "# structure, not authenticity: flip one bit in the key material and it still" + echo "# parses, still reports 256-bit ED25519, and still passes -- only the" + echo "# fingerprint changes. Pinning them turns 'these look like keys' into 'these" + echo "# are exactly the keys we intend'." + echo + ssh-keygen -lf keys.txt | awk '{print $2" "$3}' +} > fingerprints.txt + +echo "fingerprints.sh: wrote $(grep -v '^#' fingerprints.txt | grep -c .) fingerprints" +grep -v '^#' fingerprints.txt | grep . | sed 's/^/ /' diff --git a/scripts/validate.sh b/scripts/validate.sh index 4255e65..c417cb8 100755 --- a/scripts/validate.sh +++ b/scripts/validate.sh @@ -73,6 +73,35 @@ if [ -n "$dupes" ]; then fail=1 fi +# Pinned fingerprints. ssh-keygen -l above proved every line is STRUCTURALLY a +# key; it cannot prove it is the RIGHT key. Flip one bit in the key material and +# the line still parses, still reports 256-bit ED25519 and still passes — only the +# fingerprint moves. A key corrupted that way would deploy, reach a new machine's +# authorized_keys, and silently not work, because no private key matches it. +# +# Comparing against a checked-in list closes that, and also puts any change to key +# material into the diff in a form a reviewer can actually read. +FP=fingerprints.txt +if [ ! -f "$FP" ]; then + echo "validate: $FP is missing — run: sh scripts/fingerprints.sh" + fail=1 +else + want=$(grep -v '^#' "$FP" | grep . | awk '{print $1" "$2}' | sort) + got=$(ssh-keygen -lf "$F" 2>/dev/null | awk '{print $2" "$3}' | sort) + if [ "$want" != "$got" ]; then + echo "validate: keys.txt and $FP disagree." + echo + echo " pinned but not in keys.txt:" + printf '%s\n' "$want" | grep -vxF "$(printf '%s' "$got")" 2>/dev/null | grep . | sed 's/^/ /' || echo " (none)" + echo " in keys.txt but not pinned:" + printf '%s\n' "$got" | grep -vxF "$(printf '%s' "$want")" 2>/dev/null | grep . | sed 's/^/ /' || echo " (none)" + echo + echo " If the key change was intended: sh scripts/fingerprints.sh" + echo " and commit fingerprints.txt alongside keys.txt." + fail=1 + fi +fi + if [ "$fail" != 0 ]; then echo echo "validate: FAILED — not deploying."