OverviewFilesCommitsIssuesDocsReleasesPeers

ap-bridge / test / collect.test.js

214 lines · javascript ref:
// collect.test.js — a peer collecting the observations this bridge holds FOR IT.
//
// ⛔ THE HALF THAT IS EASY TO GET WRONG. Claim and release ask one question: does this caller control
// the root it names. Collect asks TWO, and the second is the one a reviewer skips — does that root
// HOLD THIS NAME. Roots are public and names are first-come, so a caller proving control of their own
// root while naming somebody else's username would otherwise be handed that person's entire inbound
// social graph. Control of a root is not authority over a name.
//
// ⛔ AND IT IS A READ, NOT A DRAIN. OBSERVED-INTERACTIONS.md §9 says the buffer is "bounded;
// prunable" — bounded is the cap, prunable is an operator's act. Deleting rows as they are served
// would lose them for a SECOND device holding the same name, which registrations.js explicitly
// supports ("a person can hold one name across a replaced device").

import test from 'brittle'
import b4a from 'b4a'
import fs from 'bare-fs'
import IdentityKey from 'keet-identity-key'
import crypto from 'hypercore-crypto'
import { signAttested } from 'favela-identity/lib/attestation.js'
import { MemoryRegistrations } from '../lib/registrations.js'
import { Admission } from '../lib/admission.js'
import { ObservationStore, KIND } from '../lib/observations.js'
import { admitCollect } from '../lib/collect.js'
import { registerHandlers } from '../lib/register-rpc.js'

const FEED = 'f'.repeat(64)

async function identity () {
  const idk = await IdentityKey.from({ mnemonic: IdentityKey.generateMnemonic() })
  const kp = crypto.keyPair()
  const proof = await IdentityKey.bootstrap({ identity: idk.identityKeyPair }, kp.publicKey)
  return { rootHex: b4a.toString(idk.identityKeyPair.publicKey, 'hex'), cred: { keyPair: kp, proof: b4a.toString(proof, 'base64') } }
}

// An in-memory store, so a test never touches a real bridge's buffer.
function store (t) {
  const path = '/tmp/ap-bridge-collect-' + Math.random().toString(16).slice(2) + '.jsonl'
  const s = new ObservationStore({ path })
  t.teardown(() => { try { fs.unlinkSync(path) } catch {} })
  return s
}

async function bridge (t, { policy = 'open' } = {}) {
  const d = {
    registrations: new MemoryRegistrations({ policy }),
    admission: new Admission(),
    observations: store(t),
    config: { host: 'ap.7rnx.net' }
  }
  return d
}

// Register `username` to a fresh identity, the way a real peer would have.
async function held (d, username) {
  const me = await identity()
  await d.registrations.claim({ username, root: me.rootHex, feedKey: FEED })
  return me
}

const sign = (d, me, { nonce, username, since = 0 }) =>
  signAttested(d.admission.collectBytes({ nonce, username, since }), me.cred)

test('★★ the whole ceremony: challenge, sign, collect what is held for me', async (t) => {
  const d = await bridge(t)
  const me = await held(d, 'alice')
  d.observations.note({ recipient: 'alice', subject: 'https://ap.7rnx.net/x', actor: 'https://prsm.space/users/bob', kind: KIND.LIKE, id: 'l1' })
  d.observations.note({ recipient: 'alice', subject: 'https://ap.7rnx.net/y', actor: 'https://prsm.space/users/bob', kind: KIND.REPLY, id: 'r1' })

  const { nonce } = d.admission.challenge()
  const r = await admitCollect(d, { username: 'alice', root: me.rootHex, since: 0, nonce, proof: sign(d, me, { nonce, username: 'alice' }) })

  t.is(r.status, 200)
  t.is(r.body.rows.length, 2)
  t.is(r.body.rows[0].actor, 'https://prsm.space/users/bob')
  t.is(r.body.cursor, r.body.rows[1].seq, 'the cursor names the last row actually handed over')
  t.absent(r.body.more)
})

