OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay commits / f955e8b

fix: fail closed on release signer authority
f955e8b80f9d699089315d4c41d2e2bfd7cb9187
by bigdestiny2 <33146784+bigdestiny2@users.noreply.github.com> · 2026-07-29T21:16:21.000Z

Changed files (8) +195 -16

CHANGELOG.md +3 -0
……@@ -66,6 +66,9 @@ canary promotion remain required before deployment.**
6666- The exact-target updater repair command defaults to a read-only probe and
6767 requires repeated relay confirmation before atomically applying a pin; it
6868 refuses application-version changes and restores updater state on failure.
69- The release cutter now distinguishes a locked vault from an absent signing
70 key, refuses setup while key presence is unknowable, and rejects a signer
71 principal not trusted by the fleet before it can create a tag.
6972- StartOS and Umbrel metadata, release-evidence tests, and operator docs are
7073 synchronized to the current prerelease baseline and distinguish configured
7174 defaults from signed runtime proof.
docs/LADDER-SHIP-MAP.md +3 -2
……@@ -55,7 +55,7 @@ Marketing one-liner when both are live: **“Fleet v0.25.0 + Blind public-test o
5555| Item | State |
5656|------|--------|
5757| Fleet channel | **canary `v0.25.0-rc.7`** · **stable `v0.24.3`** · held `v0.24.3` |
58| Repair candidate | `v0.25.0-rc.8` package identity prepared: full Node unit **3564/3564**, integration **107/107**, lint, public-artifact audit, release mailbox benchmark, and npm package dry-runs green; signed tag and CI image evidence remain pending |
58| Repair candidate | `v0.25.0-rc.8` package identity prepared: full Node unit **3570/3570** with **17954/17954** assertions, integration **107/107**, lint, public-artifact audit, release mailbox benchmark, and npm package dry-runs green; signed tag and CI image evidence remain pending |
5959| Scoped live health (2026-07-29) | Latest operator scan excludes Sydney/Dallas and covers 12 inventory entries: service catalogues reached **10/12**, hosts **11/12**, and active relay applications **10/12**. Bern remains unreachable; Dubai-2GB is reachable but blank/inactive with no relay repo, service, updater, config, or health endpoint. `utah-8gb` is healthy on signed RC4 with `CHANNEL=stable`, exact `PINNED_TAG=v0.25.0-rc.4`, and updater timer enabled/active |
6060| Wake runtime | `notify` loaded on **10/10** service-reachable relays, but signed live egress **0/10** and exact-lane wake **0/10**; eight stable relays still carry the old false `notify-v1` feature claim, while Utah and Utah-8GB correctly report loaded-not-live |
6161| Agent mailbox | `outboxlog` is signed-advertised on **8/10** service-reachable relays; Utah and Utah-8GB have mailbox off |
……@@ -442,7 +442,8 @@ You can call the **product ladder complete for honest marketing** when:
442442## 9. Immediate next three moves (in order)
443443
4444441. **Sign and ship the prepared RC8 candidate:** unlock the existing release
445 signer, cut and push the signed `v0.25.0-rc.8` tag, and let CI build and prove
445 signer, select the principal already shipped in `fleet/allowed-signers`, cut
446 and push the signed `v0.25.0-rc.8` tag, and let CI build and prove
446447 the digest-pinned image. Promote only the permitted canary in a separate
447448 health-gated change. Utah-8GB's exact RC4 updater pin and timer repair is
448449 complete; do not fold a downgrade into the canary promotion.
docs/RELEASE-RUNBOOK-0.25.0-rc.5.md +2 -2
……@@ -60,8 +60,8 @@ Two prerequisites, both currently unmet:
6060
6161**The vault is locked.** `status` reports
6262`locked: no agent running for ~/.keyvault/secrets.vault`, and consequently cannot
63see the key (`signing key: NOT in vault` is what a locked vault looks like — it is
64not proof the key is absent). Unlock first:
63see the key. Current tooling reports this as `unavailable while vault is locked`;
64it never treats a locked vault as proof the key is absent. Unlock first:
6565
6666```bash
6767keyvault agent --daemonize
docs/RELEASE.md +9 -2
……@@ -55,8 +55,11 @@ agent is down):
5555keyvault exec --scope hiverelay-release -- scripts/release.sh cut v0.24.0
5656```
5757
58`scripts/release.sh status` shows what's configured (vault key present, signer
59email, allowed-signers count, package.json version).
58`scripts/release.sh status` shows what's configured (vault state, key
59availability, whether the signer principal is trusted by `allowed-signers`,
60allowed-signers count, and package.json version). A locked vault is reported as
61unknown/unavailable, never as proof that the signing key is absent; unlock it
62before considering the one-time `setup` command.
6063
6164## Guards the script enforces
6265
……@@ -70,6 +73,10 @@ email, allowed-signers count, package.json version).
7073- **Self-verify before push.** The signed tag is verified against
7174 `fleet/allowed-signers` locally; if your key isn't trusted there, the tag is
7275 deleted and nothing is pushed — you never publish a tag the fleet would reject.
76- **Trusted principal before signing.** If `git config user.email` is not an
77 allowed principal, `cut` fails before creating a tag. Set
78 `HIVERELAY_RELEASE_SIGNER_EMAIL` to the principal shipped in
79 `fleet/allowed-signers`; do not rotate a key merely to repair an email mismatch.
7380- **No key on disk.** The signing key is materialized only to a `0600` temp file
7481 that is removed on every exit path (success, error, or interrupt).
7582
scripts/lib/audit-owned-diff.mjs +14 -0
……@@ -1,4 +1,18 @@
11export const AUDIT_OWNED_DIFF_SLICES = Object.freeze([
2 {
3 id: 'release-signer-authority-preflight',
4 title: 'Release signer vault and trusted-principal preflight',
5 files: [
6 'CHANGELOG.md',
7 'docs/LADDER-SHIP-MAP.md',
8 'docs/RELEASE.md',
9 'docs/RELEASE-RUNBOOK-0.25.0-rc.5.md',
10 'scripts/lib/audit-owned-diff.mjs',
11 'scripts/release.sh',
12 'test/unit/audit-owned-diff.test.js',
13 'test/unit/release-cutter-status.test.js'
14 ]
15 },
216 {
317 id: 'release-blocker-closure',
418 title: 'Release blocker closure board',
scripts/release.sh +60 -10
……@@ -43,6 +43,23 @@ die() { printf '\033[31mrelease: ERROR:\033[0m %s\n' "$*" >&2; exit 1; }
4343
4444need() { command -v "$1" >/dev/null 2>&1 || die "missing required tool: $1"; }
4545
46allowed_signer_count() {
47 [ -r "$ALLOWED_SIGNERS" ] || { printf '0\n'; return 0; }
48 grep -cvE '^[[:space:]]*(#|$)' "$ALLOWED_SIGNERS" 2>/dev/null || true
49}
50
51signer_principal_is_trusted() {
52 [ -n "${SIGNER_EMAIL:-}" ] && [ -r "$ALLOWED_SIGNERS" ] || return 1
53 awk -v principal="$SIGNER_EMAIL" '
54 /^[[:space:]]*(#|$)/ { next }
55 {
56 count = split($1, principals, ",")
57 for (i = 1; i <= count; i++) if (principals[i] == principal) found = 1
58 }
59 END { exit(found ? 0 : 1) }
60 ' "$ALLOWED_SIGNERS"
61}
62
4663# Any secret material written to disk is tracked here and removed on EVERY exit
4764# path — success, error under `set -e`, or interrupt. A function-local trap would
4865# miss the errexit path and strand a private key in the temp dir.
……@@ -61,7 +78,12 @@ get_signing_key() {
6178 if [ -n "${SIGNING_KEY_FILE:-}" ]; then cat "$SIGNING_KEY_FILE"; return 0; fi
6279 if command -v keyvault >/dev/null 2>&1; then
6380 keyvault get "$VAULT_SCOPE/$VAULT_KEY_NAME" 2>/dev/null && return 0
64 die "keyvault has no '$VAULT_SCOPE/$VAULT_KEY_NAME' — run: $0 setup (and unlock the agent)"
81 local vault_state
82 vault_state="$(keyvault status 2>/dev/null | head -1 || true)"
83 if [[ "$vault_state" == locked:* ]]; then
84 die "keyvault is locked, so signing-key presence is unknown — run: keyvault agent --daemonize"
85 fi
86 die "unlocked keyvault has no '$VAULT_SCOPE/$VAULT_KEY_NAME' — run: $0 setup"
6587 fi
6688 die "no signing key available: set $KEY_ENV_VAR (via 'keyvault exec --scope $VAULT_SCOPE'), install keyvault, or set SIGNING_KEY_FILE"
6789}
……@@ -70,6 +92,11 @@ get_signing_key() {
7092cmd_setup() {
7193 need ssh-keygen; need keyvault; need awk
7294 [ -n "$SIGNER_EMAIL" ] || die "no signer email — set HIVERELAY_RELEASE_SIGNER_EMAIL or 'git config user.email'"
95 local vault_state
96 vault_state="$(keyvault status 2>/dev/null | head -1 || true)"
97 if [[ "$vault_state" == locked:* ]]; then
98 die "keyvault is locked, so setup cannot prove the signing key is absent — unlock it before setup"
99 fi
73100 if keyvault get "$VAULT_SCOPE/$VAULT_KEY_NAME" >/dev/null 2>&1; then
74101 die "'$VAULT_SCOPE/$VAULT_KEY_NAME' already exists in the vault — refusing to overwrite (rotate deliberately with 'keyvault rm' first)"
75102 fi
……@@ -117,6 +144,11 @@ cmd_cut() {
117144 [ -n "$version" ] || die "usage: $0 cut <vX.Y.Z> [<ref>] [--stable] [--promote-canary] [-y]"
118145 [[ "$version" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]] || die "version must look like v0.24.0 (got '$version')"
119146 [ -n "$SIGNER_EMAIL" ] || die "no signer email — set HIVERELAY_RELEASE_SIGNER_EMAIL or 'git config user.email'"
147 local signer_lines
148 signer_lines="$(allowed_signer_count)"
149 if [ "${signer_lines:-0}" -gt 0 ] && ! signer_principal_is_trusted; then
150 die "signer principal '$SIGNER_EMAIL' is not trusted by fleet/allowed-signers — set HIVERELAY_RELEASE_SIGNER_EMAIL to an allowed principal"
151 fi
120152
121153 # FOOTGUN GUARD: the fleet updater health-gate compares the tag (minus 'v') to
122154 # the running /health version, which is package.json's version. A mismatch
……@@ -171,10 +203,7 @@ cmd_cut() {
171203 # Self-verify against the shipped allowed-signers BEFORE pushing, so we never
172204 # publish a tag the fleet's verify_tag would reject — but only when the file
173205 # has at least one real (non-comment) signer line.
174 local signer_lines=0
175 if [ -r "$ALLOWED_SIGNERS" ]; then
176 signer_lines="$(grep -cvE '^[[:space:]]*(#|$)' "$ALLOWED_SIGNERS" 2>/dev/null || true)"
177 fi
206 signer_lines="$(allowed_signer_count)"
178207 if [ "${signer_lines:-0}" -gt 0 ]; then
179208 if git -C "$REPO_ROOT" -c gpg.format=ssh -c "gpg.ssh.allowedSignersFile=$ALLOWED_SIGNERS" \
180209 verify-tag "$version" >/dev/null 2>&1; then
……@@ -226,14 +255,35 @@ cmd_status() {
226255 printf 'git: %s\n' "$(git --version 2>/dev/null || echo MISSING)"
227256 printf 'gpg: %s\n' "$(command -v gpg >/dev/null 2>&1 && gpg --version | head -1 || echo 'absent (fine — SSH signing used)')"
228257 printf 'gh: %s\n' "$(command -v gh >/dev/null 2>&1 && echo present || echo 'absent (release step skipped)')"
229 printf 'keyvault: %s\n' "$(command -v keyvault >/dev/null 2>&1 && (keyvault status 2>/dev/null | head -1 || echo installed) || echo absent)"
230 printf 'signer email: %s\n' "${SIGNER_EMAIL:-<unset>}"
231 if command -v keyvault >/dev/null 2>&1 && keyvault get "$VAULT_SCOPE/$VAULT_KEY_NAME" >/dev/null 2>&1; then
258 local vault_state="absent"
259 if command -v keyvault >/dev/null 2>&1; then
260 vault_state="$(keyvault status 2>/dev/null | head -1 || true)"
261 [ -n "$vault_state" ] || vault_state="installed; status unavailable"
262 fi
263 printf 'keyvault: %s\n' "$vault_state"
264 local signer_trust="unverifiable (no allowed signers)"
265 if [ "$(allowed_signer_count)" -gt 0 ]; then
266 if signer_principal_is_trusted; then
267 signer_trust="trusted by fleet/allowed-signers"
268 else
269 signer_trust="NOT trusted by fleet/allowed-signers"
270 fi
271 fi
272 printf 'signer email: %s (%s)\n' "${SIGNER_EMAIL:-<unset>}" "$signer_trust"
273 if [ -n "${!KEY_ENV_VAR:-}" ]; then
274 printf 'signing key: available via %s\n' "$KEY_ENV_VAR"
275 elif [ -n "${SIGNING_KEY_FILE:-}" ] && [ -r "$SIGNING_KEY_FILE" ]; then
276 printf 'signing key: available via SIGNING_KEY_FILE\n'
277 elif command -v keyvault >/dev/null 2>&1 && [[ "$vault_state" == locked:* ]]; then
278 printf 'signing key: unavailable while vault is locked — run: keyvault agent --daemonize\n'
279 elif command -v keyvault >/dev/null 2>&1 && keyvault get "$VAULT_SCOPE/$VAULT_KEY_NAME" >/dev/null 2>&1; then
232280 printf 'signing key: present in vault (%s/%s)\n' "$VAULT_SCOPE" "$VAULT_KEY_NAME"
281 elif command -v keyvault >/dev/null 2>&1; then
282 printf 'signing key: NOT found in unlocked vault — run: %s setup\n' "$0"
233283 else
234 printf 'signing key: NOT in vault — run: %s setup\n' "$0"
284 printf 'signing key: unavailable — keyvault is not installed\n'
235285 fi
236 local as_n=0; [ -r "$ALLOWED_SIGNERS" ] && as_n="$(grep -cvE '^[[:space:]]*(#|$)' "$ALLOWED_SIGNERS" 2>/dev/null || true)"
286 local as_n; as_n="$(allowed_signer_count)"
237287 printf 'allowed-signers:%s\n' "$([ "${as_n:-0}" -gt 0 ] && echo " ${as_n} signer(s)" || echo ' none — run: setup')"
238288 printf 'package.json: v%s (a fleet tag MUST be exactly this)\n' "$(node -p "require('$REPO_ROOT/package.json').version" 2>/dev/null || echo '?')"
239289}
test/unit/audit-owned-diff.test.js +18 -0
……@@ -259,6 +259,24 @@ test('audit-owned diff assigns fleet runtime evidence surfaces', (t) => {
259259 }
260260})
261261
262test('audit-owned diff assigns release signer authority surfaces', (t) => {
263 const paths = [
264 'CHANGELOG.md',
265 'docs/LADDER-SHIP-MAP.md',
266 'docs/RELEASE.md',
267 'docs/RELEASE-RUNBOOK-0.25.0-rc.5.md',
268 'scripts/release.sh',
269 'test/unit/release-cutter-status.test.js'
270 ]
271 const report = buildOwnedDiffReport(paths.map(file => ` M ${file}`).join('\n'))
272
273 t.is(report.status, 'pass')
274 t.is(report.totals.unknown, 0)
275 for (const entry of report.entries) {
276 t.ok(entry.owners.some(owner => owner.id === 'release-signer-authority-preflight'), entry.path)
277 }
278})
279
262280test('audit-owned diff blocks unknown development paths', async (t) => {
263281 const fixture = await tempFile(t, '?? stray-release-note.txt\n')
264282 const res = await runAudit(['--json', '--status-file', fixture])
test/unit/release-cutter-status.test.js +86 -0
……@@ -0,0 +1,86 @@
1import test from 'brittle'
2import { spawn } from 'node:child_process'
3import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises'
4import { delimiter, join } from 'node:path'
5import { tmpdir } from 'node:os'
6
7function runRelease (args, env = {}) {
8 return new Promise((resolve, reject) => {
9 const child = spawn('bash', ['scripts/release.sh', ...args], {
10 env: { ...process.env, ...env },
11 stdio: ['ignore', 'pipe', 'pipe']
12 })
13 let stdout = ''
14 let stderr = ''
15 child.stdout.on('data', chunk => { stdout += chunk })
16 child.stderr.on('data', chunk => { stderr += chunk })
17 child.once('error', reject)
18 child.once('close', code => resolve({ code, stdout, stderr }))
19 })
20}
21
22async function fakeKeyvault (t, state, hasKey) {
23 const dir = await mkdtemp(join(tmpdir(), 'hiverelay-release-status-'))
24 t.teardown(() => rm(dir, { recursive: true, force: true }))
25 const keyvault = join(dir, 'keyvault')
26 await writeFile(keyvault, `#!/bin/sh
27case "$1" in
28 status) printf '%s\n' '${state}'; exit 0 ;;
29 get) ${hasKey ? "printf '%s\\n' 'FAKE-KEY'; exit 0" : 'exit 1'} ;;
30esac
31exit 1
32`)
33 await chmod(keyvault, 0o755)
34 return `${dir}${delimiter}${process.env.PATH || ''}`
35}
36
37test('release status distinguishes a locked vault from an absent signing key', async (t) => {
38 const path = await fakeKeyvault(t, 'locked: no agent running for test vault', false)
39 const result = await runRelease(['status'], {
40 PATH: path,
41 HIVERELAY_RELEASE_SIGNER_EMAIL: 'bigdestiny2@users.noreply.github.com'
42 })
43 t.is(result.code, 0)
44 t.ok(result.stdout.includes('signing key: unavailable while vault is locked'))
45 t.absent(result.stdout.includes('NOT found in unlocked vault'))
46 t.ok(result.stdout.includes('(trusted by fleet/allowed-signers)'))
47})
48
49test('release status reports a missing key only when the vault is unlocked', async (t) => {
50 const path = await fakeKeyvault(t, 'unlocked: agent ready', false)
51 const result = await runRelease(['status'], {
52 PATH: path,
53 HIVERELAY_RELEASE_SIGNER_EMAIL: 'not-trusted@example.com'
54 })
55 t.is(result.code, 0)
56 t.ok(result.stdout.includes('signing key: NOT found in unlocked vault'))
57 t.ok(result.stdout.includes('(NOT trusted by fleet/allowed-signers)'))
58})
59
60test('release status recognizes a key in an unlocked vault', async (t) => {
61 const path = await fakeKeyvault(t, 'unlocked: agent ready', true)
62 const result = await runRelease(['status'], {
63 PATH: path,
64 HIVERELAY_RELEASE_SIGNER_EMAIL: 'bigdestiny2@users.noreply.github.com'
65 })
66 t.is(result.code, 0)
67 t.ok(result.stdout.includes('signing key: present in vault (hiverelay-release/tag-signing-key)'))
68})
69
70test('release setup refuses to infer key absence from a locked vault', async (t) => {
71 const path = await fakeKeyvault(t, 'locked: no agent running for test vault', false)
72 const result = await runRelease(['setup'], {
73 PATH: path,
74 HIVERELAY_RELEASE_SIGNER_EMAIL: 'bigdestiny2@users.noreply.github.com'
75 })
76 t.is(result.code, 1)
77 t.ok(result.stderr.includes('setup cannot prove the signing key is absent'))
78})
79
80test('release cut rejects an untrusted signer principal before tagging', async (t) => {
81 const result = await runRelease(['cut', 'v0.25.0-rc.8', '-y'], {
82 HIVERELAY_RELEASE_SIGNER_EMAIL: 'not-trusted@example.com'
83 })
84 t.is(result.code, 1)
85 t.ok(result.stderr.includes("signer principal 'not-trusted@example.com' is not trusted"))
86})