OverviewFilesCommitsIssuesDocsReleasesPeers

favela-peer / test / pairing.test.js

81 lines · javascript ref:
// Device pairing — a credential crossing to a second machine, over a real swarm.
//
// ⛔ What travels is a device-scoped keypair and its chain, NEVER the mnemonic. A compromised
// target holds only its own device key, and revoking that key retires it and everything it went on
// to attest. That is the whole difference from shipping a BIP39 phrase.
//
// The receiving path is not pairing-specific: what comes back is the same { keyPair, proof } shape
// the vault mints, so the target hands it to the same `link` the first device used.

import test from 'brittle'
// hyperswarm does not ship its test helpers; hyperdht's testnet is the same thing one layer down
// and is what a Hyperswarm takes as bootstrap.
import createTestnet from 'hyperdht/testnet.js'
import Hyperswarm from 'hyperswarm'
import b4a from 'b4a'
import { Pairing } from '../lib/pairing.js'
import { readProof } from '../lib/attestation.js'
import { identity } from './helpers.js'

test('★ a credential crosses to a second machine, and the mnemonic does not', async (t) => {
  const testnet = await createTestnet(3, t.teardown)
  const id = await identity()
  const source = await id.device() // as the vault would have minted it

  const a = new Pairing({ swarm: new Hyperswarm({ bootstrap: testnet.bootstrap }) })
  const b = new Pairing({ swarm: new Hyperswarm({ bootstrap: testnet.bootstrap }) })
  t.teardown(async () => {
    await a.close(); await b.close()
    await a._externalSwarm.destroy(); await b._externalSwarm.destroy()
  })

  const { invite, joined } = await a.offer({ keyPair: source.keyPair, proof: source.proof })
  t.ok(/^[0-9a-f]+$/.test(invite), 'the invite is a hex string a human can carry')

  const [cred] = await Promise.all([b.accept(invite), joined])

  t.ok(cred.keyPair.publicKey && cred.keyPair.secretKey, 'a whole keypair arrived')
  t.ok(cred.proof, 'and its chain')

  // ⛔ The proof must chain to the SAME identity root — a device linked from another device is the
  // same person's device, or the roster rule means nothing.
  const writerKey = b4a.toString(b4a.alloc(32, 7), 'hex')
  const bound = (await import('../lib/device.js')).bindWriter(cred, writerKey)
  const p = readProof(bound, writerKey)
  t.is(p.root, id.rootHex, 'same owner')
  t.alike(p.chain, [source.hex, b4a.toString(cred.keyPair.publicKey, 'hex')],
    'and the chain names the device that vouched — which is what revocation walks')
})

test('⛔ offering without a credential is refused, not silently empty', async (t) => {
  const a = new Pairing()
  t.teardown(() => a.close())
  await t.exception(a.offer(null), /link this machine first/i,
    'a machine that is not linked has nothing to pass on')
})

test('⛔ a malformed invite is refused', async (t) => {
  const b = new Pairing()
  t.teardown(() => b.close())
  await t.exception(b.accept(''), /requires an invite/i)
  await t.exception(b.accept('zzz'), /malformed/i)
})

test('⛔ adopting a feed is refused once this node has written to its own', async (t) => {
  const { adoptionRefusal } = await import('../lib/node.js')
  const mine = 'aa'.repeat(32)
  const theirs = 'bb'.repeat(32)

  // The normal case: pairing at INIT, nothing written, so adopt.
  t.is(adoptionRefusal(0, theirs, mine), null, 'a fresh node adopts — this is the whole point')

  // ⚠ The guard counts RECORDS, not founders. An unlinked node has an empty, UNFOUNDED feed —
  // opening a log is not founding it — so a "no founder" test would refuse every legitimate
  // pairing. Counting records refuses exactly the case that loses something.
  t.ok(/already written 3 record/.test(adoptionRefusal(3, theirs, mine)),
    'a node with a log of its own is refused, and told how many records are at stake')

  t.is(adoptionRefusal(9, mine, mine), null, 'adopting the feed you already have is not a change')
  t.is(adoptionRefusal(9, null, mine), null, 'and a bundle with no feed key adopts nothing')
})