/**
* 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)
})