OverviewFilesCommitsIssuesDocsReleasesPeers

pgh commits / d0afa3b

0.1.142: actually sign releases, and check the signature on read
Signing was gated on PGH_SIGNING_KEY — a variable that occurs exactly once in the whole stack, this read site, and is set nowhere: not in any ci.yaml, not in package.json defaults, not in ~/.config/pgh/apps.json. So `sig` was null on every release ever published. `verifyEntry` was exported with zero call sites, so a signature would not have been checked even if one had existed. Authenticity rested entirely on writer-set membership in the registry autobase. publish now signs with the identity it already loads — the same one it writes into the entry as publisherDevicePubkey — and carries `publisherChain` so a verifier can resolve that device back to the identity root rather than trusting the device key alone. The env var is gone. Verification sits in `get()` (and so in `latest()`/`lookupByTree()`), not at the install call sites: resolveRelease is only one of several readers, and the sidecar's resolve-release handler reads through the same methods, so it is covered without touching the sidecar. A signed entry that does not verify is not a release, it is a tamper — throwing degrades to "not found" in callers that catch, which is the safe direction. TRANSITIONAL: an entry with no `sig` still passes. Every pre-existing release has none, and refusing them would make every installed app un-updatable and every published release uninstallable. This closes the signed-but-invalid case; it is a tamper check, not yet an authenticity guarantee, since an attacker with registry write access can still omit the signature. Enforcement is a later flip once publishers have re-published. `add()` had to learn publisherChain: it rebuilds entries from a fixed field list, so a signed field it drops is a field the stored entry no longer hashes to, and the signature silently stops verifying on the way OUT. Verified by removing that one line — the round-trip test fails exactly there. Also fixes test/store-meta-corrupt.test.js, which imported 'fs'/'os'/'path' Node-style where every other test file uses the #node: map. It could not resolve under Bare and aborted the entire suite before any test ran. Pre-existing, unrelated to this change. 318/318, 914/914 across the pgh suite. Forge org #144 steps 1-2. Step 3 (bind the device to the claimed name's identityRoot) is next and needs #142 + #143 deployed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
d0afa3b8f049b254a89ab8b84094a7c110cd1e80
by plankatron <fleeky@gmail.com> · 2026-08-25T14:54:30.000Z

Changed files (5) +234 -8