test('⛔ CONTROL OF A ROOT IS NOT AUTHORITY OVER A NAME', async (t) => {
  const d = await bridge(t)
  await held(d, 'alice')
  const mallory = await identity() // a perfectly valid identity — just not alice's
  d.observations.note({ recipient: 'alice', subject: 'https://ap.7rnx.net/x', actor: 'https://prsm.space/users/bob', id: 'l1' })

  const { nonce } = d.admission.challenge()
  // Mallory signs honestly, for her own root, naming alice. Every signature here verifies.
  const r = await admitCollect(d, { username: 'alice', root: mallory.rootHex, since: 0, nonce, proof: sign(d, mallory, { nonce, username: 'alice' }) })

  t.is(r.status, 403, 'refused')
  t.is(r.body.error, 'not yours to collect')
  t.absent(r.body.rows, 'and not one row leaked')
})

test('⛔ a proof from somebody else, under the right root, is refused', async (t) => {
  const d = await bridge(t)
  const me = await held(d, 'alice')
  const other = await identity()

  const { nonce } = d.admission.challenge()
  const r = await admitCollect(d, { username: 'alice', root: me.rootHex, since: 0, nonce, proof: sign(d, other, { nonce, username: 'alice' }) })
  t.is(r.status, 403)
  t.is(r.body.error, 'proof does not attest that identity')
})

test('⛔ no proof at all is refused', async (t) => {
  const d = await bridge(t)
  const me = await held(d, 'alice')
  const r = await admitCollect(d, { username: 'alice', root: me.rootHex, since: 0 })
  t.is(r.status, 403)
})

test('⛔ a captured proof cannot be replayed with a different cursor', async (t) => {
  const d = await bridge(t)
  const me = await held(d, 'alice')
  for (let i = 0; i < 3; i++) d.observations.note({ recipient: 'alice', actor: 'https://prsm.space/users/bob', id: 'l' + i })

  const { nonce } = d.admission.challenge()
  // Signed for since=2; submitted as since=0 to rake in everything from the start.
  const proof = sign(d, me, { nonce, username: 'alice', since: 2 })
  const r = await admitCollect(d, { username: 'alice', root: me.rootHex, since: 0, nonce, proof })
  t.is(r.status, 403, 'the cursor is inside the signed bytes')
})

test('⛔ one peer can never collect another peer\'s rows', async (t) => {
  const d = await bridge(t)
  const me = await held(d, 'alice')
  await held(d, 'bob')
  d.observations.note({ recipient: 'bob', actor: 'https://prsm.space/users/x', id: 'b1' })
  d.observations.note({ recipient: 'alice', actor: 'https://prsm.space/users/y', id: 'a1' })

  const { nonce } = d.admission.challenge()
  const r = await admitCollect(d, { username: 'alice', root: me.rootHex, since: 0, nonce, proof: sign(d, me, { nonce, username: 'alice' }) })
  t.is(r.body.rows.length, 1)
  t.is(r.body.rows[0].id, 'a1', 'bob\'s row is not in alice\'s answer')
})

test('★ collecting twice resumes from the cursor rather than re-reading', async (t) => {
  const d = await bridge(t)
  const me = await held(d, 'alice')
  for (let i = 0; i < 5; i++) d.observations.note({ recipient: 'alice', actor: 'https://prsm.space/users/bob', id: 'l' + i })

  const c1 = d.admission.challenge()
  const r1 = await admitCollect(d, { username: 'alice', root: me.rootHex, since: 0, limit: 2, nonce: c1.nonce, proof: sign(d, me, { nonce: c1.nonce, username: 'alice' }) })
  t.is(r1.body.rows.length, 2)
  t.ok(r1.body.more, 'and says so')

  const c2 = d.admission.challenge()
  const r2 = await admitCollect(d, { username: 'alice', root: me.rootHex, since: r1.body.cursor, nonce: c2.nonce, proof: sign(d, me, { nonce: c2.nonce, username: 'alice', since: r1.body.cursor }) })
  t.is(r2.body.rows.length, 3, 'the rest, and none of the first page')
  t.absent(r2.body.more)
})

