OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-proto-peer / test / multi-did-signer.test.js

96 lines · javascript ref:
// S1 multi-DID signer election (the FORK-CRITICAL wiring). Two multi-DID hpp daemons, each hosting its
// own single-owner DID PLUS one CO-OWNED DID-C, connected over ONE shared swarm connection. Proves the
// per-DID presence channel id (roomKey) lets N DIDs' presence coexist on that one connection, so:
//   - the co-owned DID-C elects exactly ONE signer across BOTH nodes (both agree; exactly one holds), and
//   - each single-owner DID stays the sole signer of itself (its presence never collides with DID-C's).
//
// Without the fix (attachPresence with a null channel id, or no attach at all in bootMultiDevice), the
// second DID's presence channel returns null on the shared mux → its co-owners never exchange member keys
// → every co-owner sees only itself present → holdsSigner()==true for all → they all sign → FORK. This is
// the exact split-brain the __lease__→presence swap (S1) exists to prevent, now over the multi-DID daemon.
// Run: `bare test/multi-did-signer.test.js`
import '../lib/bare-globals.js'
import test from 'brittle'
import fs from 'bare-fs'
import b4a from 'b4a'
import createTestnet from '@hyperswarm/testnet'
import { bootMultiDevice, provisionDevice } from '../lib/device-node.js'
import { deviceKeetKeyPair } from 'atproto-room/lib/membership.js'

// distinct, valid BIP39 mnemonics (Trezor test vectors) — same set the 2-node convergence test uses.
const M = {
  rootA: 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
  deviceA: 'legal winner thank year wave sausage worth useful legal winner thank yellow',
  rootB: 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong',
  deviceB: 'letter advice cage absurd amount doctor acoustic avoid letter advice cage above',
  communityRoot: 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art',
  device1: 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo vote',
  device2: 'legal winner thank year wave sausage worth useful legal winner thank year wave sausage worth useful legal will'
}
const tmp = () => { const d = '/tmp/hpp-mds-' + Math.floor(Math.random() * 1e9); fs.mkdirSync(d, { recursive: true }); return d }
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
const waitFor = async (fn, timeout = 30000) => { const end = Date.now() + timeout; while (Date.now() < end) { if (await fn()) return true; await sleep(200) } return false }

async function bundle (rootMnem, devMnem, handle) {
  const dk = await deviceKeetKeyPair(devMnem)
  const b = await provisionDevice({ rootMnemonic: rootMnem, devicePublicKey: dk.publicKey, handle, pdsUrl: 'https://pds.local' })
  return { entry: { deviceMnemonic: devMnem, did: b.did, signingKey: b.signingKey, deviceProof: b.deviceProof, rootIdentityKey: b.rootIdentityKey, handle }, keetPub: b4a.toString(dk.publicKey, 'hex'), did: b.did }
}

test('multi-DID daemon: co-owned DID elects ONE signer across nodes; single-owner DIDs stay sole signer (no cross-DID presence collision)', { timeout: 180000 }, async (t) => {
  const testnet = await createTestnet(3)
  const swarmOpts = { bootstrap: testnet.bootstrap }
  const s1 = tmp(); const s2 = tmp()

  const A = await bundle(M.rootA, M.deviceA, 'a.local')       // node1's own DID (single-owner)
  const B = await bundle(M.rootB, M.deviceB, 'b.local')       // node2's own DID (single-owner)
  const C1 = await bundle(M.communityRoot, M.device1, 'loser-club.local') // co-owned, node1's device
  const C2 = await bundle(M.communityRoot, M.device2, 'loser-club.local') // co-owned, node2's device
  t.is(C1.did, C2.did, 'DID-C is co-owned — same DID on both nodes')
  const membersC = [{ key: C1.keetPub, role: 1 }, { key: C2.keetPub, role: 1 }] // both admins
  C1.entry.members = membersC; C2.entry.members = membersC

  // signIntervalMs:0 — presence is a function of who is CONNECTED, independent of the sign loop; keep the
  // loop off so the assertions isolate the presence election itself (no signing races).
  const n1 = await bootMultiDevice({ storage: s1, dids: [A.entry, C1.entry], swarmOpts, signIntervalMs: 0 })
  const n2 = await bootMultiDevice({ storage: s2, dids: [B.entry, C2.entry], swarmOpts, signIntervalMs: 0 })
  t.teardown(async () => { try { await n1.close() } catch {} ; try { await n2.close() } catch {} ; await testnet.destroy() })

  const c1 = n1.byDid.get(C1.did) // node1's view of the co-owned DID
  const c2 = n2.byDid.get(C2.did) // node2's view of the co-owned DID
  const a1 = n1.byDid.get(A.did)  // node1's own single-owner DID
  const b2 = n2.byDid.get(B.did)  // node2's own single-owner DID

  // The two nodes share ONLY DID-C's topic → they connect there; over that one connection DID-C's presence
  // channel (id=roomKey_C) must open on both ends and exchange the two co-owner member keys.
  const bothSeePeer = await waitFor(async () =>
    c1.sb.signerState().effective.includes(C2.keetPub) && c2.sb.signerState().effective.includes(C1.keetPub))
  t.ok(bothSeePeer, 'co-owned DID-C: each node learned the OTHER co-owner over the shared connection (per-DID presence channel opened)')

  const eff1 = c1.sb.signerState().effective
  const eff2 = c2.sb.signerState().effective
  t.is(eff1.length, 2, 'node1 DID-C effective co-owners = 2 (self + peer)')
  t.is(eff2.length, 2, 'node2 DID-C effective co-owners = 2 (self + peer)')

  // Election is deterministic (lowest-keyed effective co-owner) — both nodes MUST agree, and exactly one holds.
  t.is(c1.sb.currentSigner(), c2.sb.currentSigner(), 'both nodes agree on the single elected signer for DID-C')
  const lowest = [C1.keetPub, C2.keetPub].sort()[0]
  t.is(c1.sb.currentSigner(), lowest, 'elected signer is the lowest-keyed co-owner')
  t.is([c1.sb.holdsSigner(), c2.sb.holdsSigner()].filter(Boolean).length, 1, 'EXACTLY ONE node holds the signer role (no fork)')

  // The single-owner DIDs must NOT have been polluted by DID-C's channel: their presence stays self-only.
  // (Proves per-DID isolation: DID-A's channel id=roomKey_A never collided with DID-C's id=roomKey_C.)
  t.is(a1.sb.signerState().effective.length, 1, 'single-owner DID-A: only itself present (no cross-DID leak)')
  t.ok(a1.sb.holdsSigner(), 'single-owner DID-A holds its own signer role')
  t.is(b2.sb.signerState().effective.length, 1, 'single-owner DID-B: only itself present')
  t.ok(b2.sb.holdsSigner(), 'single-owner DID-B holds its own signer role')

  // FAILOVER: drop the current signer's whole daemon; after grace, the survivor becomes DID-C's sole signer.
  const survivor = c1.sb.holdsSigner() ? { node: n2, c: c2, self: C2.keetPub } : { node: n1, c: c1, self: C1.keetPub }
  const dying = c1.sb.holdsSigner() ? n1 : n2
  await dying.close()
  const survivorTakesOver = await waitFor(async () =>
    survivor.c.sb.holdsSigner() && survivor.c.sb.currentSigner() === survivor.self)
  t.ok(survivorTakesOver, 'after the signer node drops, the survivor fails over to sole signer of DID-C (chain continues, no fork)')
})