Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,15 +1,26 @@
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
# failed build log. Production was never at risk (a failed build does not deploy)
# 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.
Expand Down
29 changes: 26 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions fingerprints.txt
Original file line number Diff line number Diff line change
@@ -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 [email protected]
SHA256:J0Xs4eAxSVzI/XhTmESrauM48xEMtFmN78GxEc0CkV4 aswin@Aswin-Macbook-Pro
SHA256:ubg9S7gxhwKqTvmgRxS/SRIvfEo1ZayOa+FqfVl+qJ8 mail@ubuntu
SHA256:gWAoI59eLhaNK6FGvQDobXNmxPZUCkf+HUNh329gZAg aswin@truenas-host
27 changes: 27 additions & 0 deletions scripts/fingerprints.sh
Original file line number Diff line number Diff line change
@@ -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/^/ /'
29 changes: 29 additions & 0 deletions scripts/validate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down