| … | … | @@ -29,6 +29,7 @@ export const usage = `pgh names / claim / release — NameRegistry (sidecar-owne |
| 29 | 29 | pgh names resolve <name> |
| 30 | 30 | pgh names update <name> [--registry-key <hex>] [--server-key <hex>] [--description "..."] |
| 31 | 31 | 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) | |
| 32 | 33 | pgh names info |
| 33 | 34 | pgh names subscribe <registryKeyHex> Replicate another publisher's NameRegistry (read-only) |
| 34 | 35 | pgh names subscriptions List own + subscribed name registries` |
| … | … | @@ -96,6 +97,7 @@ export async function run (_client, args) { |
| 96 | 97 | ' resolve <name>', |
| 97 | 98 | ' update <name> [--registry-key <hex>] [--server-key <hex>] [--description "..."]', |
| 98 | 99 | ' Re-point a name you own', |
| 100 | ' reanchor <name> | --all Move a pre-#142 claim from its device key to the identity root', | |
| 99 | 101 | ' info Print registry key + writable status', |
| 100 | 102 | ' subscribe <registryKeyHex> Replicate another publisher\'s NameRegistry (read-only)', |
| 101 | 103 | ' subscriptions List own + subscribed name registries', |
| … | … | @@ -162,6 +164,33 @@ export async function run (_client, args) { |
| 162 | 164 | }) |
| 163 | 165 | } |
| 164 | 166 | |
| 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 | ||
| 165 | 194 | if (sub === 'subscribe') { |
| 166 | 195 | const key = args[1] |
| 167 | 196 | if (!key || key.length !== 64) { |
| … | … | @@ -55,6 +55,7 @@ import IdentityKey from 'keet-identity-key' |
| 55 | 55 | export const OP_CLAIM_NAME = 'claim-name' |
| 56 | 56 | export const OP_UPDATE_NAME = 'update-name' |
| 57 | 57 | export const OP_RELEASE_NAME = 'release-name' |
| 58 | export const OP_REANCHOR_NAME = 'reanchor-name' | |
| 58 | 59 | export const OP_ADD_WRITER = 'add-writer' |
| 59 | 60 | export const OP_REMOVE_WRITER = 'remove-writer' |
| 60 | 61 | |
| … | … | @@ -153,6 +154,33 @@ export class NameRegistry { |
| 153 | 154 | await view.put('claim:' + op.name, updated) |
| 154 | 155 | } |
| 155 | 156 | |
| 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 | ||
| 156 | 184 | if (op.type === OP_RELEASE_NAME) { |
| 157 | 185 | if (!op.name || !op.signedBy) continue |
| 158 | 186 | const existing = await view.get('claim:' + op.name) |
| … | … | @@ -243,6 +271,33 @@ export class NameRegistry { |
| 243 | 271 | await this.update() |
| 244 | 272 | } |
| 245 | 273 | |
| 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 | ||
| 246 | 301 | async release (name, signer) { |
| 247 | 302 | if (!this._ready) await this.ready() |
| 248 | 303 | if (!name) throw new Error('release requires { name }') |
| … | … | @@ -435,6 +490,61 @@ export function verifySignedOp (op, claim) { |
| 435 | 490 | } |
| 436 | 491 | } |
| 437 | 492 | |
| 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 | */ | |
| 504 | export 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 | ||
| 438 | 548 | /** |
| 439 | 549 | * Authorisation gate shared by update-name and release-name. |
| 440 | 550 | * |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "pgh", |
| 3 | "version": "0.1.143", | |
| 3 | "version": "0.1.144", | |
| 4 | 4 | "description": "pgh \u2014 P2P content-addressed package manager and app launcher", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "main": "index.js", |
| … | … | @@ -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 | ||
| 28 | import test from 'brittle' | |
| 29 | import fs from '#node:fs' | |
| 30 | import path from '#node:path' | |
| 31 | import os from '#node:os' | |
| 32 | import Corestore from 'corestore' | |
| 33 | import IdentityKey from 'keet-identity-key' | |
| 34 | import crypto from 'hypercore-crypto' | |
| 35 | import b4a from 'b4a' | |
| 36 | ||
| 37 | import { NameRegistry } from '../lib/name-registry.js' | |
| 38 | ||
| 39 | function mktemp () { | |
| 40 | return fs.mkdtempSync(path.join(os.tmpdir(), 'pgh-reanchor-')) | |
| 41 | } | |
| 42 | ||
| 43 | async 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 | ||
| 56 | async 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. | |
| 71 | async 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 | ||
| 84 | test('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 | ||
| 99 | test('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 | ||
| 114 | test('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 | ||
| 128 | test('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 | ||
| 142 | test('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 | ||
| 163 | test('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 | }) |