test('⛔ a read NEVER drains — a second device holding the same name still gets them', async (t) => {
  const d = await bridge(t)
  const me = await held(d, 'alice')
  d.observations.note({ recipient: 'alice', actor: 'https://prsm.space/users/bob', id: 'l1' })

  const c1 = d.admission.challenge()
  await admitCollect(d, { username: 'alice', root: me.rootHex, since: 0, nonce: c1.nonce, proof: sign(d, me, { nonce: c1.nonce, username: 'alice' }) })

  const c2 = d.admission.challenge()
  const again = await admitCollect(d, { username: 'alice', root: me.rootHex, since: 0, nonce: c2.nonce, proof: sign(d, me, { nonce: c2.nonce, username: 'alice' }) })
  t.is(again.body.rows.length, 1, 'still there')
})

test('⛔ an unregistered name is a 404, not an empty answer', async (t) => {
  const d = await bridge(t)
  const me = await identity()
  const { nonce } = d.admission.challenge()
  const r = await admitCollect(d, { username: 'nobody', root: me.rootHex, since: 0, nonce, proof: sign(d, me, { nonce, username: 'nobody' }) })
  t.is(r.status, 404, 'an empty answer would read as "you have no likes"')
})

test('★★ the swarm door reaches the same room', async (t) => {
  const d = await bridge(t)
  const me = await held(d, 'alice')
  d.observations.note({ recipient: 'alice', actor: 'https://prsm.space/users/bob', id: 'l1' })

  const h = registerHandlers(d)
  const { nonce } = await h.challenge()
  const r = await h.collect({ username: 'alice', root: me.rootHex, since: 0, nonce, proof: sign(d, me, { nonce, username: 'alice' }) })
  t.is(r.status, 200)
  t.is(r.body.rows.length, 1)
})

// ⛔ A PROOF FOR ONE ACT MUST NOT VERIFY AS ANOTHER. This is the entire reason `collect` got its own
// prefix rather than reusing CLAIM's. Releasing BURNS a name permanently and there is no way back, so
// a routine poll whose proof could be repurposed as a release would be a peer handing strangers a
// weapon every few minutes.
test('⛔ a collect proof cannot be replayed as a release', async (t) => {
  const d = await bridge(t)
  const me = await held(d, 'alice')

  const { nonce } = d.admission.challenge()
  const proof = sign(d, me, { nonce, username: 'alice' })

  // The nonce is still open — verifyCollect has not been called, so this is the best case for an
  // attacker and it must still fail.
  const r = d.admission.verifyRelease({ nonce, username: 'alice', root: me.rootHex, proof })
  t.absent(r.ok, 'refused')
  t.is(r.error, 'proof does not attest that identity', 'the bytes describe a different act')
})

// ⛔ THE WIRING, WHICH NO OTHER TEST CAN SEE. Every test above hands `observations` to the handlers
// directly. If index.js forgets to, `admitCollect` returns 404 'not found' — the daemon boots, the
// verb answers, the tests all pass, and every peer is told for ever that it has nothing. That is the
// exact shape of the `held:false` defect registrations.js records, one door over.
test('⛔ the boot wiring (lib/bridge.js since 0.10.15) hands the observation store to the swarm door', async (t) => {
  const src = fs.readFileSync(new URL('../lib/bridge.js', import.meta.url), 'utf8')
  const call = /serveRegistration\(\{([^}]*)\}/.exec(src)
  t.ok(call, 'the swarm door is opened at boot')
  t.ok(/\bobservations\b/.test(call[1]), 'and it is given the buffer it is meant to serve')
})