OverviewFilesCommitsIssuesDocsReleasesPeers

pgh commits / 9d0af13

0.1.144: reanchor-name — repair a claim anchored to a device key
#142 fixed what NEW claims record and could not touch the 13 already in the registry. #143 said update-name would repair them; that was wrong twice over. apply only ever writes registryKey/serverKey/description, so identityRoot is not updatable at all — and after #142 the owner cannot even invoke it, because update-name and release-name both authorise by comparing op.signedBy against claim.publisher.identityRoot. The identity now correctly reports its ROOT while the legacy claims hold a DEVICE key, so they stopped matching. Those claims became un-updatable and un-releasable by their own owner. That regression is what this fixes. The way out exists because the stale anchor is a device key: this device holds its secret, so it can prove control of the very key being replaced, while its keet chain proves which root that device belongs to. verifyReanchorOp requires all of: signed by device D, chain verifies to root R, chain's device === op.devicePubkey === D, claim.publisher.identityRoot === D, and op.newRoot === R. So a name cannot be stolen (only the anchoring key can sign) and cannot be pointed at an arbitrary identity (only the attested root). It is a separate verifier because verifySignedOp requires the chain's root to equal the claim's anchor, which is false by construction here. A claim already anchored to a root fails the anchor===device test and is left alone, so `--all` is safe to re-run. apply also moves the by-publisher reverse index from the old anchor to the new — it is keyed by the anchor, so leaving it would make listByPublisher(root) miss the name while the old device key kept reporting it. `pgh names reanchor <name> | --all`, with the sweep skipping rather than aborting on a name this device does not anchor. Test-first this time: the test was written against a method that did not exist. 6/6 here, including the payoff case — update-name refused while device-anchored, authorised immediately after. 331/331, 941/941 across the suite. Forge org #142. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
9d0af130d8b24c4e77f81a59b95827f9c943d193
by plankatron <fleeky@gmail.com> · 2026-08-25T16:50:35.000Z

Changed files (4) +324 -1

