Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
66 changes: 66 additions & 0 deletions .github/workflows/sign-hashlist.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
name: sign-hashlist

# Signs the hash list this repository publishes, so consumers can verify it came from us.
#
# Runs unattended: RunOnFlux/flux CI already pushes each new hash here, and this signs whatever that
# push produced. Nothing to approve, and no cross-repository dispatch or token needed.
#
# Deliberately NOT triggered by pull_request_target or pull_request: this repository is public and
# either would expose the signing key to a fork.

on:
push:
branches: [master]
paths: ['src/hashes/hashes.js']
workflow_dispatch:

permissions:
contents: write

# 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
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'

- name: Sign
id: sign
env:
HASHLIST_SIGNING_SEED_B64: ${{ secrets.HASHLIST_SIGNING_SEED_B64 }}
run: node scripts/sign-hashlist.js >> "$GITHUB_OUTPUT"

# Against the published public keys, not the signing key. A mangled secret produces a
# well-formed document that no consumer will accept; this makes that a red run rather than a
# document that looks published and satisfies nobody.
- name: Verify what was just signed
if: steps.sign.outputs.changed == 'true'
run: node scripts/verify-hashlist.js

# Commits the signed document only. The trigger above watches src/hashes/hashes.js, so this
# cannot retrigger itself.
- name: Publish
if: steps.sign.outputs.changed == 'true'
run: |
git config user.email 'runonfluxbot@gmail.com'
git config user.name 'policy-bot'
# Scoped to the one path, both times. A bare `git commit` would sweep in anything else a
# previous step left staged, and a bare `git diff --cached` would call that a change and
# publish a document that had not moved.
SIGNED=src/hashes/hashlist-signed.json
git add "$SIGNED"
if git diff --cached --quiet -- "$SIGNED"; then
echo "nothing changed, not publishing"
exit 0
fi
SEQ=$(node -p "JSON.parse(Buffer.from(require('./$SIGNED').payload_b64,'base64')).seq")
git commit --quiet -m "Sign hash list seq $SEQ" -- "$SIGNED"
git push
23 changes: 23 additions & 0 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
name: validate

# The published list is served by requiring it, so a file that does not load takes the endpoint down.
# Flux CI checks its own edit before pushing; this covers the other way the list changes, which is by
# hand.

on:
pull_request:
push:
branches: [master]

permissions:
contents: read

jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: node scripts/validate.js
58 changes: 58 additions & 0 deletions SIGNING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# 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.

## 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 | `3023cb5e01dc22257ac5c31c4d12106cd0d58fa2005f867b3fdc5d303f6446ec` | CI, repository secret `HASHLIST_SIGNING_SEED_B64` | day to day |
| 2 | `fee7b0ccf2323954af68a249eaa61f957239eb222329e08a5b6a50ced649bae8` | cold, offline | continuity only |

### Key 1

Generated 2026-08-14 straight into the repository secret `HASHLIST_SIGNING_SEED_B64`. **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.

## Trust

Anyone who can land a workflow change on `master` can read the secret; a GitHub secret is an
access-controlled environment variable, not a vault. It is not passed to workflows triggered by a
pull request from a fork, which matters because this repository is public.
113 changes: 113 additions & 0 deletions scripts/sign-hashlist.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
#!/usr/bin/env node

// Signs the hash list this repository publishes, so consumers can verify it came from us rather
// than trusting the transport or whatever relayed it.
//
// The signed document sits alongside the unsigned array rather than replacing it; both are served.
//
// The payload is signed and transmitted as exact bytes in base64, so verification never depends on
// the signer and the verifier agreeing about JSON key order or whitespace -- the kind of agreement
// that holds in testing and fails in production.

const crypto = require('crypto');
const fs = require('fs');
const path = require('path');

const ROOT = path.join(__dirname, '..');
const OUTPUT = path.join(ROOT, 'src', 'hashes', 'hashlist-signed.json');

// A raw 32-byte Ed25519 seed is not directly importable; Node wants PKCS8. The prefix is fixed for
// the algorithm, so prepending it is enough.
const PKCS8_ED25519_PREFIX = Buffer.from('302e020100300506032b657004220420', 'hex');
const SPKI_ED25519_PREFIX_LENGTH = 12;

function privateKeyFromSeed(seedB64) {
const seed = Buffer.from(seedB64, 'base64');
if (seed.length !== 32) {
throw new Error(`signing seed must be 32 bytes, got ${seed.length}`);
}
return crypto.createPrivateKey({
key: Buffer.concat([PKCS8_ED25519_PREFIX, seed]),
format: 'der',
type: 'pkcs8',
});
}

// The raw 32 bytes consumers pin, rather than any DER wrapping around them.
function rawPublicKey(privateKey) {
const spki = crypto.createPublicKey(privateKey).export({ format: 'der', type: 'spki' });
return spki.subarray(SPKI_ED25519_PREFIX_LENGTH);
}

function buildSignedDocument(seq, hashes, privateKey) {
if (!Number.isInteger(seq) || seq < 1) {
throw new Error('seq must be a positive integer');
}
if (!Array.isArray(hashes) || hashes.length === 0) {
throw new Error('hashes must be a non-empty array');
}
if (!hashes.every((h) => typeof h === 'string' && /^[0-9a-f]{32}$/.test(h))) {
throw new Error('every hash must be a lowercase 32-character md5');
}

const payload = Buffer.from(JSON.stringify({ seq, hashes }), 'utf8');
const signature = crypto.sign(null, payload, privateKey);

return {
payload_b64: payload.toString('base64'),
sig_b64: signature.toString('base64'),
};
}

