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. 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."