OverviewFilesCommitsIssuesDocsReleasesPeers

pgh / test / identity-root-anchor.test.js

111 lines · javascript ref:
/**
 * identityRootHex — the anchor that must survive device rotation.
 *
 * Bug this pins (forge org #142): NameRegistry claims are supposed to be bound
 * to `publisher.identityRoot`, documented as "the publisher's identity root
 * pubkey (survives device rotation)" (lib/name-registry.js). The sidecar
 * computed it as `masterPublicKey || devicePubkey` in three places
 * (identity-info, claim-name, release-name).
 *
 * `masterPublicKey` is set ONLY on the hyperident path. On a LINKED device —
 * which every mirror is, and which tabertron itself is — tryLoadLinkedDevice
 * returns `rootPubkeyHex` and never sets `masterPublicKey`, so the fallback
 * fired and the anchor was written as the DEVICE key: the exact key it exists
 * to outlive. All 13 live claims on tabertron were anchored to d92e2f4e4cae…
 * (a device key) rather than 96b89f8750ff… (the root).
 *
 * `pgh identity show` had it right all along (`identity.rootPubkeyHex ||
 * identity.publicKeyHex`, lib/commands/identity.js) — the same machine gave
 * two different answers depending on which field the caller reached for.
 *
 * The linked case drives the REAL loader through a fixture file, because the
 * bug lives in which field that loader populates, not in the accessor alone.
 */

import test from 'brittle'
import process from '#node:process'
import fs from '#node:fs'
import path from '#node:path'
import os from '#node:os'
import crypto from 'hypercore-crypto'
import b4a from 'b4a'

import { loadOrCreateIdentity, identityRootHex } from '../lib/identity.js'

function mktemp (prefix = 'pgh-root-anchor') {
  return fs.mkdtempSync(path.join(os.tmpdir(), prefix + '-'))
}

test('linked device: the anchor is the ROOT, not the device key', async (t) => {
  const dir = mktemp()
  t.teardown(() => fs.rmSync(dir, { recursive: true, force: true }))

  // A `pgh device join` payload: this device's own keypair, plus the root it
  // was attested to. Distinct keys — that separation is the whole point.
  const device = crypto.keyPair()
  const root = crypto.keyPair()
  const rootHex = b4a.toString(root.publicKey, 'hex')
  const deviceHex = b4a.toString(device.publicKey, 'hex')

  const linkedFile = path.join(dir, 'linked-device.json')
  fs.writeFileSync(linkedFile, JSON.stringify({
    version: 1,
    label: 'test-device',
    rootPubkeyHex: rootHex,
    devicePublicKeyHex: deviceHex,
    deviceSecretKeyHex: b4a.toString(device.secretKey, 'hex'),
    chainB64: b4a.toString(b4a.from('not-verified-here'), 'base64'),
    createdAt: 1787600000000
  }))

  const prevLinked = process.env.PGH_LINKED_DEVICE_FILE || ''
  process.env.PGH_LINKED_DEVICE_FILE = linkedFile
  t.teardown(() => { process.env.PGH_LINKED_DEVICE_FILE = prevLinked })

  const id = await loadOrCreateIdentity(dir)

  // Preconditions — we are exercising the shape the bug lives in.
  t.is(id.source, 'linked', 'loader took the linked-device path')
  t.is(id.rootPubkeyHex, rootHex, 'loader exposes the root')
  t.is(id.publicKeyHex, deviceHex, 'loader exposes the device key')
  t.absent(id.masterPublicKey, 'linked path sets no masterPublicKey — the trap')

  // The assertion the three sidecar call sites got wrong.
  t.is(identityRootHex(id), rootHex, 'anchor is the root pubkey')
  t.not(identityRootHex(id), deviceHex, 'anchor is NOT the device pubkey')
})

test('hyperident shape: masterPublicKey still wins (control, must not regress)', (t) => {
  const rootHex = 'a'.repeat(64)
  const deviceHex = 'b'.repeat(64)

  t.is(
    identityRootHex({ masterPublicKey: rootHex, rootPubkeyHex: rootHex, publicKeyHex: deviceHex }),
    rootHex,
    'hex masterPublicKey'
  )

  // Historically it could arrive as a Buffer.
  t.is(
    identityRootHex({ masterPublicKey: b4a.from(rootHex, 'hex'), publicKeyHex: deviceHex }),
    rootHex,
    'Buffer masterPublicKey is hex-encoded'
  )
})

test('raw / legacy identity: falls back to the device key, which IS its root', (t) => {
  const deviceHex = 'c'.repeat(64)
  t.is(identityRootHex({ publicKeyHex: deviceHex, source: 'raw' }), deviceHex)
  t.is(
    identityRootHex({ publicKey: b4a.from(deviceHex, 'hex'), source: 'legacy' }),
    deviceHex,
    'derives hex from the raw pubkey when no hex field is present'
  )
})

test('no identity at all', (t) => {
  t.is(identityRootHex(null), null)
  t.is(identityRootHex(undefined), null)
  t.is(identityRootHex({}), null)
})