From 8a75baf58f58dbf74009c4b1c0733ec3a9033479 Mon Sep 17 00:00:00 2001 From: Aswin Date: Fri, 28 Aug 2026 07:21:15 +0000 Subject: [PATCH 1/2] 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." From 73e393a9479ee1cbbe01815e5680c1b58c81b013 Mon Sep 17 00:00:00 2001 From: Aswin Date: Fri, 28 Aug 2026 07:24:02 +0000 Subject: [PATCH 2/2] Run CI once per PR commit, not twice 'push:' with no branch filter fired alongside 'pull_request:', so every pull request commit ran the whole suite twice -- two identical Checks and two identical E2E runs, visible on PR #1. Restricting the push trigger to main leaves one run per PR commit plus one on main after a merge, which is what verifies the squashed result rather than the branch head. Tradeoff: a branch pushed with no PR open now gets no CI. That is the right way round, because main is protected by required status checks and nothing reaches it without a PR. --- .github/workflows/ci.yml | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1bb9620..e9c8945 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,8 +1,18 @@ name: CI -# Every push and every pull request. Everything here is offline — no secrets, no -# Cloudflare token, no network beyond the npm registry — so there is nothing to -# gate it on. +# Pull requests, and pushes to main only. +# +# `push:` with no branch filter ran the whole suite twice on every pull request +# commit — once for the push to the branch, once for the pull_request event — so +# each PR showed two identical Checks and two identical E2E runs. Restricting the +# push trigger to main leaves one run per PR commit, plus one on main after a +# merge to verify the squashed result. +# +# The tradeoff: a branch pushed with no PR open gets no CI. That is the right way +# round here, because main is protected and nothing reaches it without a PR. +# +# Everything is offline — no secrets, no Cloudflare token, no network beyond the +# npm registry — so there is nothing to gate it on. # # This exists because validate.sh previously ran only at deploy time, which is # after merge. A malformed keys.txt would land on main and be discovered from a @@ -10,6 +20,7 @@ name: CI # but main was, and the whole point of the file is that it can be trusted. on: push: + branches: [main] pull_request: # Read-only. This workflow reports; it must never be able to change anything.