OverviewFilesCommitsIssuesDocsReleasesPeers

favela-peer / test / attest.test.js

271 lines · javascript ref:
// Device attestation — the User owns the feed, and the roster is gated by proof.
//
// ── The rule ──────────────────────────────────────────────────────────────────────────────────
//
// A feed's genesis BINDS an identity root. After that, the only way onto the roster is a chain
// proof that resolves to that root. Nobody vouches; nobody needs to be online. A device that can
// prove it descends from the feed's owner admits ITSELF.
//
// That deletes the old rule's awkwardness rather than adding to it: no "an admitted device must
// admit you", and no ambiguity about two devices each believing they are first — two devices under
// one root are both legitimately admitted, in whatever order linearizes.
//
// ── Why apply may do this at all ──────────────────────────────────────────────────────────────
//
// `IdentityKey.verify` is PURE and SYNCHRONOUS — measured under Bare: no Promise, ~76µs, no I/O.
// So it can run inside apply without breaking determinism. It THROWS on malformed input, which
// would wedge the base, so every call goes through `readProof`, which never throws.
//
// ── There is no unattested feed ───────────────────────────────────────────────────────────────
//
// Genesis REQUIRES a proof. A feed with no bound root cannot exist, so there is no second
// authorisation rule to remember and no mode anyone can be in by accident. The deployed feeds
// predate this and are discarded — favela-peer is days old and the owner said so explicitly.

import test from 'brittle'
import crypto from 'hypercore-crypto'
import b4a from 'b4a'
import IdentityKey from 'keet-identity-key'
import { applyOp, ROSTER, REVOKED, FOUNDER } from '../lib/feed.js'
import { readProof } from '../lib/attestation.js'
// ⚠ The shared helper, not a local copy. This file had its own `identity()` that lacked
// `proofFor`, so a test calling it committed red — one definition, one behaviour.
import { identity } from './helpers.js'

const kv = () => {
  const m = new Map()
  return {
    m,
    async set (k, v) { m.set(k, v) },
    async get (k) { return m.has(k) ? m.get(k) : null }
  }
}

const hex = (k) => b4a.toString(k, 'hex')
const run = async (ops, view = kv()) => {
  for (const [device, op] of ops) await applyOp(view, op, { device })
  return view
}
const roster = (v, writer) => v.get(ROSTER + '/' + writer)
const revoked = (v, dev) => v.get(REVOKED + '/' + dev)

// A writer core key. Opaque, manifest-derived in production, never able to sign — which is the
// whole reason it is bound to a device as data rather than being the attested key itself.
const writer = () => hex(crypto.randomBytes(32))

const add = (writerKey, proof) => ({ t: 'add-device', device: writerKey, proof })
const remove = (device) => ({ t: 'remove-device', device })

// ── genesis ────────────────────────────────────────────────────────────────────────────────────

test('genesis BINDS the identity root, and the roster records the DEVICE', async (t) => {
  const id = await identity()
  const d = await id.device()
  const w = writer()

  const v = await run([[w, add(w, d.bind(w))]])

  t.alike(await roster(v, w), { device: d.hex, admittedBy: w },
    'the roster maps a writer core to the device that vouches for it')
  t.alike(await v.get(FOUNDER), { device: w, identityRoot: id.rootHex },
    'and genesis binds the owner, once, permanently')
})

test('⛔ genesis without a proof is REFUSED — there is no unattested feed', async (t) => {
  const w1 = writer()
  const w2 = writer()
  const v = await run([[w1, add(w1)], [w1, add(w2)]])
  t.is(await v.get(FOUNDER), null, 'no founder, so no feed')
  t.absent(await roster(v, w1), 'and nothing was admitted')
})

// ── admission ──────────────────────────────────────────────────────────────────────────────────

test('★ an attested device admits ITSELF — nobody vouches, nobody is online', async (t) => {
  const id = await identity()
  const a = await id.device()
  const b = await id.device()
  const wa = writer()
  const wb = writer()

  const v = await run([[wa, add(wa, a.bind(wa))], [wb, add(wb, b.bind(wb))]])
  t.ok(await roster(v, wb), 'a proof to the bound root is the whole authorisation')
})

test('★ ONE device can hold several writer cores', async (t) => {
  const id = await identity()
  const a = await id.device()
  const w1 = writer()
  const w2 = writer()

  const v = await run([[w1, add(w1, a.bind(w1))], [w1, add(w2, a.bind(w2))]])
  t.is((await roster(v, w1)).device, a.hex)
  t.is((await roster(v, w2)).device, a.hex, 'the device is the identity; a writer core is not')
})

test('⛔ a device under a DIFFERENT root is refused', async (t) => {
  const mine = await identity()
  const theirs = await identity()
  const a = await mine.device()
  const x = await theirs.device()
  const wa = writer()
  const wx = writer()

  const v = await run([[wa, add(wa, a.bind(wa))], [wx, add(wx, x.bind(wx))]])
  t.absent(await roster(v, wx), 'their proof is valid — for their identity, not mine')
})

test('⛔ a proof is bound to ONE writer key and cannot be replayed onto another', async (t) => {
  const id = await identity()
  const a = await id.device()
  const wa = writer()
  const mine = writer()
  const other = writer()

  // The proof attests `mine` as data. Presenting it for `other` must fail.
  const v = await run([[wa, add(wa, a.bind(wa))], [wa, add(other, a.bind(mine))]])
  t.absent(await roster(v, other), 'attested data is what stops a proof being a bearer token')
})

