OverviewFilesCommitsIssuesDocsReleasesPeers

pgh commits / c464aae

0.1.141: sign update-name / release-name, and expose `pgh names update`
Both ops were authorised by a bare string compare — if (existing.value.publisher.identityRoot !== op.signedBy) continue `signedBy` is just a field in the op, so any registry writer could assert any value and re-point or tombstone a name it did not own. The writer set is invite-gated, so a closed set rather than an open hole, but not a check. A v2 op is now signed by the DEVICE key and carries the keet attestation chain proving that device belongs to the claim's identityRoot — the shape pear-git-v3 already authenticates envelopes with (lib/auth/keet-verifier.js). It is device-signature-plus-chain rather than a root signature because a linked device never holds the root secret; it holds its device key and a proof. apply() verifies, fail-closed: op.signedBy is the claim's root; the chain verifies and resolves to that root; the chain's device is the one the op names; that device signed these exact canonical bytes. keet-identity-key is imported unconditionally and never injected — apply must reach the same verdict on every peer, and a registry that could not verify would reject ops its peers accept and diverge on identical history. Legacy (no `v`) ops still apply. release-name has been callable, so tombstones may exist in real logs, and rejecting them on reindex would resurrect released names. Writers only ever emit v2; closing the legacy path is a follow-up once the fleet is on v2 (in-band, as pgv3's schema guard does it). Also fixes aliasing in the same branch: `{ ...existing.value }` is shallow, so assigning through updated.publisher mutated the object read out of the bee. `pgh names update <name> [--registry-key] [--server-key] [--description]` is the CLI that repairs a mis-anchored or stale claim. Proof: test/name-registry-signed-ops.test.js. Verified against the OLD auth by forcing the legacy branch — the forgery, tamper and stranger-release cases all fail there and pass after; the owner, legacy-compat and call-site cases are unaffected in both, as they must be. 23/23, 95/95 across the pgh suite. Forge org #143. Repairs the claims from #142. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
c464aae15e331f648f3e8eba944c39c718462241
by plankatron <fleeky@gmail.com> · 2026-08-25T14:33:02.000Z

Changed files (4) +384 -12

