Skip to content
Open
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
f3d5265
feat: publish the hash list signed, alongside the unsigned array
MorningLightMountain713 Aug 14, 2026
fad433e
test: shape-check the published list, and the signed copy against it
MorningLightMountain713 Aug 14, 2026
ed94395
fix: scope the publish commit to the document it signed
MorningLightMountain713 Aug 14, 2026
8145203
fix: do not cache the not-yet-published response
MorningLightMountain713 Aug 14, 2026
be48408
style: satisfy the repository's eslint config
MorningLightMountain713 Aug 14, 2026
9033d0f
feat: the sequence reads its high-water from the provenance record
MorningLightMountain713 Aug 17, 2026
0025137
fix: validate refuses a sequence that is not the recorded high-water
MorningLightMountain713 Aug 17, 2026
ea0fdd2
fix(ci): retry the publish when it races a push from flux CI
MorningLightMountain713 Aug 17, 2026
20a6cfb
feat: the signer derives what it signs, as the single writer
MorningLightMountain713 Aug 24, 2026
e677ce8
polish from independent review: keep both labels on a dual-ref commit…
MorningLightMountain713 Aug 24, 2026
2882267
fix: anchor the outputs guard on the merge commit's first parent
MorningLightMountain713 Aug 24, 2026
9fda0cd
chore: pin the regenerated key 1
MorningLightMountain713 Aug 24, 2026
d823841
fix: refuse the empty-tree hash instead of signing it
MorningLightMountain713 Aug 25, 2026
636fca1
docs: state what a dispatch is actually trusted to name
MorningLightMountain713 Aug 25, 2026
f2df129
chore(ci): move the actions to the current majors
MorningLightMountain713 Aug 25, 2026
1892bdf
test: land the reconciler harness and run it in CI
MorningLightMountain713 Aug 25, 2026
13732fe
docs: say what the signature attests, and log the host-key rotation t…
MorningLightMountain713 Aug 25, 2026
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
124 changes: 124 additions & 0 deletions .github/workflows/sign-hashlist.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
name: sign-hashlist

# The single writer of everything src/hashes/ serves. It derives what it signs: a run fetches new
# flux commits from the official repository, hashes their trees itself, and publishes list, signed
# document and provenance in one commit. Requests carry pointers, never content -- flux CI's
# dispatch token has Actions permission only.
#
# Level-triggered: every run reconciles the full delta between the flux remote's refs and the
# snapshot in the provenance record, so a dispatch lost to the concurrency group's
# newest-pending-wins cancellation is repaired by whichever run survives, and the daily sweep
# bounds the tail when nothing follows.
#
# Deliberately NOT triggered by pull_request_target or pull_request: this repository is public and
# either would expose the secrets to a fork. The push trigger watches only the human-edited ledger,
# which this workflow never writes, so it cannot retrigger itself. Both secrets live in the
# environment below, whose deployment branch policy admits master only -- a branch run is refused
# before its first step.

on:
workflow_dispatch:
inputs:
commit:
description: 'flux commit SHA to publish'
required: false
ref:
description: 'ref name the caller saw (label of last resort, never authority)'
required: false
ref_type:
description: 'branch or tag (label of last resort)'
required: false
claimed_hash:
description: 'tree hash the caller computed -- a tripwire, never an input to the list'
required: false
push:
branches: [master]
paths: ['src/hashes/ledger.json']
schedule:
- cron: '43 3 * * *'

# The push happens over SSH with the deploy key -- the ruleset's one bypass -- so the run token
# needs read only.
permissions:
contents: read

# Two runs signing at once would both read the same sequence, and one would publish over the other
# under a sequence already used.
concurrency:
group: sign-hashlist
cancel-in-progress: false

jobs:
sign:
runs-on: ubuntu-latest
environment: hashlist-signing
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'

