OverviewFilesCommitsIssuesDocsReleasesPeers

pgh / test / publisher-trust.test.js

166 lines · javascript ref:
/**
 * Binding a release to the identity that owns the name (forge org #144 step 3).
 *
 * A release signature proves the entry was signed by the device key inside it.
 * It does not prove that device may publish under this app NAME — nothing
 * connected the release registry to the name registry. This is that link:
 * resolve the entry's device to its identity root via the attestation chain,
 * and require that root to be the one the NameRegistry claim is anchored to.
 *
 * It is also the payoff for #142. Anchored to a device key, this check would
 * refuse every release published from any other device the same identity owns.
 *
 * Transitional: a missing chain or a pre-#142 claim is INDETERMINATE, not a
 * refusal — no entry in the wild carries a chain yet, and all 13 live claims
 * are still anchored to a device key. Only a present-and-contradictory pair is
 * refused. Enforcement is a later flip.
 */

import test from 'brittle'
import IdentityKey from 'keet-identity-key'
import crypto from 'hypercore-crypto'
import b4a from 'b4a'

import {
  checkPublisherTrust,
  assertPublisherTrust,
  TRUST_OK,
  TRUST_INDETERMINATE,
  TRUST_REFUSED
} from '../lib/publisher-trust.js'

// A root identity with one or more attested devices.
async function makeIdentity () {
  const mnemonic = IdentityKey.generateMnemonic()
  const identityKey = await IdentityKey.from({ mnemonic })
  const root = b4a.toString(identityKey.identityKeyPair.publicKey, 'hex')

  async function attest () {
    const device = crypto.keyPair()
    const proof = await identityKey.bootstrap(device.publicKey)
    return {
      devicePubkey: b4a.toString(device.publicKey, 'hex'),
      chain: b4a.toString(proof, 'base64')
    }
  }

  return { root, attest }
}

function entryFor (device, overrides = {}) {
  return {
    name: 'demo',
    version: '1.0.0',
    treeHash: 'a'.repeat(64),
    publisherDevicePubkey: device.devicePubkey,
    publisherChain: device.chain,
    ...overrides
  }
}

const claimFor = (root) => ({ name: 'demo', publisher: { identityRoot: root } })

test('a release from the owning identity is accepted', async (t) => {
  const owner = await makeIdentity()
  const device = await owner.attest()

  const r = checkPublisherTrust(entryFor(device), claimFor(owner.root))
  t.is(r.status, TRUST_OK, r.reason)
  t.is(r.root, owner.root, 'resolved to the owning root')
})

test('a SECOND device of the same identity is accepted — the point of #142', async (t) => {
  const owner = await makeIdentity()
  const laptop = await owner.attest()
  const server = await owner.attest()

  t.not(laptop.devicePubkey, server.devicePubkey, 'genuinely different devices')

  // The name was claimed from one device; the release was published from the
  // other. Anchored to a device key this would be refused.
  t.is(checkPublisherTrust(entryFor(laptop), claimFor(owner.root)).status, TRUST_OK)
  t.is(checkPublisherTrust(entryFor(server), claimFor(owner.root)).status, TRUST_OK)
})

test('a release from a DIFFERENT identity is refused', async (t) => {
  const owner = await makeIdentity()
  const attacker = await makeIdentity()
  const attackerDevice = await attacker.attest()

  const r = checkPublisherTrust(entryFor(attackerDevice), claimFor(owner.root))
  t.is(r.status, TRUST_REFUSED)
  t.ok(/is owned by/.test(r.reason), r.reason)
})

test('a chain lifted from someone else is refused (it must attest THIS device)', async (t) => {
  const owner = await makeIdentity()
  const ownerDevice = await owner.attest()
  const other = await owner.attest()

  // Correct root, valid chain — but the chain describes a different device
  // than the one that signed the entry.
  const frankenstein = entryFor(ownerDevice, { publisherChain: other.chain })

  const r = checkPublisherTrust(frankenstein, claimFor(owner.root))
  t.is(r.status, TRUST_REFUSED)
  t.ok(/but the entry was signed by/.test(r.reason), r.reason)
})

test('a garbage chain is refused, not treated as missing', async (t) => {
  const owner = await makeIdentity()
  const device = await owner.attest()

  const r = checkPublisherTrust(
    entryFor(device, { publisherChain: b4a.toString(b4a.from('not a proof'), 'base64') }),
    claimFor(owner.root)
  )
  t.is(r.status, TRUST_REFUSED, r.reason)
})

test('indeterminate: no claim, no chain, or a pre-#142 device-anchored claim', async (t) => {
  const owner = await makeIdentity()
  const device = await owner.attest()

  t.is(
    checkPublisherTrust(entryFor(device), null).status,
    TRUST_INDETERMINATE,
    'unclaimed name'
  )

  t.is(
    checkPublisherTrust(entryFor(device, { publisherChain: null }), claimFor(owner.root)).status,
    TRUST_INDETERMINATE,
    'entry predates signing'
  )

  // What every live claim looks like today: anchored to the device key.
  const legacyClaim = claimFor(device.devicePubkey)
  t.is(
    checkPublisherTrust(entryFor(device, { publisherChain: null }), legacyClaim).status,
    TRUST_INDETERMINATE,
    'pre-#142 claim anchored to a device key'
  )
})

test('assertPublisherTrust throws only on a contradiction', async (t) => {
  const owner = await makeIdentity()
  const attacker = await makeIdentity()
  const device = await owner.attest()
  const attackerDevice = await attacker.attest()

  t.is(assertPublisherTrust(entryFor(device), claimFor(owner.root)).status, TRUST_OK)

  // Indeterminate must pass — otherwise nothing installs until every publisher
  // has re-published and every claim has been re-anchored.
  t.is(
    assertPublisherTrust(entryFor(device, { publisherChain: null }), claimFor(owner.root)).status,
    TRUST_INDETERMINATE
  )

  t.exception(
    () => assertPublisherTrust(entryFor(attackerDevice), claimFor(owner.root), { name: 'demo' }),
    /refusing demo@1\.0\.0/,
    'contradiction throws'
  )
})