| … | … | @@ -356,12 +356,24 @@ export async function run (client, args) { |
| 356 | 356 | }), |
| 357 | 357 | publishedAt: lockfile.publishedAt, |
| 358 | 358 | 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, | |
| 359 | 364 | acceptedConflicts: lockfile.acceptedConflicts || null |
| 360 | 365 | } |
| 361 | 366 | |
| 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) | |
| 365 | 377 | signed = true |
| 366 | 378 | } |
| 367 | 379 |
| … | … | @@ -178,6 +178,11 @@ export class ReleaseRegistry { |
| 178 | 178 | deps: entry.deps || [], |
| 179 | 179 | publishedAt: entry.publishedAt || new Date().toISOString(), |
| 180 | 180 | 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, | |
| 181 | 186 | sig: entry.sig || null, |
| 182 | 187 | acceptedConflicts: entry.acceptedConflicts || null |
| 183 | 188 | } |
| … | … | @@ -207,7 +212,9 @@ export class ReleaseRegistry { |
| 207 | 212 | async get (name, version) { |
| 208 | 213 | if (!this._ready) await this.ready() |
| 209 | 214 | 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 | |
| 211 | 218 | } |
| 212 | 219 | |
| 213 | 220 | async latest (name) { |
| … | … | @@ -266,6 +273,67 @@ export function signEntry (entry, secretKey) { |
| 266 | 273 | return { ...entry, sig: sig.toString('hex') } |
| 267 | 274 | } |
| 268 | 275 | |
| 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 | */ | |
| 292 | export 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 | */ | |
| 323 | export 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 | ||
| 269 | 337 | export function verifyEntry (entry, publisherPublicKey) { |
| 270 | 338 | if (!entry.sig) return false |
| 271 | 339 | const sig = b4a.from(entry.sig, 'hex') |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "pgh", |
| 3 | "version": "0.1.141", | |
| 3 | "version": "0.1.142", | |
| 4 | 4 | "description": "pgh \u2014 P2P content-addressed package manager and app launcher", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "main": "index.js", |
| … | … | @@ -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 | ||
| 24 | import test from 'brittle' | |
| 25 | import fs from '#node:fs' | |
| 26 | import path from '#node:path' | |
| 27 | import os from '#node:os' | |
| 28 | import Corestore from 'corestore' | |
| 29 | import crypto from 'hypercore-crypto' | |
| 30 | import b4a from 'b4a' | |
| 31 | ||
| 32 | import { | |
| 33 | ReleaseRegistry, | |
| 34 | signEntry, | |
| 35 | verifyReleaseEntry | |
| 36 | } from '../lib/release-registry.js' | |
| 37 | ||
| 38 | async 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 | ||
| 51 | function 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 | ||
| 68 | test('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 | ||
| 86 | test('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 | ||
| 96 | test('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 | ||
| 114 | test('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 | ||
| 126 | test('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 | ||
| 134 | test('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 | }) |
| … | … | @@ -9,9 +9,9 @@ |
| 9 | 9 | // Meta is a CACHE (pulledAt/source/pinned/schema), never the tree, so degrading to "no meta" is |
| 10 | 10 | // always safe. _readRefs in the same class already tolerated this; getMeta was the outlier. |
| 11 | 11 | import test from 'brittle' |
| 12 | import fs from 'fs' | |
| 13 | import os from 'os' | |
| 14 | import path from 'path' | |
| 12 | import fs from '#node:fs' | |
| 13 | import os from '#node:os' | |
| 14 | import path from '#node:path' | |
| 15 | 15 | import { TreeStore } from '../lib/store.js' |
| 16 | 16 | |
| 17 | 17 | const tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'pgh-meta-')) |