# Reconcile, sign, verify, publish -- as one step, because the push can race a human PR
# merge, and recovering means re-syncing to origin/master and reconciling again from scratch.
#
# The verification runs against the published public keys, not the signing key: a mangled
# secret produces a well-formed document that no consumer will accept, and verifying here
# makes that a red run rather than a document that looks published and satisfies nobody.
- name: Reconcile, sign and publish
env:
HASHLIST_SIGNING_SEED_B64: ${{ secrets.HASHLIST_SIGNING_SEED_B64 }}
HASHLIST_DEPLOY_KEY: ${{ secrets.HASHLIST_DEPLOY_KEY }}
DISPATCH_COMMIT: ${{ inputs.commit }}
DISPATCH_REF: ${{ inputs.ref }}
DISPATCH_REF_TYPE: ${{ inputs.ref_type }}
DISPATCH_CLAIMED_HASH: ${{ inputs.claimed_hash }}
run: |
umask 077
printf '%s\n' "$HASHLIST_DEPLOY_KEY" > "$RUNNER_TEMP/deploy_key"
printf 'github.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl\n' > "$RUNNER_TEMP/known_hosts"
export GIT_SSH_COMMAND="ssh -F /dev/null -i $RUNNER_TEMP/deploy_key -o IdentitiesOnly=yes -o UserKnownHostsFile=$RUNNER_TEMP/known_hosts"

git config user.email 'runonfluxbot@gmail.com'
git config user.name 'hashlist-signer'

LIST=src/hashes/hashes.js
SIGNED=src/hashes/hashlist-signed.json
PROVENANCE=src/hashes/provenance.json

for attempt in 1 2 3; do
git fetch --quiet origin master
git reset --quiet --hard origin/master

RESULT=$(node scripts/sign-hashlist.js)
case "$RESULT" in
changed=false)
echo 'nothing to publish'
exit 0
;;
changed=state)
node scripts/validate.js
git add "$PROVENANCE"
git commit --quiet -m 'Reconcile flux refs' -- "$PROVENANCE"
;;
changed=signed)
node scripts/verify-hashlist.js
node scripts/validate.js
git add "$LIST" "$SIGNED" "$PROVENANCE"
SEQ=$(node -p "JSON.parse(Buffer.from(require('./$SIGNED').payload_b64,'base64')).seq")
git commit --quiet -m "Sign hash list seq $SEQ" -- "$LIST" "$SIGNED" "$PROVENANCE"
;;
*)
echo "unexpected reconciler output: $RESULT"
exit 1
;;
esac

if git push --quiet "git@github.com:${GITHUB_REPOSITORY}.git" HEAD:master; then
echo "published ($RESULT)"
exit 0
fi
echo 'push raced a human merge, retrying'
done

echo 'could not publish after 3 attempts'
exit 1
49 changes: 49 additions & 0 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
name: validate

# The published list is served by requiring it, so a file that does not load takes the endpoint
# down. The signer runs validate.js itself before pushing; this run covers human PRs and stands as
# the live tripwire on master -- the signer's deploy-key pushes trigger it, so every signing commit
# gets a green check and a red one always means something real.

on:
pull_request:
push:
branches: [master]

permissions:
contents: read

jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
# The outputs guard below diffs the merge commit against its first parent.
fetch-depth: 2
- uses: actions/setup-node@v4
with:
node-version: '20'

# The three outputs are generated by the signer; edit the ledger instead. This catches
# honest mistakes, not attacks: pull_request runs the PR head's copy of this workflow, so a
# hostile PR could weaken the check it is judged by. The control against a hostile PR is
# required review on the ruleset -- this step just makes the mistake loud before a human
# looks.
#
# HEAD is the PR merge commit and HEAD^1 is the base branch as it stands NOW, so this sees
# exactly what merging the PR would change. (The event payload's base.sha is the base at the
# last synchronize -- stale on any PR whose base has since moved, and the signer moves master
# daily, so diffing against it flags every hash published since the PR was opened.)
- name: Outputs are generated, not edited
if: github.event_name == 'pull_request'
run: |
CHANGED=$(git diff --name-only HEAD^1 HEAD -- \
src/hashes/hashes.js src/hashes/hashlist-signed.json src/hashes/provenance.json)
if [ -n "$CHANGED" ]; then
echo 'these files are generated by the signing workflow; edit src/hashes/ledger.json instead:'
echo "$CHANGED"
exit 1
fi

- run: node scripts/validate.js
105 changes: 105 additions & 0 deletions SIGNING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# Signing the hash list

`src/hashes/hashlist-signed.json` is the list this repository publishes, signed with Ed25519 so consumers can verify
it came from us rather than trusting the transport or whatever relayed it.

It is published **alongside** `src/hashes/hashes.js`, not instead of it. Both are served.

## The document

`{ payload_b64, sig_b64 }`, where the payload is the exact signed bytes — a JSON object
`{ seq, issued_at, hashes }`. The signature covers the transmitted bytes, so verification never
depends on signer and verifier agreeing about JSON key order or whitespace.

