OverviewFilesCommitsIssuesDocsReleasesPeers

pgh / test / release-signing.test.js

147 lines · javascript ref:
/**
 * 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')
})