OverviewFilesCommitsIssuesDocsReleasesPeers

pgh / test / name-registry-signed-ops.test.js

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