OverviewFilesCommitsIssuesDocsReleasesPeers

pgh commits / 85ca46c

0.1.140: identityRootHex — anchor names to the ROOT, not the device key
NameRegistry claims bind to `publisher.identityRoot`, documented as "survives device rotation". On a linked device it was written as the DEVICE key — the one key it exists to outlive. All 13 live claims on tabertron are anchored to d92e2f4e4cae… (device) rather than 96b89f8750ff… (root). Cause: consumers reached for `masterPublicKey`, which only the hyperident loader sets. `tryLoadLinkedDevice` — every mirror, and tabertron itself — exposes `rootPubkeyHex` and no master, so the `|| devicePubkey` fallback fired. `pgh identity show` never had the bug (`rootPubkeyHex || publicKeyHex`), so the same machine answered differently depending on which field the caller picked. One accessor now owns the decision: rootPubkeyHex, then masterPublicKey (Buffer or hex), then the device key — the last only for raw/legacy identities, which genuinely are their own root. test/identity-root-anchor.test.js drives the REAL loader through a linked-device fixture, because the bug is in which field that loader populates. Control cases (hyperident shape, raw fallback, null) pin the paths that were already correct. Red before, green after: 13/13. Does NOT repair the existing claims — that needs update-name, which has no RPC yet. Forge org #142; repair is #143. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
85ca46c5b1edabbd199c097d3f2243396646f138
by plankatron <fleeky@gmail.com> · 2026-08-25T14:11:04.000Z

Changed files (3) +144 -1

lib/identity.js +33 -0
……@@ -81,6 +81,39 @@ export async function loadOrCreateIdentity (storagePath) {
8181 }
8282}
8383
84/**
85 * The identity's ROOT pubkey as hex — the anchor that survives device
86 * rotation, and the thing a NameRegistry claim or a release signature should
87 * be bound to.
88 *
89 * Every loader path exposes `rootPubkeyHex`; only the hyperident path also
90 * exposes `masterPublicKey` (as a Buffer or hex string, historically). Callers
91 * that consulted `masterPublicKey` alone silently fell through to the DEVICE
92 * key on a linked device, which is precisely the key the anchor exists to
93 * outlive.
94 *
95 * Falls back to the device pubkey only when there is no root at all (the raw
96 * / legacy identities, which are their own root).
97 *
98 * @param {object|null} identity - as returned by loadOrCreateIdentity
99 * @returns {string|null} 64-char hex, or null if there is no identity
100 */
101export function identityRootHex (identity) {
102 if (!identity) return null
103
104 // Canonical: every loader that HAS a root exposes it here (hyperident sets
105 // it from identity.publicKey, linked-device from the joined payload). This
106 // is the same field `pgh identity show` has always used.
107 if (identity.rootPubkeyHex) return identity.rootPubkeyHex
108
109 // hyperident also carries it as masterPublicKey, Buffer or hex.
110 const master = identity.masterPublicKey
111 if (master) return Buffer.isBuffer(master) ? b4a.toString(master, 'hex') : master
112
113 return identity.publicKeyHex ||
114 (identity.publicKey ? b4a.toString(identity.publicKey, 'hex') : null)
115}
116
84117/**
85118 * Load a linked-device payload written by `pgh device join`. Shape:
86119 * {
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "pgh",
3 "version": "0.1.139",
3 "version": "0.1.140",
44 "description": "pgh \u2014 P2P content-addressed package manager and app launcher",
55 "type": "module",
66 "main": "index.js",
test/identity-root-anchor.test.js +110 -0
……@@ -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
25import test from 'brittle'
26import process from '#node:process'
27import fs from '#node:fs'
28import path from '#node:path'
29import os from '#node:os'
30import crypto from 'hypercore-crypto'
31import b4a from 'b4a'
32
33import { loadOrCreateIdentity, identityRootHex } from '../lib/identity.js'
34
35function mktemp (prefix = 'pgh-root-anchor') {
36 return fs.mkdtempSync(path.join(os.tmpdir(), prefix + '-'))
37}
38
39test('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
78test('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
96test('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
106test('no identity at all', (t) => {
107 t.is(identityRootHex(null), null)
108 t.is(identityRootHex(undefined), null)
109 t.is(identityRootHex({}), null)
110})