/**
* Signed update-name / release-name (forge org #143).
*
* Before: apply authorised both ops with 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 for it and re-point or tombstone a name they do not own. The
* writer set is invite-gated, so this was a closed set rather than an open
* hole, but it was not a cryptographic check.
*
* After: a v2 op is signed by a DEVICE key and carries the keet attestation
* chain proving that device belongs to the claim's identityRoot — the same
* shape pear-git-v3 authenticates envelopes with. The root key itself is never
* available to sign with (a linked device holds only its device key + proof),
* which is why it is device-signature-plus-chain and not a root signature.
*
* Legacy (no `v`) ops still apply, deliberately: release-name has been
* callable, so tombstones may exist in real logs and rejecting them on reindex
* would resurrect released names.
*/
import test from 'brittle'
import fs from '#node:fs'
import path from '#node:path'
import os from '#node:os'
import Corestore from 'corestore'
import IdentityKey from 'keet-identity-key'
import crypto from 'hypercore-crypto'
import b4a from 'b4a'
import { NameRegistry, signOp, OP_UPDATE_NAME, OP_VERSION_SIGNED } from '../lib/name-registry.js'
function mktemp () {
return fs.mkdtempSync(path.join(os.tmpdir(), 'pgh-name-signed-'))
}
// A whole identity: root + one attested device, plus the signer shape the
// registry's write methods take.
async function makeIdentity () {
const mnemonic = IdentityKey.generateMnemonic()
const identityKey = await IdentityKey.from({ mnemonic })
const device = crypto.keyPair()
const proof = await identityKey.bootstrap(device.publicKey)
const identityRoot = b4a.toString(identityKey.identityKeyPair.publicKey, 'hex')
const devicePubkey = b4a.toString(device.publicKey, 'hex')
return {
identityRoot,
devicePubkey,
deviceSecretKey: device.secretKey,
chain: b4a.toString(proof, 'base64')
}
}
async function openRegistry (t) {
const dir = mktemp()
const store = new Corestore(dir)
const reg = new NameRegistry(store)
await reg.ready()
t.teardown(async () => {
await reg.close()
await store.close()
fs.rmSync(dir, { recursive: true, force: true })
})
return reg
}
async function claimFor (reg, name, id, registryKey = 'a'.repeat(64)) {
await reg.claim({
name,
publisher: {
devicePubkey: id.devicePubkey,
registryKey,
serverKey: id.devicePubkey,
identityRoot: id.identityRoot
},
description: 'test'
})
}
test('the owner can re-point a name it owns', async (t) => {
const reg = await openRegistry(t)
const owner = await makeIdentity()
await claimFor(reg, 'app', owner)
const newRegistry = 'b'.repeat(64)
await reg.updateName('app', { registryKey: newRegistry }, owner)
const claim = await reg.resolve('app')
t.is(claim.publisher.registryKey, newRegistry, 'registryKey re-pointed')
t.is(claim.publisher.identityRoot, owner.identityRoot, 'anchor unchanged')
})
test('a different identity cannot re-point it, even asserting the owner root', async (t) => {
const reg = await openRegistry(t)
const owner = await makeIdentity()
const attacker = await makeIdentity()
await claimFor(reg, 'app', owner)
// The attacker signs with its OWN device + chain, but claims the owner's
// root — the forgery the string compare could not catch.
await reg.updateName('app', { registryKey: 'c'.repeat(64) }, {
...attacker,
identityRoot: owner.identityRoot
})
const claim = await reg.resolve('app')
t.is(claim.publisher.registryKey, 'a'.repeat(64), 'claim untouched')
})
test('a tampered op is refused (signature covers the updates)', async (t) => {
const reg = await openRegistry(t)
const owner = await makeIdentity()
await claimFor(reg, 'app', owner)
// Sign a benign update, then swap the payload before it is appended.
const good = signOp({
type: OP_UPDATE_NAME,
v: OP_VERSION_SIGNED,
name: 'app',
updates: { description: 'harmless' },
signedBy: owner.identityRoot,
devicePubkey: owner.devicePubkey,
chain: owner.chain
}, owner.deviceSecretKey)
const tampered = { ...good, updates: { registryKey: 'd'.repeat(64) } }
await reg._base.append(b4a.from(JSON.stringify(tampered)))
await reg.update()
const claim = await reg.resolve('app')
t.is(claim.publisher.registryKey, 'a'.repeat(64), 'tampered update refused')
})
test('release: owner tombstones, stranger cannot', async (t) => {
const reg = await openRegistry(t)
const owner = await makeIdentity()
const stranger = await makeIdentity()
await claimFor(reg, 'keepme', owner)
await claimFor(reg, 'dropme', owner)
await reg.release('keepme', { ...stranger, identityRoot: owner.identityRoot })
t.ok(await reg.resolve('keepme'), 'stranger did not release it')
await reg.release('dropme', owner)
const listed = await reg.list()
t.absent(listed.find((c) => c.name === 'dropme'), 'owner released it')
})
test('legacy unsigned ops still apply (existing history must not change)', async (t) => {
const reg = await openRegistry(t)
const owner = await makeIdentity()
await claimFor(reg, 'app', owner)
// Exactly what a pre-#143 writer appended: no `v`, no chain, no signature.
await reg._base.append(b4a.from(JSON.stringify({
type: OP_UPDATE_NAME,
name: 'app',
updates: { description: 'from an old writer' },
signedBy: owner.identityRoot
})))
await reg.update()
const claim = await reg.resolve('app')
t.is(claim.description, 'from an old writer', 'legacy path still authorised')
})
test('a signer with no chain is refused at the call site, not silently dropped', async (t) => {
const reg = await openRegistry(t)
const owner = await makeIdentity()
await claimFor(reg, 'app', owner)
await t.exception(
() => reg.updateName('app', { description: 'x' }, { ...owner, chain: null }),
/chain/,
'throws naming the missing field'
)
})