lib/commands/publish.js +15 -3
……@@ -356,12 +356,24 @@ export async function run (client, args) {
356356 }),
357357 publishedAt: lockfile.publishedAt,
358358 publisherDevicePubkey: identity.publicKeyHex,
359 // The attestation chain for publisherDevicePubkey, so a verifier can
360 // resolve this device back to the identity ROOT that owns the app name
361 // rather than trusting the device key on its own. null for raw/legacy
362 // identities, which have no chain to offer.
363 publisherChain: identity.chain || null,
359364 acceptedConflicts: lockfile.acceptedConflicts || null
360365 }
361366
362 const signingKeyHex = process.env.PGH_SIGNING_KEY
363 if (signingKeyHex && signingKeyHex.length === 128) {
364 entry = signEntry(entry, Buffer.from(signingKeyHex, 'hex'))
367 // Sign with the identity we already loaded. This used to be gated on
368 // PGH_SIGNING_KEY, a variable that appeared exactly once in the whole stack
369 // — this read — and was never set anywhere, so `sig` was null on every
370 // release ever published while the verifier sat uncalled (org #144).
371 //
372 // A raw/legacy identity with no chain can still sign; it simply cannot be
373 // resolved past its device key. An identity with no secret key at all
374 // publishes unsigned, as before.
375 if (identity.secretKey) {
376 entry = signEntry(entry, identity.secretKey)
365377 signed = true
366378 }
367379
lib/release-registry.js +69 -1
……@@ -178,6 +178,11 @@ export class ReleaseRegistry {
178178 deps: entry.deps || [],
179179 publishedAt: entry.publishedAt || new Date().toISOString(),
180180 publisherDevicePubkey: entry.publisherDevicePubkey || null,
181 // MUST be carried: `sig` covers every field except itself, so a field
182 // dropped here is a field the stored entry no longer hashes to and the
183 // signature stops verifying. Anything added to the signed entry has to
184 // be added to this list too (org #144).
185 publisherChain: entry.publisherChain || null,
181186 sig: entry.sig || null,
182187 acceptedConflicts: entry.acceptedConflicts || null
183188 }
……@@ -207,7 +212,9 @@ export class ReleaseRegistry {
207212 async get (name, version) {
208213 if (!this._ready) await this.ready()
209214 const entry = await this._view.get('release:' + name + ':' + version)
210 return entry ? entry.value : null
215 if (!entry) return null
216 assertEntryIntegrity(entry.value)
217 return entry.value
211218 }
212219
213220 async latest (name) {
……@@ -266,6 +273,67 @@ export function signEntry (entry, secretKey) {
266273 return { ...entry, sig: sig.toString('hex') }
267274}
268275
276/**
277 * Verify a release entry's signature against the device key it names.
278 *
279 * Transitional by design (org #144): an entry with NO `sig` passes. Every
280 * release published before signing was wired carries `sig: null`, and
281 * refusing those would make every installed app un-updatable and every
282 * existing release uninstallable. What this closes is the case that actually
283 * matters now — an entry that carries a signature which does not verify.
284 *
285 * Enforcement (refuse unsigned) is a later flip, once publishers have
286 * re-published. Until then this is a tamper check, not an authenticity
287 * guarantee: an attacker who can write to the registry can still simply omit
288 * the signature.
289 *
290 * @returns {{ ok: boolean, signed: boolean, reason?: string }}
291 */
292export function verifyReleaseEntry (entry) {
293 if (!entry) return { ok: false, signed: false, reason: 'no entry' }
294 if (!entry.sig) return { ok: true, signed: false }
295
296 const pubkey = entry.publisherDevicePubkey
297 if (!pubkey) {
298 return { ok: false, signed: true, reason: 'entry is signed but names no publisherDevicePubkey' }
299 }
300
301 let ok = false
302 try {
303 ok = verifyEntry(entry, pubkey)
304 } catch (err) {
305 return { ok: false, signed: true, reason: 'signature check threw: ' + err.message }
306 }
307 if (!ok) {
308 return {
309 ok: false,
310 signed: true,
311 reason: 'signature does not verify against publisherDevicePubkey ' + String(pubkey).slice(0, 12) + '…'
312 }
313 }
314 return { ok: true, signed: true }
315}
316
317/**
318 * Throwing form, used on every read path so no call site can forget it. A
319 * signed entry that fails verification is not a release, it is a tamper — and
320 * a loud failure degrades to "not found" in callers that catch, which is the
321 * safe direction.
322 */
323export function assertEntryIntegrity (entry) {
324 const v = verifyReleaseEntry(entry)
325 if (!v.ok) {
326 throw Object.assign(
327 new Error(
328 'release ' + (entry && entry.name) + '@' + (entry && entry.version) +
329 ' failed verification: ' + v.reason
330 ),
331 { code: 'EBADRELEASESIG', entry }
332 )
333 }
334 return entry
335}
336
269337export function verifyEntry (entry, publisherPublicKey) {
270338 if (!entry.sig) return false
271339 const sig = b4a.from(entry.sig, 'hex')
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "pgh",
3 "version": "0.1.141",
3 "version": "0.1.142",
44 "description": "pgh \u2014 P2P content-addressed package manager and app launcher",
55 "type": "module",
66 "main": "index.js",
test/release-signing.test.js +146 -0
……@@ -0,0 +1,146 @@
1/**
2 * Release entry signing + verification (forge org #144).
3 *
4 * Before: `signEntry` was gated on PGH_SIGNING_KEY — a variable that occurred
5 * exactly once in the entire stack, the read site, and was never set anywhere.
6 * So every release ever published carried `sig: null`. `verifyEntry` was
7 * exported and had zero call sites, so a signature would not have been checked
8 * even if one existed. Release authenticity rested entirely on writer-set
9 * membership in the registry autobase.
10 *
11 * After: publish signs with the device key it already loads, and every read
12 * path (`get`, and `latest`/`lookupByTree` through it) asserts integrity.
13 *
14 * Transitional on purpose: an entry with NO sig still passes, because every
15 * pre-existing release has none and refusing them would make every installed
16 * app un-updatable. What is closed is the signed-but-invalid case.
17 *
18 * The round-trip test is the load-bearing one. `add()` rebuilds the entry from
19 * a fixed field list, so any signed field it forgets to carry is a field the
20 * stored entry no longer hashes to — the signature silently stops verifying.
21 * That is exactly what adding `publisherChain` risked.
22 */
23
24import test from 'brittle'
25import fs from '#node:fs'
26import path from '#node:path'
27import os from '#node:os'
28import Corestore from 'corestore'
29import crypto from 'hypercore-crypto'
30import b4a from 'b4a'
31
32import {
33 ReleaseRegistry,
34 signEntry,
35 verifyReleaseEntry
36} from '../lib/release-registry.js'
37
38async function openRegistry (t) {
39 const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pgh-relsign-'))
40 const store = new Corestore(dir)
41 const reg = new ReleaseRegistry(store)
42 await reg.ready()
43 t.teardown(async () => {
44 await reg.close()
45 await store.close()
46 fs.rmSync(dir, { recursive: true, force: true })
47 })
48 return reg
49}
50
51function makeEntry (device, overrides = {}) {
52 return {
53 name: 'demo',
54 version: '1.0.0',
55 runtime: 'bare',
56 treeHash: 'a'.repeat(64),
57 upgrade: null,
58 serverKey: b4a.toString(device.publicKey, 'hex'),
59 deps: [],
60 publishedAt: '2026-08-25T00:00:00.000Z',
61 publisherDevicePubkey: b4a.toString(device.publicKey, 'hex'),
62 publisherChain: 'Y2hhaW4tYnl0ZXM=',
63 acceptedConflicts: null,
64 ...overrides
65 }
66}
67
68test('a signed entry survives the registry round-trip', async (t) => {
69 const reg = await openRegistry(t)
70 const device = crypto.keyPair()
71
72 const signed = signEntry(makeEntry(device), device.secretKey)
73 t.ok(signed.sig, 'entry carries a signature')
74 t.ok(verifyReleaseEntry(signed).ok, 'verifies before storing')
75
76 await reg.add(signed)
77
78 // The real hazard: add() reconstructs the entry from a fixed field list, so
79 // a signed field it drops breaks the signature on the way OUT, not in.
80 const stored = await reg.get('demo', '1.0.0')
81 t.is(stored.publisherChain, signed.publisherChain, 'publisherChain carried through add()')
82 const v = verifyReleaseEntry(stored)
83 t.ok(v.ok && v.signed, 'still verifies after storage: ' + (v.reason || 'ok'))
84})
85
86test('latest() reads through the same check', async (t) => {
87 const reg = await openRegistry(t)
88 const device = crypto.keyPair()
89 await reg.add(signEntry(makeEntry(device), device.secretKey))
90
91 const latest = await reg.latest('demo')
92 t.is(latest.version, '1.0.0')
93 t.ok(verifyReleaseEntry(latest).ok, 'latest returns a verified entry')
94})
95
96test('a tampered entry is refused on read', async (t) => {
97 const reg = await openRegistry(t)
98 const device = crypto.keyPair()
99
100 // Sign an honest entry, then swap the tree it points at — the substitution
101 // that matters, since treeHash is what actually gets installed.
102 const signed = signEntry(makeEntry(device), device.secretKey)
103 const tampered = { ...signed, treeHash: 'b'.repeat(64) }
104
105 await reg.add(tampered)
106
107 await t.exception(
108 () => reg.get('demo', '1.0.0'),
109 /failed verification/,
110 'get() refuses it'
111 )
112})
113
114test('an entry signed by a different key is refused', async (t) => {
115 const device = crypto.keyPair()
116 const impostor = crypto.keyPair()
117
118 // Claims to be from `device`, actually signed by `impostor`.
119 const forged = signEntry(makeEntry(device), impostor.secretKey)
120 const v = verifyReleaseEntry(forged)
121 t.absent(v.ok, 'refused')
122 t.ok(v.signed, 'recognised as signed')
123 t.ok(/does not verify/.test(v.reason), v.reason)
124})
125
126test('a signed entry naming no device key is refused', (t) => {
127 const device = crypto.keyPair()
128 const signed = signEntry(makeEntry(device), device.secretKey)
129 const v = verifyReleaseEntry({ ...signed, publisherDevicePubkey: null })
130 t.absent(v.ok, 'refused')
131 t.ok(/names no publisherDevicePubkey/.test(v.reason), v.reason)
132})
133
134test('an UNSIGNED legacy entry still passes (transitional)', async (t) => {
135 const reg = await openRegistry(t)
136 const device = crypto.keyPair()
137
138 // Exactly what every release published before #144 looks like.
139 await reg.add(makeEntry(device, { publisherChain: null }))
140
141 const stored = await reg.get('demo', '1.0.0')
142 t.is(stored.sig, null, 'no signature, as before')
143 const v = verifyReleaseEntry(stored)
144 t.ok(v.ok, 'accepted')
145 t.absent(v.signed, 'but reported as unsigned')
146})
test/store-meta-corrupt.test.js +3 -3
……@@ -9,9 +9,9 @@
99// Meta is a CACHE (pulledAt/source/pinned/schema), never the tree, so degrading to "no meta" is
1010// always safe. _readRefs in the same class already tolerated this; getMeta was the outlier.
1111import test from 'brittle'
12import fs from 'fs'
13import os from 'os'
14import path from 'path'
12import fs from '#node:fs'
13import os from '#node:os'
14import path from '#node:path'
1515import { TreeStore } from '../lib/store.js'
1616
1717const tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'pgh-meta-'))