/**
* Release entry signing + verification (forge org #144).
*
* Before: `signEntry` was gated on PGH_SIGNING_KEY — a variable that occurred
* exactly once in the entire stack, the read site, and was never set anywhere.
* So every release ever published carried `sig: null`. `verifyEntry` was
* exported and had zero call sites, so a signature would not have been checked
* even if one existed. Release authenticity rested entirely on writer-set
* membership in the registry autobase.
*
* After: publish signs with the device key it already loads, and every read
* path (`get`, and `latest`/`lookupByTree` through it) asserts integrity.
*
* Transitional on purpose: an entry with NO sig still passes, because every
* pre-existing release has none and refusing them would make every installed
* app un-updatable. What is closed is the signed-but-invalid case.
*
* The round-trip test is the load-bearing one. `add()` rebuilds the entry from
* a fixed field list, so any signed field it forgets to carry is a field the
* stored entry no longer hashes to — the signature silently stops verifying.
* That is exactly what adding `publisherChain` risked.
*/
import test from 'brittle'
import fs from '#node:fs'
import path from '#node:path'
import os from '#node:os'
import Corestore from 'corestore'
import crypto from 'hypercore-crypto'
import b4a from 'b4a'
import {
ReleaseRegistry,
signEntry,
verifyReleaseEntry
} from '../lib/release-registry.js'
async function openRegistry (t) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pgh-relsign-'))
const store = new Corestore(dir)
const reg = new ReleaseRegistry(store)
await reg.ready()
t.teardown(async () => {
await reg.close()
await store.close()
fs.rmSync(dir, { recursive: true, force: true })
})
return reg
}
function makeEntry (device, overrides = {}) {
return {
name: 'demo',
version: '1.0.0',
runtime: 'bare',
treeHash: 'a'.repeat(64),
upgrade: null,
serverKey: b4a.toString(device.publicKey, 'hex'),
deps: [],
publishedAt: '2026-08-25T00:00:00.000Z',
publisherDevicePubkey: b4a.toString(device.publicKey, 'hex'),
publisherChain: 'Y2hhaW4tYnl0ZXM=',
acceptedConflicts: null,
...overrides
}
}
test('a signed entry survives the registry round-trip', async (t) => {
const reg = await openRegistry(t)
const device = crypto.keyPair()
const signed = signEntry(makeEntry(device), device.secretKey)
t.ok(signed.sig, 'entry carries a signature')
t.ok(verifyReleaseEntry(signed).ok, 'verifies before storing')
await reg.add(signed)
// The real hazard: add() reconstructs the entry from a fixed field list, so
// a signed field it drops breaks the signature on the way OUT, not in.
const stored = await reg.get('demo', '1.0.0')
t.is(stored.publisherChain, signed.publisherChain, 'publisherChain carried through add()')
const v = verifyReleaseEntry(stored)
t.ok(v.ok && v.signed, 'still verifies after storage: ' + (v.reason || 'ok'))
})
test('latest() reads through the same check', async (t) => {
const reg = await openRegistry(t)
const device = crypto.keyPair()
await reg.add(signEntry(makeEntry(device), device.secretKey))
const latest = await reg.latest('demo')
t.is(latest.version, '1.0.0')
t.ok(verifyReleaseEntry(latest).ok, 'latest returns a verified entry')
})
test('a tampered entry is refused on read', async (t) => {
const reg = await openRegistry(t)
const device = crypto.keyPair()
// Sign an honest entry, then swap the tree it points at — the substitution
// that matters, since treeHash is what actually gets installed.
const signed = signEntry(makeEntry(device), device.secretKey)
const tampered = { ...signed, treeHash: 'b'.repeat(64) }
await reg.add(tampered)
await t.exception(
() => reg.get('demo', '1.0.0'),
/failed verification/,
'get() refuses it'
)
})
test('an entry signed by a different key is refused', async (t) => {
const device = crypto.keyPair()
const impostor = crypto.keyPair()
// Claims to be from `device`, actually signed by `impostor`.
const forged = signEntry(makeEntry(device), impostor.secretKey)
const v = verifyReleaseEntry(forged)
t.absent(v.ok, 'refused')
t.ok(v.signed, 'recognised as signed')
t.ok(/does not verify/.test(v.reason), v.reason)
})
test('a signed entry naming no device key is refused', (t) => {
const device = crypto.keyPair()
const signed = signEntry(makeEntry(device), device.secretKey)
const v = verifyReleaseEntry({ ...signed, publisherDevicePubkey: null })
t.absent(v.ok, 'refused')
t.ok(/names no publisherDevicePubkey/.test(v.reason), v.reason)
})
test('an UNSIGNED legacy entry still passes (transitional)', async (t) => {
const reg = await openRegistry(t)
const device = crypto.keyPair()
// Exactly what every release published before #144 looks like.
await reg.add(makeEntry(device, { publisherChain: null }))
const stored = await reg.get('demo', '1.0.0')
t.is(stored.sig, null, 'no signature, as before')
const v = verifyReleaseEntry(stored)
t.ok(v.ok, 'accepted')
t.absent(v.signed, 'but reported as unsigned')
})