lib/commands/names.js +29 -0
……@@ -29,6 +29,7 @@ export const usage = `pgh names / claim / release — NameRegistry (sidecar-owne
2929 pgh names resolve <name>
3030 pgh names update <name> [--registry-key <hex>] [--server-key <hex>] [--description "..."]
3131 Re-point a name you own (rotate registry/server key)
32 pgh names reanchor <name> | --all Repair a claim anchored to a device key (org #142)
3233 pgh names info
3334 pgh names subscribe <registryKeyHex> Replicate another publisher's NameRegistry (read-only)
3435 pgh names subscriptions List own + subscribed name registries`
……@@ -96,6 +97,7 @@ export async function run (_client, args) {
9697 ' resolve <name>',
9798 ' update <name> [--registry-key <hex>] [--server-key <hex>] [--description "..."]',
9899 ' Re-point a name you own',
100 ' reanchor <name> | --all Move a pre-#142 claim from its device key to the identity root',
99101 ' info Print registry key + writable status',
100102 ' subscribe <registryKeyHex> Replicate another publisher\'s NameRegistry (read-only)',
101103 ' subscriptions List own + subscribed name registries',
……@@ -162,6 +164,33 @@ export async function run (_client, args) {
162164 })
163165 }
164166
167 if (sub === 'reanchor') {
168 const all = hasFlag(args, '--all')
169 const name = args[1] && args[1][0] !== '-' ? args[1] : null
170 if (!name && !all) {
171 throw new Error('usage: pgh names reanchor <name> | pgh names reanchor --all')
172 }
173 return withClient(async (c) => {
174 const targets = name ? [name] : (await c.call('list-names', { own: true })).map(e => e.name)
175 const out = []
176 for (const n of targets) {
177 try {
178 const res = await c.call('reanchor-name', { name: n })
179 out.push({
180 name: n,
181 status: res.alreadyAnchored ? 'already anchored' : 're-anchored',
182 identityRoot: res.entry.publisher.identityRoot
183 })
184 } catch (err) {
185 // One name that cannot be repaired must not abort the sweep — the
186 // registry may hold claims from other devices entirely.
187 out.push({ name: n, status: 'skipped: ' + err.message })
188 }
189 }
190 return out
191 })
192 }
193
165194 if (sub === 'subscribe') {
166195 const key = args[1]
167196 if (!key || key.length !== 64) {
lib/name-registry.js +110 -0
……@@ -55,6 +55,7 @@ import IdentityKey from 'keet-identity-key'
5555export const OP_CLAIM_NAME = 'claim-name'
5656export const OP_UPDATE_NAME = 'update-name'
5757export const OP_RELEASE_NAME = 'release-name'
58export const OP_REANCHOR_NAME = 'reanchor-name'
5859export const OP_ADD_WRITER = 'add-writer'
5960export const OP_REMOVE_WRITER = 'remove-writer'
6061
……@@ -153,6 +154,33 @@ export class NameRegistry {
153154 await view.put('claim:' + op.name, updated)
154155 }
155156
157 // Re-anchor a claim whose identityRoot still holds a DEVICE key
158 // (written before org #142). Authorised by the key being replaced:
159 // the stale anchor IS this device, so signing proves control of it,
160 // and the chain proves which root that device belongs to. A claim
161 // already anchored to a root fails the anchor===device test and is
162 // left alone. See verifyReanchorOp.
163 if (op.type === OP_REANCHOR_NAME) {
164 if (!op.name) continue
165 const existing = await view.get('claim:' + op.name)
166 if (!existing) continue
167 const claim = existing.value
168 const newRoot = verifyReanchorOp(op, claim)
169 if (!newRoot) continue
170
171 const oldRoot = claim.publisher.identityRoot
172 await view.put('claim:' + op.name, {
173 ...claim,
174 publisher: { ...claim.publisher, identityRoot: newRoot },
175 updatedAt: Date.now()
176 })
177 // The reverse index is keyed by the anchor, so it has to move with
178 // it — otherwise `listByPublisher(root)` misses the name and
179 // `listByPublisher(oldDeviceKey)` keeps reporting it.
180 await view.del('by-publisher:' + oldRoot + ':' + op.name)
181 await view.put('by-publisher:' + newRoot + ':' + op.name, { name: op.name })
182 }
183
156184 if (op.type === OP_RELEASE_NAME) {
157185 if (!op.name || !op.signedBy) continue
158186 const existing = await view.get('claim:' + op.name)
……@@ -243,6 +271,33 @@ export class NameRegistry {
243271 await this.update()
244272 }
245273
274 /**
275 * Move a claim's anchor from the device key it was mistakenly written with
276 * (org #142) to the identity root that attests that device.
277 *
278 * Only the device currently named as the anchor can do this, and only to
279 * its own chain's root — see verifyReanchorOp. No-op on a claim that is
280 * already root-anchored.
281 */
282 async reanchor (name, signer) {
283 if (!this._ready) await this.ready()
284 if (!name) throw new Error('reanchor requires { name }')
285 if (!signer || typeof signer === 'string' || !signer.identityRoot ||
286 !signer.devicePubkey || !signer.deviceSecretKey || !signer.chain) {
287 throw new Error('reanchor requires a signer with { identityRoot, devicePubkey, deviceSecretKey, chain } — there is no unsigned form')
288 }
289 const op = signOp({
290 type: OP_REANCHOR_NAME,
291 v: OP_VERSION_SIGNED,
292 name,
293 newRoot: signer.identityRoot,
294 devicePubkey: signer.devicePubkey,
295 chain: signer.chain
296 }, signer.deviceSecretKey)
297 await this._base.append(b4a.from(JSON.stringify(op)))
298 await this.update()
299 }
300
246301 async release (name, signer) {
247302 if (!this._ready) await this.ready()
248303 if (!name) throw new Error('release requires { name }')
……@@ -435,6 +490,61 @@ export function verifySignedOp (op, claim) {
435490 }
436491}
437492
493/**
494 * Verify a re-anchor op against the claim it targets.
495 *
496 * Distinct from verifySignedOp because that one requires the chain's root to
497 * equal the claim's anchor — which is false by construction here: the whole
498 * point is that the anchor is a DEVICE key, not a root.
499 *
500 * Fail-closed; returns the new root on success, null otherwise.
501 *
502 * @returns {string|null} the verified root (hex), or null
503 */
504export function verifyReanchorOp (op, claim) {
505 if (!op || !claim || !claim.publisher) return null
506 if (!(op.v >= OP_VERSION_SIGNED)) return null
507 if (typeof op.devicePubkey !== 'string' || typeof op.chain !== 'string' ||
508 typeof op.sig !== 'string' || typeof op.newRoot !== 'string') {
509 return null
510 }
511
512 // The stale anchor must BE this device. This is the authorisation: the
513 // signature below proves control of the very key being replaced. It also
514 // makes the op a no-op on an already-root-anchored claim, since a root is
515 // not a device key anyone can sign with here.
516 if (claim.publisher.identityRoot !== op.devicePubkey) return null
517
518 let verified
519 try {
520 const proof = b4a.from(op.chain, 'base64')
521 if (!proof || proof.length === 0) return null
522 verified = IdentityKey.verify(proof)
523 } catch {
524 return null
525 }
526 if (!verified || !verified.identityPublicKey || !verified.devicePublicKey) return null
527
528 // The chain must describe THIS device, and the op may only name the root
529 // that chain actually attests — otherwise a name could be re-pointed at an
530 // arbitrary identity.
531 if (b4a.toString(verified.devicePublicKey, 'hex') !== op.devicePubkey) return null
532 const root = b4a.toString(verified.identityPublicKey, 'hex')
533 if (root !== op.newRoot) return null
534
535 try {
536 const sig = b4a.from(op.sig, 'hex')
537 if (sig.length !== sodium.crypto_sign_BYTES) return null
538 const devicePub = b4a.from(op.devicePubkey, 'hex')
539 if (devicePub.length !== sodium.crypto_sign_PUBLICKEYBYTES) return null
540 if (!sodium.crypto_sign_verify_detached(sig, canonicalOpBytes(op), devicePub)) return null
541 } catch {
542 return null
543 }
544
545 return root
546}
547
438548/**
439549 * Authorisation gate shared by update-name and release-name.
440550 *
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "pgh",
3 "version": "0.1.143",
3 "version": "0.1.144",
44 "description": "pgh \u2014 P2P content-addressed package manager and app launcher",
55 "type": "module",
66 "main": "index.js",
test/name-reanchor.test.js +184 -0
……@@ -0,0 +1,184 @@
1/**
2 * Re-anchoring a device-anchored claim to its identity root (forge org #142).
3 *
4 * #142 fixed what NEW claims record. It could not fix the claims already in
5 * the registry, and #143's update-name turned out to be the wrong tool twice
6 * over: apply only ever writes registryKey/serverKey/description, so
7 * identityRoot is not updatable at all — and after #142 the owner cannot even
8 * invoke it, because both update-name and release-name authorise by comparing
9 * op.signedBy against claim.publisher.identityRoot. The identity now correctly
10 * reports its ROOT while the legacy claims are anchored to a DEVICE key, so
11 * they no longer match. The 13 live claims became un-updatable and
12 * un-releasable by their own owner.
13 *
14 * The way out is available precisely because the stale anchor is a device key:
15 * this device holds that key's secret, so it can prove control of the current
16 * anchor, while its keet chain proves that device belongs to the root. A
17 * re-anchor is therefore authorised by the key being replaced.
18 *
19 * The rule, and why it cannot be abused:
20 * - signed by device D, chain verifies to root R
21 * - chain's device === op.devicePubkey === D
22 * - claim.publisher.identityRoot === D (the anchor IS this device)
23 * - op.newRoot === R (only the attested root)
24 * so a name cannot be stolen (only the currently-anchoring key can sign) and
25 * cannot be pointed at an arbitrary root (it must be the chain's).
26 */
27
28import test from 'brittle'
29import fs from '#node:fs'
30import path from '#node:path'
31import os from '#node:os'
32import Corestore from 'corestore'
33import IdentityKey from 'keet-identity-key'
34import crypto from 'hypercore-crypto'
35import b4a from 'b4a'
36
37import { NameRegistry } from '../lib/name-registry.js'
38
39function mktemp () {
40 return fs.mkdtempSync(path.join(os.tmpdir(), 'pgh-reanchor-'))
41}
42
43async function makeIdentity () {
44 const mnemonic = IdentityKey.generateMnemonic()
45 const identityKey = await IdentityKey.from({ mnemonic })
46 const device = crypto.keyPair()
47 const proof = await identityKey.bootstrap(device.publicKey)
48 return {
49 identityRoot: b4a.toString(identityKey.identityKeyPair.publicKey, 'hex'),
50 devicePubkey: b4a.toString(device.publicKey, 'hex'),
51 deviceSecretKey: device.secretKey,
52 chain: b4a.toString(proof, 'base64')
53 }
54}
55
56async function openRegistry (t) {
57 const dir = mktemp()
58 const store = new Corestore(dir)
59 const reg = new NameRegistry(store)
60 await reg.ready()
61 t.teardown(async () => {
62 await reg.close()
63 await store.close()
64 fs.rmSync(dir, { recursive: true, force: true })
65 })
66 return reg
67}
68
69// A claim in the shape the pre-#142 sidecar wrote: identityRoot holds the
70// DEVICE key, because masterPublicKey was unset on a linked device.
71async function claimLegacy (reg, name, id) {
72 await reg.claim({
73 name,
74 publisher: {
75 devicePubkey: id.devicePubkey,
76 registryKey: 'a'.repeat(64),
77 serverKey: id.devicePubkey,
78 identityRoot: id.devicePubkey
79 },
80 description: 'legacy'
81 })
82}
83
84test('the owning device re-anchors its own claim to the root', async (t) => {
85 const reg = await openRegistry(t)
86 const id = await makeIdentity()
87 await claimLegacy(reg, 'app', id)
88
89 const before = await reg.resolve('app')
90 t.is(before.publisher.identityRoot, id.devicePubkey, 'starts anchored to the device')
91
92 await reg.reanchor('app', id)
93
94 const after = await reg.resolve('app')
95 t.is(after.publisher.identityRoot, id.identityRoot, 'now anchored to the root')
96 t.is(after.publisher.devicePubkey, id.devicePubkey, 'device record untouched')
97})
98
99test('the by-publisher index moves with the anchor', async (t) => {
100 const reg = await openRegistry(t)
101 const id = await makeIdentity()
102 await claimLegacy(reg, 'app', id)
103
104 await reg.reanchor('app', id)
105
106 const byRoot = await reg.listByPublisher(id.identityRoot)
107 t.is(byRoot.length, 1, 'listed under the root')
108 t.is(byRoot[0].name, 'app')
109
110 const byDevice = await reg.listByPublisher(id.devicePubkey)
111 t.is(byDevice.length, 0, 'no longer listed under the device')
112})
113
114test('a stranger cannot re-anchor someone else\'s claim', async (t) => {
115 const reg = await openRegistry(t)
116 const owner = await makeIdentity()
117 const attacker = await makeIdentity()
118 await claimLegacy(reg, 'app', owner)
119
120 // The attacker signs with its own device+chain. The anchor is not its
121 // device, so it has nothing to prove control of.
122 await reg.reanchor('app', attacker)
123
124 const after = await reg.resolve('app')
125 t.is(after.publisher.identityRoot, owner.devicePubkey, 'claim untouched')
126})
127
128test('re-anchoring to a root the chain does not attest is refused', async (t) => {
129 const reg = await openRegistry(t)
130 const id = await makeIdentity()
131 const other = await makeIdentity()
132 await claimLegacy(reg, 'app', id)
133
134 // Correct device and a valid chain, but pointing the name at a root that
135 // chain says nothing about.
136 await reg.reanchor('app', { ...id, identityRoot: other.identityRoot })
137
138 const after = await reg.resolve('app')
139 t.is(after.publisher.identityRoot, id.devicePubkey, 'claim untouched')
140})
141
142test('a claim already anchored to a root is not re-anchored again', async (t) => {
143 const reg = await openRegistry(t)
144 const id = await makeIdentity()
145
146 // A post-#142 claim: already correct.
147 await reg.claim({
148 name: 'app',
149 publisher: {
150 devicePubkey: id.devicePubkey,
151 registryKey: 'a'.repeat(64),
152 serverKey: id.devicePubkey,
153 identityRoot: id.identityRoot
154 }
155 })
156
157 await reg.reanchor('app', id)
158
159 const after = await reg.resolve('app')
160 t.is(after.publisher.identityRoot, id.identityRoot, 'unchanged and still correct')
161})
162
163test('after re-anchoring, normal update-name works again — the payoff', async (t) => {
164 const reg = await openRegistry(t)
165 const id = await makeIdentity()
166 await claimLegacy(reg, 'app', id)
167
168 // Blocked before: signedBy is the root, the anchor is the device.
169 await reg.updateName('app', { registryKey: 'b'.repeat(64) }, id)
170 t.is(
171 (await reg.resolve('app')).publisher.registryKey,
172 'a'.repeat(64),
173 'update refused while the claim is device-anchored'
174 )
175
176 await reg.reanchor('app', id)
177
178 await reg.updateName('app', { registryKey: 'b'.repeat(64) }, id)
179 t.is(
180 (await reg.resolve('app')).publisher.registryKey,
181 'b'.repeat(64),
182 'update authorised once the anchor is the root'
183 )
184})