| … | … | @@ -0,0 +1,128 @@ |
| 1 | // Multi-DID (multi-tenant) StateBaseV3 — the multi-DID-hpp foundation. |
| 2 | // |
| 3 | // Proves ONE daemon can host N DIDs over ONE Corestore + ONE Hyperswarm + ONE ProtomuxWakeup: each |
| 4 | // DID is a StateBaseV3 opened on a per-DID NAMESPACE of the shared store (opts.store) with the shared |
| 5 | // wakeup (opts.wakeup) and network:false (the daemon drives replication). We run 2 DIDs, each with a |
| 6 | // creator on "machine 1" and a member on "machine 2", and assert the FULL encrypted, attestation-gated |
| 7 | // admission doorbell works PER DID over the shared connection, data is isolated per DID, and each |
| 8 | // machine holds exactly ONE storage lock. This is the harder half the foundational corestore test |
| 9 | // (test/multi-did-mechanism) didn't cover: encrypted optimistic admission over a SHARED wakeup. |
| 10 | import '../lib/bare-globals.js' |
| 11 | import test from 'brittle' |
| 12 | import crypto from 'hypercore-crypto' |
| 13 | import fs from 'bare-fs' |
| 14 | import b4a from 'b4a' |
| 15 | import Corestore from 'corestore' |
| 16 | import Hyperswarm from 'hyperswarm' |
| 17 | import ProtomuxWakeup from 'protomux-wakeup' |
| 18 | import createTestnet from '@hyperswarm/testnet' |
| 19 | import { StateBaseV3 } from '../lib/state-base.js' |
| 20 | import { makeAttest, makeVerifyAttest, attestDeviceChain, deviceKeetKeyPair, rootIdentityPublicKey } from '../lib/membership.js' |
| 21 | |
| 22 | const ROOT = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' |
| 23 | // 4 device identities: 2 per DID (a creator + a member). |
| 24 | const MNEMS = [ |
| 25 | 'legal winner thank year wave sausage worth useful legal winner thank yellow', |
| 26 | 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong', |
| 27 | 'letter advice cage absurd amount doctor acoustic avoid letter advice cage above', |
| 28 | 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo vote' |
| 29 | ] |
| 30 | const tmp = () => { const d = '/tmp/hpp-mtdid-' + Math.floor(Math.random() * 1e9); fs.mkdirSync(d, { recursive: true }); return d } |
| 31 | const sleep = (ms) => new Promise(r => setTimeout(r, ms)) |
| 32 | |
| 33 | // Build a DID spec: distinct room secrets + a creator (lowest key) + a member, both attested to ROOT. |
| 34 | async function mkDid (rootPub, m0, m1) { |
| 35 | let cd = await deviceKeetKeyPair(m0); let cp = await attestDeviceChain({ rootMnemonic: ROOT, devicePublicKey: cd.publicKey }) |
| 36 | let md = await deviceKeetKeyPair(m1); let mp = await attestDeviceChain({ rootMnemonic: ROOT, devicePublicKey: md.publicKey }) |
| 37 | let creator = { dev: cd, proof: cp }; let member = { dev: md, proof: mp } |
| 38 | if (b4a.compare(creator.dev.publicKey, member.dev.publicKey) > 0) { const t = creator; creator = member; member = t } // creator = lowest key |
| 39 | const members = [ |
| 40 | { key: b4a.toString(creator.dev.publicKey, 'hex'), role: 1 }, |
| 41 | { key: b4a.toString(member.dev.publicKey, 'hex'), role: 1 } |
| 42 | ] |
| 43 | return { rootPub, creator, member, members, roomSeed: crypto.randomBytes(32), roomKey: crypto.randomBytes(32), encryptionKey: crypto.randomBytes(32) } |
| 44 | } |
| 45 | |
| 46 | // A StateBaseV3 on a SHARED store + SHARED wakeup, network:false (daemon-driven replication). |
| 47 | function mkSb (store, wakeup, did, who) { |
| 48 | const dev = did[who] |
| 49 | return new StateBaseV3(null, did.roomKey, { |
| 50 | store, wakeup, network: false, |
| 51 | roomSeed: did.roomSeed, encryptionKey: did.encryptionKey, |
| 52 | selfKey: dev.dev.publicKey, attest: makeAttest(dev.dev, dev.proof), verifyAttest: makeVerifyAttest(did.rootPub), |
| 53 | getMembers: async () => did.members |
| 54 | }) |
| 55 | } |
| 56 | |
| 57 | // Wire the daemon side: one connection handler that muxes EVERY DID's cores + wakeup, then join each |
| 58 | // DID's base topic. This is exactly what a multi-DID hpp daemon does. |
| 59 | function wireDaemon (swarm, store, wakeup, sbs) { |
| 60 | swarm.on('connection', (conn) => { |
| 61 | conn.on('error', () => {}) |
| 62 | try { wakeup.addStream(conn) } catch {} |
| 63 | try { store.replicate(conn) } catch {} |
| 64 | }) |
| 65 | for (const sb of sbs) swarm.join(sb.base.discoveryKey, { server: true, client: true }) |
| 66 | } |
| 67 | |
| 68 | test('2 DIDs on ONE shared store/swarm/wakeup per machine: admission + isolation + one lock', { timeout: 120000 }, async (t) => { |
| 69 | const testnet = await createTestnet(3) |
| 70 | const rootPub = await rootIdentityPublicKey(ROOT) |
| 71 | const A = await mkDid(rootPub, MNEMS[0], MNEMS[1]) |
| 72 | const B = await mkDid(rootPub, MNEMS[2], MNEMS[3]) |
| 73 | |
| 74 | // ── Machine 1 (creators): ONE store, ONE swarm, ONE wakeup ── |
| 75 | const dir1 = tmp() |
| 76 | const store1 = new Corestore(dir1); await store1.ready() |
| 77 | const swarm1 = new Hyperswarm({ bootstrap: testnet.bootstrap }) |
| 78 | const wakeup1 = new ProtomuxWakeup() |
| 79 | const cA = mkSb(store1, wakeup1, A, 'creator'); await cA.ready() |
| 80 | const cB = mkSb(store1, wakeup1, B, 'creator'); await cB.ready() |
| 81 | await cA.projectMembers(A.members); await cB.projectMembers(B.members) |
| 82 | wireDaemon(swarm1, store1, wakeup1, [cA, cB]) |
| 83 | await swarm1.flush().catch(() => {}) |
| 84 | |
| 85 | // ── Machine 2 (members): ONE store, ONE swarm, ONE wakeup ── |
| 86 | const dir2 = tmp() |
| 87 | const store2 = new Corestore(dir2); await store2.ready() |
| 88 | const swarm2 = new Hyperswarm({ bootstrap: testnet.bootstrap }) |
| 89 | const wakeup2 = new ProtomuxWakeup() |
| 90 | const mA = mkSb(store2, wakeup2, A, 'member'); await mA.ready() |
| 91 | const mB = mkSb(store2, wakeup2, B, 'member'); await mB.ready() |
| 92 | wireDaemon(swarm2, store2, wakeup2, [mA, mB]) |
| 93 | await swarm2.flush().catch(() => {}) |
| 94 | |
| 95 | t.teardown(async () => { |
| 96 | for (const sb of [cA, cB, mA, mB]) { try { await sb.close() } catch {} } |
| 97 | try { await store1.close() } catch {}; try { await store2.close() } catch {} |
| 98 | try { await swarm1.destroy() } catch {}; try { await swarm2.destroy() } catch {} |
| 99 | await testnet.destroy() |
| 100 | }) |
| 101 | |
| 102 | t.ok(cA.base.writable && cB.base.writable, 'both creators writable on ONE shared store (machine 1)') |
| 103 | t.absent(mA.base.writable, 'member A starts non-writable (awaiting admission)') |
| 104 | t.absent(mB.base.writable, 'member B starts non-writable (awaiting admission)') |
| 105 | |
| 106 | // ── ADMISSION: each member writes → its own DID's creator admits it (encrypted optimistic doorbell |
| 107 | // over the SHARED wakeup). Distinct content per DID. ── |
| 108 | await mA.set('post/1', { did: 'A', text: 'hello-from-A' }) |
| 109 | await mB.set('post/1', { did: 'B', text: 'hello-from-B' }) |
| 110 | t.ok(mA.base.writable, 'member A got ADMITTED over the shared connection (DID A)') |
| 111 | t.ok(mB.base.writable, 'member B got ADMITTED over the shared connection (DID B)') |
| 112 | |
| 113 | // converge |
| 114 | for (let i = 0; i < 16; i++) { for (const sb of [cA, cB, mA, mB]) await sb.base.update().catch(() => {}); await sleep(300) } |
| 115 | |
| 116 | // ── ISOLATION: each DID's data is its own; no cross-tenant bleed ── |
| 117 | t.alike(await mA.get('post/1'), { did: 'A', text: 'hello-from-A' }, 'member A reads its own durable write back') |
| 118 | t.alike(await cA.get('post/1'), { did: 'A', text: 'hello-from-A' }, 'creator A converged with A\'s write') |
| 119 | t.alike(await cB.get('post/1'), { did: 'B', text: 'hello-from-B' }, 'creator B converged with B\'s write') |
| 120 | t.absent(await cA.get('post/1') && (await cA.get('post/1')).did === 'B', 'DID A base has NO DID B data (isolation)') |
| 121 | t.is((await cB.get('post/1')).did, 'B', 'DID B base holds only B\'s record (isolation)') |
| 122 | |
| 123 | // ── ONE lock per machine (per-directory) ── |
| 124 | const locks1 = fs.readdirSync(dir1).filter(f => /CORESTORE/i.test(f)) |
| 125 | const locks2 = fs.readdirSync(dir2).filter(f => /CORESTORE/i.test(f)) |
| 126 | t.is(locks1.length, 1, 'machine 1: exactly ONE CORESTORE lock for both DIDs') |
| 127 | t.is(locks2.length, 1, 'machine 2: exactly ONE CORESTORE lock for both DIDs') |
| 128 | }) |