/**
* Re-anchoring a device-anchored claim to its identity root (forge org #142).
*
* #142 fixed what NEW claims record. It could not fix the claims already in
* the registry, and #143's update-name turned out to be the wrong tool 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 both update-name and release-name authorise by comparing
* op.signedBy against claim.publisher.identityRoot. The identity now correctly
* reports its ROOT while the legacy claims are anchored to a DEVICE key, so
* they no longer match. The 13 live claims became un-updatable and
* un-releasable by their own owner.
*
* The way out is available precisely because the stale anchor is a device key:
* this device holds that key's secret, so it can prove control of the current
* anchor, while its keet chain proves that device belongs to the root. A
* re-anchor is therefore authorised by the key being replaced.
*
* The rule, and why it cannot be abused:
* - signed by device D, chain verifies to root R
* - chain's device === op.devicePubkey === D
* - claim.publisher.identityRoot === D (the anchor IS this device)
* - op.newRoot === R (only the attested root)
* so a name cannot be stolen (only the currently-anchoring key can sign) and
* cannot be pointed at an arbitrary root (it must be the chain's).
*/
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 } from '../lib/name-registry.js'
function mktemp () {
return fs.mkdtempSync(path.join(os.tmpdir(), 'pgh-reanchor-'))
}
async function makeIdentity () {
const mnemonic = IdentityKey.generateMnemonic()
const identityKey = await IdentityKey.from({ mnemonic })
const device = crypto.keyPair()
const proof = await identityKey.bootstrap(device.publicKey)
return {
identityRoot: b4a.toString(identityKey.identityKeyPair.publicKey, 'hex'),
devicePubkey: b4a.toString(device.publicKey, 'hex'),
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
}
// A claim in the shape the pre-#142 sidecar wrote: identityRoot holds the
// DEVICE key, because masterPublicKey was unset on a linked device.
async function claimLegacy (reg, name, id) {
await reg.claim({
name,
publisher: {
devicePubkey: id.devicePubkey,
registryKey: 'a'.repeat(64),
serverKey: id.devicePubkey,
identityRoot: id.devicePubkey
},
description: 'legacy'
})
}
test('the owning device re-anchors its own claim to the root', async (t) => {
const reg = await openRegistry(t)
const id = await makeIdentity()
await claimLegacy(reg, 'app', id)
const before = await reg.resolve('app')
t.is(before.publisher.identityRoot, id.devicePubkey, 'starts anchored to the device')
await reg.reanchor('app', id)
const after = await reg.resolve('app')
t.is(after.publisher.identityRoot, id.identityRoot, 'now anchored to the root')
t.is(after.publisher.devicePubkey, id.devicePubkey, 'device record untouched')
})
test('the by-publisher index moves with the anchor', async (t) => {
const reg = await openRegistry(t)
const id = await makeIdentity()
await claimLegacy(reg, 'app', id)
await reg.reanchor('app', id)
const byRoot = await reg.listByPublisher(id.identityRoot)
t.is(byRoot.length, 1, 'listed under the root')
t.is(byRoot[0].name, 'app')
const byDevice = await reg.listByPublisher(id.devicePubkey)
t.is(byDevice.length, 0, 'no longer listed under the device')
})
test('a stranger cannot re-anchor someone else\'s claim', async (t) => {
const reg = await openRegistry(t)
const owner = await makeIdentity()
const attacker = await makeIdentity()
await claimLegacy(reg, 'app', owner)
// The attacker signs with its own device+chain. The anchor is not its
// device, so it has nothing to prove control of.
await reg.reanchor('app', attacker)
const after = await reg.resolve('app')
t.is(after.publisher.identityRoot, owner.devicePubkey, 'claim untouched')
})
test('re-anchoring to a root the chain does not attest is refused', async (t) => {
const reg = await openRegistry(t)
const id = await makeIdentity()
const other = await makeIdentity()
await claimLegacy(reg, 'app', id)
// Correct device and a valid chain, but pointing the name at a root that
// chain says nothing about.
await reg.reanchor('app', { ...id, identityRoot: other.identityRoot })
const after = await reg.resolve('app')
t.is(after.publisher.identityRoot, id.devicePubkey, 'claim untouched')
})
test('a claim already anchored to a root is not re-anchored again', async (t) => {
const reg = await openRegistry(t)
const id = await makeIdentity()
// A post-#142 claim: already correct.
await reg.claim({
name: 'app',
publisher: {
devicePubkey: id.devicePubkey,
registryKey: 'a'.repeat(64),
serverKey: id.devicePubkey,
identityRoot: id.identityRoot
}
})
await reg.reanchor('app', id)
const after = await reg.resolve('app')
t.is(after.publisher.identityRoot, id.identityRoot, 'unchanged and still correct')
})
test('after re-anchoring, normal update-name works again — the payoff', async (t) => {
const reg = await openRegistry(t)
const id = await makeIdentity()
await claimLegacy(reg, 'app', id)
// Blocked before: signedBy is the root, the anchor is the device.
await reg.updateName('app', { registryKey: 'b'.repeat(64) }, id)
t.is(
(await reg.resolve('app')).publisher.registryKey,
'a'.repeat(64),
'update refused while the claim is device-anchored'
)
await reg.reanchor('app', id)
await reg.updateName('app', { registryKey: 'b'.repeat(64) }, id)
t.is(
(await reg.resolve('app')).publisher.registryKey,
'b'.repeat(64),
'update authorised once the anchor is the root'
)
})