| … | … | @@ -0,0 +1,110 @@ |
| 1 | /** |
| 2 | * identityRootHex — the anchor that must survive device rotation. |
| 3 | * |
| 4 | * Bug this pins (forge org #142): NameRegistry claims are supposed to be bound |
| 5 | * to `publisher.identityRoot`, documented as "the publisher's identity root |
| 6 | * pubkey (survives device rotation)" (lib/name-registry.js). The sidecar |
| 7 | * computed it as `masterPublicKey || devicePubkey` in three places |
| 8 | * (identity-info, claim-name, release-name). |
| 9 | * |
| 10 | * `masterPublicKey` is set ONLY on the hyperident path. On a LINKED device — |
| 11 | * which every mirror is, and which tabertron itself is — tryLoadLinkedDevice |
| 12 | * returns `rootPubkeyHex` and never sets `masterPublicKey`, so the fallback |
| 13 | * fired and the anchor was written as the DEVICE key: the exact key it exists |
| 14 | * to outlive. All 13 live claims on tabertron were anchored to d92e2f4e4cae… |
| 15 | * (a device key) rather than 96b89f8750ff… (the root). |
| 16 | * |
| 17 | * `pgh identity show` had it right all along (`identity.rootPubkeyHex || |
| 18 | * identity.publicKeyHex`, lib/commands/identity.js) — the same machine gave |
| 19 | * two different answers depending on which field the caller reached for. |
| 20 | * |
| 21 | * The linked case drives the REAL loader through a fixture file, because the |
| 22 | * bug lives in which field that loader populates, not in the accessor alone. |
| 23 | */ |
| 24 | |
| 25 | import test from 'brittle' |
| 26 | import process from '#node:process' |
| 27 | import fs from '#node:fs' |
| 28 | import path from '#node:path' |
| 29 | import os from '#node:os' |
| 30 | import crypto from 'hypercore-crypto' |
| 31 | import b4a from 'b4a' |
| 32 | |
| 33 | import { loadOrCreateIdentity, identityRootHex } from '../lib/identity.js' |
| 34 | |
| 35 | function mktemp (prefix = 'pgh-root-anchor') { |
| 36 | return fs.mkdtempSync(path.join(os.tmpdir(), prefix + '-')) |
| 37 | } |
| 38 | |
| 39 | test('linked device: the anchor is the ROOT, not the device key', async (t) => { |
| 40 | const dir = mktemp() |
| 41 | t.teardown(() => fs.rmSync(dir, { recursive: true, force: true })) |
| 42 | |
| 43 | // A `pgh device join` payload: this device's own keypair, plus the root it |
| 44 | // was attested to. Distinct keys — that separation is the whole point. |
| 45 | const device = crypto.keyPair() |
| 46 | const root = crypto.keyPair() |
| 47 | const rootHex = b4a.toString(root.publicKey, 'hex') |
| 48 | const deviceHex = b4a.toString(device.publicKey, 'hex') |
| 49 | |
| 50 | const linkedFile = path.join(dir, 'linked-device.json') |
| 51 | fs.writeFileSync(linkedFile, JSON.stringify({ |
| 52 | version: 1, |
| 53 | label: 'test-device', |
| 54 | rootPubkeyHex: rootHex, |
| 55 | devicePublicKeyHex: deviceHex, |
| 56 | deviceSecretKeyHex: b4a.toString(device.secretKey, 'hex'), |
| 57 | chainB64: b4a.toString(b4a.from('not-verified-here'), 'base64'), |
| 58 | createdAt: 1787600000000 |
| 59 | })) |
| 60 | |
| 61 | const prevLinked = process.env.PGH_LINKED_DEVICE_FILE || '' |
| 62 | process.env.PGH_LINKED_DEVICE_FILE = linkedFile |
| 63 | t.teardown(() => { process.env.PGH_LINKED_DEVICE_FILE = prevLinked }) |
| 64 | |
| 65 | const id = await loadOrCreateIdentity(dir) |
| 66 | |
| 67 | // Preconditions — we are exercising the shape the bug lives in. |
| 68 | t.is(id.source, 'linked', 'loader took the linked-device path') |
| 69 | t.is(id.rootPubkeyHex, rootHex, 'loader exposes the root') |
| 70 | t.is(id.publicKeyHex, deviceHex, 'loader exposes the device key') |
| 71 | t.absent(id.masterPublicKey, 'linked path sets no masterPublicKey — the trap') |
| 72 | |
| 73 | // The assertion the three sidecar call sites got wrong. |
| 74 | t.is(identityRootHex(id), rootHex, 'anchor is the root pubkey') |
| 75 | t.not(identityRootHex(id), deviceHex, 'anchor is NOT the device pubkey') |
| 76 | }) |
| 77 | |
| 78 | test('hyperident shape: masterPublicKey still wins (control, must not regress)', (t) => { |
| 79 | const rootHex = 'a'.repeat(64) |
| 80 | const deviceHex = 'b'.repeat(64) |
| 81 | |
| 82 | t.is( |
| 83 | identityRootHex({ masterPublicKey: rootHex, rootPubkeyHex: rootHex, publicKeyHex: deviceHex }), |
| 84 | rootHex, |
| 85 | 'hex masterPublicKey' |
| 86 | ) |
| 87 | |
| 88 | // Historically it could arrive as a Buffer. |
| 89 | t.is( |
| 90 | identityRootHex({ masterPublicKey: b4a.from(rootHex, 'hex'), publicKeyHex: deviceHex }), |
| 91 | rootHex, |
| 92 | 'Buffer masterPublicKey is hex-encoded' |
| 93 | ) |
| 94 | }) |
| 95 | |
| 96 | test('raw / legacy identity: falls back to the device key, which IS its root', (t) => { |
| 97 | const deviceHex = 'c'.repeat(64) |
| 98 | t.is(identityRootHex({ publicKeyHex: deviceHex, source: 'raw' }), deviceHex) |
| 99 | t.is( |
| 100 | identityRootHex({ publicKey: b4a.from(deviceHex, 'hex'), source: 'legacy' }), |
| 101 | deviceHex, |
| 102 | 'derives hex from the raw pubkey when no hex field is present' |
| 103 | ) |
| 104 | }) |
| 105 | |
| 106 | test('no identity at all', (t) => { |
| 107 | t.is(identityRootHex(null), null) |
| 108 | t.is(identityRootHex(undefined), null) |
| 109 | t.is(identityRootHex({}), null) |
| 110 | }) |