OverviewFilesCommitsIssuesDocsReleasesPeers

pgh / test / name-reanchor.test.js

185 lines · javascript ref:
/**
 * 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'
  )
})