lib/commands/names.js +27 -0
……@@ -27,6 +27,8 @@ export const usage = `pgh names / claim / release — NameRegistry (sidecar-owne
2727 pgh release <name> Tombstone a name you own
2828 pgh names list [--publisher <id>] [--include-released]
2929 pgh names resolve <name>
30 pgh names update <name> [--registry-key <hex>] [--server-key <hex>] [--description "..."]
31 Re-point a name you own (rotate registry/server key)
3032 pgh names info
3133 pgh names subscribe <registryKeyHex> Replicate another publisher's NameRegistry (read-only)
3234 pgh names subscriptions List own + subscribed name registries`
……@@ -92,6 +94,8 @@ export async function run (_client, args) {
9294 'usage: pgh names <subcommand>',
9395 ' list [--publisher <identityRoot>] [--include-released]',
9496 ' resolve <name>',
97 ' update <name> [--registry-key <hex>] [--server-key <hex>] [--description "..."]',
98 ' Re-point a name you own',
9599 ' info Print registry key + writable status',
96100 ' subscribe <registryKeyHex> Replicate another publisher\'s NameRegistry (read-only)',
97101 ' subscriptions List own + subscribed name registries',
……@@ -135,6 +139,29 @@ export async function run (_client, args) {
135139 })
136140 }
137141
142 if (sub === 'update') {
143 const name = args[1]
144 if (!name) {
145 throw new Error('usage: pgh names update <name> [--registry-key <hex>] [--server-key <hex>] [--description "..."]')
146 }
147 const registryKey = argVal(args, '--registry-key')
148 const serverKey = argVal(args, '--server-key')
149 const description = argVal(args, '--description')
150 if (!registryKey && !serverKey && description === null) {
151 throw new Error('nothing to update: pass --registry-key, --server-key and/or --description')
152 }
153 for (const [flag, val] of [['--registry-key', registryKey], ['--server-key', serverKey]]) {
154 if (val && val.length !== 64) throw new Error(flag + ' must be 64-char hex')
155 }
156 return withClient(async (c) => {
157 const params = { name }
158 if (registryKey) params.registryKey = registryKey
159 if (serverKey) params.serverKey = serverKey
160 if (description !== null) params.description = description
161 return await c.call('update-name', params)
162 })
163 }
164
138165 if (sub === 'subscribe') {
139166 const key = args[1]
140167 if (!key || key.length !== 64) {
lib/name-registry.js +174 -11
……@@ -45,6 +45,12 @@
4545import Autobase from 'autobase'
4646import Hyperbee from 'hyperbee'
4747import b4a from 'b4a'
48import sodium from 'sodium-native'
49// Imported UNCONDITIONALLY, and never injected. apply() must reach the same
50// verdict on every peer replaying the same log — a registry that could not
51// verify a chain would reject ops its peers accept, and the two views would
52// diverge on identical history. Optional verification is not an option here.
53import IdentityKey from 'keet-identity-key'
4854
4955export const OP_CLAIM_NAME = 'claim-name'
5056export const OP_UPDATE_NAME = 'update-name'
……@@ -52,6 +58,18 @@ export const OP_RELEASE_NAME = 'release-name'
5258export const OP_ADD_WRITER = 'add-writer'
5359export const OP_REMOVE_WRITER = 'remove-writer'
5460
61// Op version. Absent/1 = the legacy unsigned form, authorised by a bare string
62// compare on `signedBy`. 2 = device-signed with an attestation chain back to
63// the claim's identityRoot.
64//
65// Legacy ops are still accepted so that replaying EXISTING history reaches the
66// same view it always did — `release-name` has been callable, so tombstones may
67// already be in the log, and rejecting them on reindex would resurrect released
68// names. Writers only ever emit v2. Closing the legacy path is a follow-up once
69// the fleet is known to be on v2 (same in-band pattern as pgv3's schema guard).
70export const OP_VERSION_SIGNED = 2
71
72
5573export class NameRegistry {
5674 /**
5775 * @param {Corestore} corestore
……@@ -115,10 +133,17 @@ export class NameRegistry {
115133 if (!op.name || !op.signedBy) continue
116134 const existing = await view.get('claim:' + op.name)
117135 if (!existing) continue
118 // Auth: op.signedBy must match the existing claim's identityRoot
119 if (existing.value.publisher.identityRoot !== op.signedBy) continue
120
121 const updated = { ...existing.value }
136 // v2: device signature + chain back to the claim's root.
137 // Legacy: the original string compare. See OP_VERSION_SIGNED.
138 if (!opAuthorised(op, existing.value)) continue
139
140 // Deep-copy `publisher`: `{ ...existing.value }` is shallow, so
141 // assigning through updated.publisher mutated the object read out of
142 // the bee rather than a copy of it.
143 const updated = {
144 ...existing.value,
145 publisher: { ...existing.value.publisher }
146 }
122147 if (op.updates && typeof op.updates === 'object') {
123148 if (op.updates.registryKey) updated.publisher.registryKey = op.updates.registryKey
124149 if (op.updates.serverKey) updated.publisher.serverKey = op.updates.serverKey
……@@ -132,7 +157,7 @@ export class NameRegistry {
132157 if (!op.name || !op.signedBy) continue
133158 const existing = await view.get('claim:' + op.name)
134159 if (!existing) continue
135 if (existing.value.publisher.identityRoot !== op.signedBy) continue
160 if (!opAuthorised(op, existing.value)) continue
136161 // Don't delete the claim; tombstone allows audit + reclaim semantics
137162 await view.put('tombstone:' + op.name, {
138163 releasedAt: Date.now(),
……@@ -210,18 +235,18 @@ export class NameRegistry {
210235 await this.update()
211236 }
212237
213 async updateName (name, updates, signedBy) {
238 async updateName (name, updates, signer) {
214239 if (!this._ready) await this.ready()
215 if (!name || !signedBy) throw new Error('updateName requires { name, signedBy }')
216 const op = { type: OP_UPDATE_NAME, name, updates: updates || {}, signedBy }
240 if (!name) throw new Error('updateName requires { name }')
241 const op = buildAuthOp(OP_UPDATE_NAME, { name, updates: updates || {} }, signer)
217242 await this._base.append(b4a.from(JSON.stringify(op)))
218243 await this.update()
219244 }
220245
221 async release (name, signedBy) {
246 async release (name, signer) {
222247 if (!this._ready) await this.ready()
223 if (!name || !signedBy) throw new Error('release requires { name, signedBy }')
224 const op = { type: OP_RELEASE_NAME, name, signedBy }
248 if (!name) throw new Error('release requires { name }')
249 const op = buildAuthOp(OP_RELEASE_NAME, { name }, signer)
225250 await this._base.append(b4a.from(JSON.stringify(op)))
226251 await this.update()
227252 }
……@@ -283,3 +308,141 @@ export class NameRegistry {
283308 return this._base ? this._base.discoveryKey : null
284309 }
285310}
311
312// ── Signed ops (v2) ────────────────────────────────────────────
313//
314// A name is owned by an identity ROOT, but the root key is not available to
315// sign with: a linked device holds only its own device key plus a keet proof
316// that the root attested it. So the op is signed by the DEVICE and carries the
317// chain, exactly as pear-git-v3 authenticates envelopes
318// (lib/auth/keet-verifier.js). Verification resolves device -> root and then
319// checks that root against the claim.
320
321/**
322 * Build an authorised update/release op.
323 *
324 * `signer` is the identity acting: `{ identityRoot, devicePubkey,
325 * deviceSecretKey, chain }` — exactly the fields loadOrCreateIdentity already
326 * returns. Produces a v2 signed op.
327 *
328 * A bare string is accepted as the legacy `signedBy` and produces an UNSIGNED
329 * op. That path exists only so callers not yet migrated keep working; it will
330 * stop being authorised when the legacy gate closes.
331 */
332export function buildAuthOp (type, fields, signer) {
333 if (typeof signer === 'string') {
334 if (!signer) throw new Error(type + ' requires a signer')
335 return { type, ...fields, signedBy: signer }
336 }
337
338 if (!signer || !signer.identityRoot) {
339 throw new Error(type + ' requires a signer with { identityRoot, devicePubkey, deviceSecretKey, chain }')
340 }
341 const { identityRoot, devicePubkey, deviceSecretKey, chain } = signer
342 if (!devicePubkey || !deviceSecretKey || !chain) {
343 // Refuse rather than silently emitting an op that apply will drop — a
344 // dropped op is indistinguishable from replication lag at the call site.
345 throw new Error(
346 type + ': signer is missing ' +
347 [!devicePubkey && 'devicePubkey', !deviceSecretKey && 'deviceSecretKey', !chain && 'chain']
348 .filter(Boolean).join(', ') +
349 ' (an identity with no attestation chain cannot prove it owns a name)'
350 )
351 }
352
353 return signOp({
354 type,
355 v: OP_VERSION_SIGNED,
356 ...fields,
357 signedBy: identityRoot,
358 devicePubkey,
359 chain
360 }, deviceSecretKey)
361}
362
363/**
364 * Canonical bytes an op's signature covers: every field except `sig`, keys
365 * sorted, JSON. Same shape as the release registry's canonicalBytes.
366 */
367export function canonicalOpBytes (op) {
368 const { sig, ...rest } = op // eslint-disable-line no-unused-vars
369 const ordered = {}
370 for (const k of Object.keys(rest).sort()) ordered[k] = rest[k]
371 return b4a.from(JSON.stringify(ordered))
372}
373
374/**
375 * Sign an op with a device secret key, returning a copy carrying `sig`.
376 */
377export function signOp (op, deviceSecretKey) {
378 const sig = b4a.alloc(sodium.crypto_sign_BYTES)
379 const secret = b4a.isBuffer(deviceSecretKey)
380 ? deviceSecretKey
381 : b4a.from(deviceSecretKey, 'hex')
382 sodium.crypto_sign_detached(sig, canonicalOpBytes(op), secret)
383 return { ...op, sig: b4a.toString(sig, 'hex') }
384}
385
386/**
387 * Is this op authorised to act on `claim`?
388 *
389 * Returns true only when the op is signed by a device that the claim's
390 * identityRoot has attested. Fail-closed: any malformed field, any failed
391 * check, any thrown verifier is a refusal.
392 *
393 * Deterministic and side-effect free — it is called from apply(), so every
394 * peer must reach the same verdict from the same bytes.
395 */
396export function verifySignedOp (op, claim) {
397 if (!op || !claim || !claim.publisher) return false
398 const root = claim.publisher.identityRoot
399 if (!root || typeof op.signedBy !== 'string') return false
400
401 // The op must name the root that actually owns the claim.
402 if (op.signedBy !== root) return false
403
404 if (typeof op.devicePubkey !== 'string' ||
405 typeof op.chain !== 'string' ||
406 typeof op.sig !== 'string') {
407 return false
408 }
409
410 // 1. The chain proves "root R attested device D".
411 let verified
412 try {
413 const proof = b4a.from(op.chain, 'base64')
414 if (!proof || proof.length === 0) return false
415 verified = IdentityKey.verify(proof)
416 } catch {
417 return false
418 }
419 if (!verified || !verified.identityPublicKey || !verified.devicePublicKey) return false
420
421 // 2. That R is the claim's root, and that D is who the op says signed it.
422 // Without the second check the chain proves nothing about THIS op.
423 if (b4a.toString(verified.identityPublicKey, 'hex') !== root) return false
424 if (b4a.toString(verified.devicePublicKey, 'hex') !== op.devicePubkey) return false
425
426 // 3. D actually signed these exact bytes.
427 try {
428 const sig = b4a.from(op.sig, 'hex')
429 if (sig.length !== sodium.crypto_sign_BYTES) return false
430 const devicePub = b4a.from(op.devicePubkey, 'hex')
431 if (devicePub.length !== sodium.crypto_sign_PUBLICKEYBYTES) return false
432 return sodium.crypto_sign_verify_detached(sig, canonicalOpBytes(op), devicePub)
433 } catch {
434 return false
435 }
436}
437
438/**
439 * Authorisation gate shared by update-name and release-name.
440 *
441 * v2 ops must carry a valid device signature + chain. Legacy ops (no `v`)
442 * keep the original string compare so replaying existing history is unchanged
443 * — see OP_VERSION_SIGNED for why that path still exists.
444 */
445export function opAuthorised (op, claim) {
446 if (op && op.v >= OP_VERSION_SIGNED) return verifySignedOp(op, claim)
447 return !!(claim && claim.publisher && claim.publisher.identityRoot === op.signedBy)
448}
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "pgh",
3 "version": "0.1.140",
3 "version": "0.1.141",
44 "description": "pgh \u2014 P2P content-addressed package manager and app launcher",
55 "type": "module",
66 "main": "index.js",
test/name-registry-signed-ops.test.js +182 -0
……@@ -0,0 +1,182 @@
1/**
2 * Signed update-name / release-name (forge org #143).
3 *
4 * Before: apply authorised both ops with a bare string compare —
5 *
6 * if (existing.value.publisher.identityRoot !== op.signedBy) continue
7 *
8 * `signedBy` is just a field in the op, so any registry writer could assert
9 * any value for it and re-point or tombstone a name they do not own. The
10 * writer set is invite-gated, so this was a closed set rather than an open
11 * hole, but it was not a cryptographic check.
12 *
13 * After: a v2 op is signed by a DEVICE key and carries the keet attestation
14 * chain proving that device belongs to the claim's identityRoot — the same
15 * shape pear-git-v3 authenticates envelopes with. The root key itself is never
16 * available to sign with (a linked device holds only its device key + proof),
17 * which is why it is device-signature-plus-chain and not a root signature.
18 *
19 * Legacy (no `v`) ops still apply, deliberately: release-name has been
20 * callable, so tombstones may exist in real logs and rejecting them on reindex
21 * would resurrect released names.
22 */
23
24import test from 'brittle'
25import fs from '#node:fs'
26import path from '#node:path'
27import os from '#node:os'
28import Corestore from 'corestore'
29import IdentityKey from 'keet-identity-key'
30import crypto from 'hypercore-crypto'
31import b4a from 'b4a'
32
33import { NameRegistry, signOp, OP_UPDATE_NAME, OP_VERSION_SIGNED } from '../lib/name-registry.js'
34
35function mktemp () {
36 return fs.mkdtempSync(path.join(os.tmpdir(), 'pgh-name-signed-'))
37}
38
39// A whole identity: root + one attested device, plus the signer shape the
40// registry's write methods take.
41async function makeIdentity () {
42 const mnemonic = IdentityKey.generateMnemonic()
43 const identityKey = await IdentityKey.from({ mnemonic })
44 const device = crypto.keyPair()
45 const proof = await identityKey.bootstrap(device.publicKey)
46
47 const identityRoot = b4a.toString(identityKey.identityKeyPair.publicKey, 'hex')
48 const devicePubkey = b4a.toString(device.publicKey, 'hex')
49
50 return {
51 identityRoot,
52 devicePubkey,
53 deviceSecretKey: device.secretKey,
54 chain: b4a.toString(proof, 'base64')
55 }
56}
57
58async function openRegistry (t) {
59 const dir = mktemp()
60 const store = new Corestore(dir)
61 const reg = new NameRegistry(store)
62 await reg.ready()
63 t.teardown(async () => {
64 await reg.close()
65 await store.close()
66 fs.rmSync(dir, { recursive: true, force: true })
67 })
68 return reg
69}
70
71async function claimFor (reg, name, id, registryKey = 'a'.repeat(64)) {
72 await reg.claim({
73 name,
74 publisher: {
75 devicePubkey: id.devicePubkey,
76 registryKey,
77 serverKey: id.devicePubkey,
78 identityRoot: id.identityRoot
79 },
80 description: 'test'
81 })
82}
83
84test('the owner can re-point a name it owns', async (t) => {
85 const reg = await openRegistry(t)
86 const owner = await makeIdentity()
87 await claimFor(reg, 'app', owner)
88
89 const newRegistry = 'b'.repeat(64)
90 await reg.updateName('app', { registryKey: newRegistry }, owner)
91
92 const claim = await reg.resolve('app')
93 t.is(claim.publisher.registryKey, newRegistry, 'registryKey re-pointed')
94 t.is(claim.publisher.identityRoot, owner.identityRoot, 'anchor unchanged')
95})
96
97test('a different identity cannot re-point it, even asserting the owner root', async (t) => {
98 const reg = await openRegistry(t)
99 const owner = await makeIdentity()
100 const attacker = await makeIdentity()
101 await claimFor(reg, 'app', owner)
102
103 // The attacker signs with its OWN device + chain, but claims the owner's
104 // root — the forgery the string compare could not catch.
105 await reg.updateName('app', { registryKey: 'c'.repeat(64) }, {
106 ...attacker,
107 identityRoot: owner.identityRoot
108 })
109
110 const claim = await reg.resolve('app')
111 t.is(claim.publisher.registryKey, 'a'.repeat(64), 'claim untouched')
112})
113
114test('a tampered op is refused (signature covers the updates)', async (t) => {
115 const reg = await openRegistry(t)
116 const owner = await makeIdentity()
117 await claimFor(reg, 'app', owner)
118
119 // Sign a benign update, then swap the payload before it is appended.
120 const good = signOp({
121 type: OP_UPDATE_NAME,
122 v: OP_VERSION_SIGNED,
123 name: 'app',
124 updates: { description: 'harmless' },
125 signedBy: owner.identityRoot,
126 devicePubkey: owner.devicePubkey,
127 chain: owner.chain
128 }, owner.deviceSecretKey)
129
130 const tampered = { ...good, updates: { registryKey: 'd'.repeat(64) } }
131 await reg._base.append(b4a.from(JSON.stringify(tampered)))
132 await reg.update()
133
134 const claim = await reg.resolve('app')
135 t.is(claim.publisher.registryKey, 'a'.repeat(64), 'tampered update refused')
136})
137
138test('release: owner tombstones, stranger cannot', async (t) => {
139 const reg = await openRegistry(t)
140 const owner = await makeIdentity()
141 const stranger = await makeIdentity()
142
143 await claimFor(reg, 'keepme', owner)
144 await claimFor(reg, 'dropme', owner)
145
146 await reg.release('keepme', { ...stranger, identityRoot: owner.identityRoot })
147 t.ok(await reg.resolve('keepme'), 'stranger did not release it')
148
149 await reg.release('dropme', owner)
150 const listed = await reg.list()
151 t.absent(listed.find((c) => c.name === 'dropme'), 'owner released it')
152})
153
154test('legacy unsigned ops still apply (existing history must not change)', async (t) => {
155 const reg = await openRegistry(t)
156 const owner = await makeIdentity()
157 await claimFor(reg, 'app', owner)
158
159 // Exactly what a pre-#143 writer appended: no `v`, no chain, no signature.
160 await reg._base.append(b4a.from(JSON.stringify({
161 type: OP_UPDATE_NAME,
162 name: 'app',
163 updates: { description: 'from an old writer' },
164 signedBy: owner.identityRoot
165 })))
166 await reg.update()
167
168 const claim = await reg.resolve('app')
169 t.is(claim.description, 'from an old writer', 'legacy path still authorised')
170})
171
172test('a signer with no chain is refused at the call site, not silently dropped', async (t) => {
173 const reg = await openRegistry(t)
174 const owner = await makeIdentity()
175 await claimFor(reg, 'app', owner)
176
177 await t.exception(
178 () => reg.updateName('app', { description: 'x' }, { ...owner, chain: null }),
179 /chain/,
180 'throws naming the missing field'
181 )
182})