`seq` increases by one per signing run and never restarts. Its high-water mark is recorded in the
provenance record beside the document, and the signer takes the next sequence from whichever of the
two is higher — so losing the document, however that happens, does not reset the sequence.
`validate.js` refuses a document whose sequence is not exactly the recorded high-water.

## The provenance record

`src/hashes/provenance.json`, outside the signed payload, with **one writer — the signing
workflow**, which derives every post-cutover entry itself from commits it fetches from
`RunOnFlux/flux`:

- a **row per listed hash** — `published` date, `commit`, `branch`, `tag`, `derived`. This is what
makes an entry attributable later: the list itself is opaque md5s, and the commit that produced
an entry can stop existing (a force-push, a branch deleted after merge). First attribution wins;
a new tag on a known commit annotates the existing row. Rows with `derived: false` predate
derivation (grandfathered at cutover) and have no commit to point at.
- a **commits map** (`sha → hash`) so nothing is fetched or hashed twice, and a **refs snapshot**
of the flux remote, which is what the reconciler diffs against — a publication request that gets
lost is repaired by the next run reconciling the full delta.
- **`signed`** — the sequence high-water and `issued_at`, stamped in the same commit as the signed
document.

`src/hashes/ledger.json` is the one human-edited input: cull marks, reviewed through PRs. The
signer reads it and never writes it. The three output files are generated — `validate` fails any
PR that edits them directly.

## Keys

Consumers pin a set of public keys and accept a document signed by any one of them, so a second key
can take over without those consumers needing an update.

| key | public key (raw ed25519, hex) | custody | use |
|---|---|---|---|
| 1 | `14837066068b258bfbd0749702056f7065361af44aed48761834744391cbbaaa` | CI, secret `HASHLIST_SIGNING_SEED_B64` in the `hashlist-signing` environment | day to day |
| 2 | `fee7b0ccf2323954af68a249eaa61f957239eb222329e08a5b6a50ced649bae8` | cold, offline | continuity only |

### Key 1

Generated 2026-08-24 straight into the secret `HASHLIST_SIGNING_SEED_B64`, which lives in the
`hashlist-signing` GitHub Environment whose deployment branch policy admits `master` only — a
workflow run on any other ref is refused before its first step, so a branch push cannot read it.
(This replaced the 2026-08-14 key, which had only ever lived as a repository-level secret: a
secret's value cannot be moved into an environment, and nothing had ever consumed the old key, so
regenerating was free.)
**There is no copy of the private half anywhere else, on purpose** — key 2 covers its loss, and a
second copy would only widen where it can leak from.

To replace it, generate a new one the same way:

```sh
node -e '
const crypto = require("crypto");
const seed = crypto.randomBytes(32);
const key = crypto.createPrivateKey({
key: Buffer.concat([Buffer.from("302e020100300506032b657004220420","hex"), seed]),
format: "der", type: "pkcs8",
});
process.stderr.write("public_key_hex=" + crypto.createPublicKey(key)
.export({format:"der", type:"spki"}).subarray(12).toString("hex") + "\n");
process.stdout.write(seed.toString("base64"));
' | gh secret set HASHLIST_SIGNING_SEED_B64 --repo RunOnFlux/fluxhashes
```

The seed goes down the pipe and is never printed or written to disk. Put the printed public key in
the table above and in `scripts/verify-hashlist.js`.

### Key 2

Generated offline, private half never on a networked machine, stored with the release signing
material. Not used in normal operation.

Its purpose is continuity: without a second key, losing key 1 would mean nothing new could be
published until consumers were updated with a replacement.

It does not provide revocation — removing a key from the pinned set requires updating consumers.
Two keys held in the same place buy nothing; the separation is the point.

### The deploy key

The signer pushes to `master` over SSH with a write deploy key, `HASHLIST_DEPLOY_KEY` in the same
environment — the one bypass on the master ruleset, which otherwise admits only reviewed PRs with
`validate` green (repository admins included). The run's own `GITHUB_TOKEN` stays read-only. To
rotate: generate a fresh keypair, replace the repository deploy key and the environment secret;
the ruleset's `DeployKey` bypass covers whatever write keys the repository holds, so it needs no
change — which is also why the repository must hold exactly this one write deploy key.

## Trust

Anyone who can land a workflow change on `master` can read the secrets; a GitHub secret is an
access-controlled environment variable, not a vault. Environment scoping means landing that change
requires a reviewed merge — a branch push is no longer enough. Secrets are not passed to workflows
triggered by a pull request from a fork, which matters because this repository is public.
Loading
Loading