// The sequence lives in the published document rather than in a file beside it, so there is nothing
// to drift out of step with what was actually signed. A consumer refuses a document whose sequence
// is below the highest it has accepted, so an older validly-signed list cannot be replayed over a
// newer one.
function previousDocument() {
if (!fs.existsSync(OUTPUT)) {
return null;
}
const document = JSON.parse(fs.readFileSync(OUTPUT, 'utf8'));
return JSON.parse(Buffer.from(document.payload_b64, 'base64').toString('utf8'));
}

function main() {
const seedB64 = process.env.HASHLIST_SIGNING_SEED_B64;
if (!seedB64) {
throw new Error('HASHLIST_SIGNING_SEED_B64 is not set');
}

// eslint-disable-next-line global-require
const hashes = require('../src/hashes/hashes').getHashes();
const previous = previousDocument();

// Re-signing an unchanged list would burn a sequence for nothing, and every node would have to
// fetch and verify a document identical to the one it already holds.
if (previous
&& previous.hashes.length === hashes.length
&& previous.hashes.every((hash, i) => hash === hashes[i])) {
process.stderr.write(`unchanged at seq ${previous.seq}, nothing to sign\n`);
process.stdout.write('changed=false\n');
return;
}

const seq = previous ? previous.seq + 1 : 1;
const privateKey = privateKeyFromSeed(seedB64);
const document = buildSignedDocument(seq, hashes, privateKey);

fs.writeFileSync(OUTPUT, `${JSON.stringify(document, null, 2)}\n`);
process.stderr.write(`signed seq ${seq} over ${hashes.length} hashes\n`);
process.stderr.write(`public key (raw, hex): ${rawPublicKey(privateKey).toString('hex')}\n`);
process.stdout.write('changed=true\n');
}

if (require.main === module) {
try {
main();
} catch (error) {
process.stderr.write(`sign-hashlist: ${error.message}\n`);
process.exit(1);
}
}

module.exports = { privateKeyFromSeed, rawPublicKey, buildSignedDocument };
87 changes: 87 additions & 0 deletions scripts/validate.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
#!/usr/bin/env node

// Shape-checks what this repository publishes.
//
// The list is served by requiring it, so a file that does not load takes the endpoint down rather
// than merely publishing something odd. Flux CI checks its own edit before pushing, but the list is
// also edited by hand -- a cull removes entries in bulk -- and that path had nothing in front of it.
//
// This checks shape only. Whether a particular hash *should* be listed is not knowable from here:
// removing one that is still in use looks identical to removing one that is obsolete.

const fs = require('fs');
const path = require('path');

const ROOT = path.join(__dirname, '..');
const SIGNED = path.join(ROOT, 'src', 'hashes', 'hashlist-signed.json');

const failures = [];

function check(condition, message) {
if (!condition) failures.push(message);
}

function validateHashes() {
// eslint-disable-next-line global-require
const hashes = require('../src/hashes/hashes').getHashes();

check(Array.isArray(hashes), 'hashes.js did not return an array');
if (!Array.isArray(hashes)) return null;

check(hashes.length > 0, 'the list is empty');

const malformed = hashes.filter((hash) => typeof hash !== 'string' || !/^[0-9a-f]{32}$/.test(hash));
check(malformed.length === 0, `${malformed.length} entries are not lowercase md5s: ${malformed.slice(0, 3)}`);

// Harmless to serve, but a sign that an edit went in twice, which is worth seeing.
const duplicates = hashes.filter((hash, i) => hashes.indexOf(hash) !== i);
check(duplicates.length === 0, `${duplicates.length} duplicate entries: ${[...new Set(duplicates)].slice(0, 3)}`);

process.stderr.write(`hashes.js: ${hashes.length} entries\n`);
return hashes;
}

// The signed copy is written by CI and only exists once it has run, so its absence is not a failure.
// If it is there it must verify, and it must describe the list beside it -- a signed document that
// no longer matches what it claims to sign would be accepted by a consumer and then not contain
// what that consumer is looking for.
function validateSigned(hashes) {
if (!fs.existsSync(SIGNED)) {
process.stderr.write('no signed document yet, skipping\n');
return;
}

// eslint-disable-next-line global-require
const { verifyDocument, PINNED_PUBLIC_KEYS } = require('./verify-hashlist');

let payload;
try {
payload = verifyDocument(JSON.parse(fs.readFileSync(SIGNED, 'utf8')), PINNED_PUBLIC_KEYS);
} catch (error) {
check(false, `signed document does not verify: ${error.message}`);
return;
}

check(Number.isInteger(payload.seq) && payload.seq >= 1, `signed sequence is not a positive integer: ${payload.seq}`);

if (hashes) {
const matches = payload.hashes.length === hashes.length
&& payload.hashes.every((hash, i) => hash === hashes[i]);
check(matches, `signed document lists ${payload.hashes.length} entries, hashes.js has ${hashes.length}`);
}

process.stderr.write(`signed document: seq ${payload.seq}, ${payload.hashes.length} entries\n`);
}

function main() {
const hashes = validateHashes();
validateSigned(hashes);

if (failures.length) {
failures.forEach((failure) => process.stderr.write(` FAIL ${failure}\n`));
process.exit(1);
}
process.stderr.write('ok\n');
}

if (require.main === module) main();
Loading
Loading