test('⛔ apply is TOTAL — malformed proofs are refused, never thrown', async (t) => {
  const id = await identity()
  const a = await id.device()
  const wa = writer()
  const v = kv()
  await run([[wa, add(wa, a.bind(wa))]], v)

  const junk = [
    '', 'not base64 at all!!', b4a.toString(b4a.alloc(0), 'base64'),
    b4a.toString(crypto.randomBytes(64), 'base64'),
    b4a.toString(b4a.from(a.proof, 'base64').subarray(0, 10), 'base64'),
    a.proof // ⛔ a valid DEVICE proof that carries no attested data — this one throws in blake2b
  ]
  for (const bad of junk) {
    const w = writer()
    await applyOp(v, add(w, bad), { device: w })
    t.absent(await roster(v, w), 'refused: ' + JSON.stringify(String(bad).slice(0, 16)))
  }
})

// ── linking a second machine, with no mnemonic ─────────────────────────────────────────────────

test('★★ a linked device attests the NEXT one — this is the blind-pairing path', async (t) => {
  const id = await identity()
  const a = await id.device()
  const b = await a.child() // attested by A, from A's own keypair. No vault, no mnemonic.
  const wa = writer()
  const wb = writer()

  const v = await run([[wa, add(wa, a.bind(wa))], [wb, add(wb, b.bind(wb))]])

  t.ok(await roster(v, wb), 'B is admitted, and the vault was never opened')
  t.is((await roster(v, wb)).device, b.hex)
})

// ── revocation ─────────────────────────────────────────────────────────────────────────────────

test('revocation targets the DEVICE, and un-admits every core it holds', async (t) => {
  const id = await identity()
  const a = await id.device()
  const b = await id.device()
  const wa = writer()
  const wb1 = writer()
  const wb2 = writer()

  const v = await run([
    [wa, add(wa, a.bind(wa))],
    [wb1, add(wb1, b.bind(wb1))],
    [wb2, add(wb2, b.bind(wb2))],
    [wa, remove(b.hex)]
  ])

  t.ok(await revoked(v, b.hex), 'positive evidence, not an absence')

  // Both of B's cores stop counting at once — no roster walk needed.
  for (const w of [wb1, wb2]) {
    await applyOp(v, { t: 'put', collection: 'post', name: 'p' + w.slice(0, 4), $oid: 'o', size: 1 }, { device: w })
  }
  const posts = [...v.m.keys()].filter((k) => k.startsWith('post/'))
  t.alike(posts, [], 'a revoked device cannot write, through any core it holds')
})

test('⛔ THE HOLE: a revoked device cannot re-admit itself with its still-valid proof', async (t) => {
  const id = await identity()
  const a = await id.device()
  const b = await id.device()
  const wa = writer()
  const wb = writer()

  const v = await run([
    [wa, add(wa, a.bind(wa))],
    [wb, add(wb, b.bind(wb))],
    [wa, remove(b.hex)],
    [wb, add(wb, b.bind(wb))] // same device, same proof, still cryptographically valid
  ])

  t.ok(await revoked(v, b.hex), 'the revocation stands')

  // The assertion that matters is the CONSEQUENCE, not the bookkeeping: the re-admitted writer
  // must still be unable to write. Asserting the roster row alone would pass trivially — the row
  // from the first admission is still there, and it is the revocation list that overrides it.
  await applyOp(v, { t: 'put', collection: 'post', name: 'after', $oid: 'o', size: 1 }, { device: wb })
  t.is(await v.get('post/after'), null, 'a re-admitted revoked device still cannot write')
})

test('⛔ revoking a device reaches the devices it ATTESTED', async (t) => {
  const id = await identity()
  const a = await id.device()
  const b = await id.device()
  const c = await b.child() // B attested C — the escalation revocation must answer for
  const wa = writer()
  const wb = writer()
  const wc = writer()

  const v = await run([
    [wa, add(wa, a.bind(wa))],
    [wb, add(wb, b.bind(wb))],
    [wa, remove(b.hex)],
    [wc, add(wc, c.bind(wc))] // C's proof verifies cleanly and chains through the revoked B
  ])

  t.absent(await roster(v, wc), 'a compromised device has descendants, so revocation must reach them')
})

test('revocation reaches descendants and nothing else', async (t) => {
  const id = await identity()
  const a = await id.device()
  const b = await id.device()
  const d = await id.device() // a sibling, attested by the root directly
  const wa = writer()
  const wb = writer()
  const wd = writer()

  const v = await run([
    [wa, add(wa, a.bind(wa))],
    [wb, add(wb, b.bind(wb))],
    [wa, remove(b.hex)],
    [wd, add(wd, d.bind(wd))]
  ])

  t.ok(await roster(v, wd), 'a sibling is untouched — revocation is not a purge')
})

// ── the wrapper ────────────────────────────────────────────────────────────────────────────────

test('readProof binds a writer key and returns the device ancestry', async (t) => {
  const id = await identity()
  const a = await id.device()
  const c = await a.child()
  const w = writer()

  const p = readProof(c.bind(w), w)
  t.is(p.root, id.rootHex)
  t.is(p.device, c.hex, 'the DEVICE key, not the writer key')
  t.alike(p.chain, [a.hex, c.hex], 'ancestry in device keys — what revocation walks')

  t.is(readProof(c.bind(w), writer()), null, 'a different writer key does not verify')
  for (const bad of [null, undefined, '', 'zzz!!', b4a.alloc(0)]) {
    t.is(readProof(bad, w), null, 'null, never a throw')
  }
})