| … | … | @@ -0,0 +1,54 @@ |
| 1 | // PHASE 0 #2 — MEMBERS-ONLY ENCRYPTION. The base encryption key is derived from the roomSeed (the |
| 2 | // members-only secret), so co-owners transparently share it, and a non-member — who has neither the |
| 3 | // roomSeed nor the key — cannot read the co-owned content even if the (public-ish) base key leaks. |
| 4 | |
| 5 | import test from 'brittle' |
| 6 | import { mkdtempSync, rmSync } from 'node:fs' |
| 7 | import { tmpdir } from 'node:os' |
| 8 | import { join } from 'node:path' |
| 9 | import crypto from 'hypercore-crypto' |
| 10 | import { deriveAtprotoKey, createAtprotoIdentity } from 'atproto-identity' |
| 11 | import { AtprotoBase } from '../lib/base.js' |
| 12 | |
| 13 | const MNEMONIC = 'test test test test test test test test test test test junk' |
| 14 | const ID = createAtprotoIdentity(MNEMONIC, { handle: 'x.7rnx.net', pdsUrl: 'https://pds.7rnx.net' }) |
| 15 | const KEY = deriveAtprotoKey(MNEMONIC) |
| 16 | const post = (text) => ({ $type: 'app.bsky.feed.post', text, createdAt: '2026-07-23T00:00:00.000Z', langs: ['en'] }) |
| 17 | const hex = (b) => b.toString('hex') |
| 18 | const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) |
| 19 | function mk (opts) { const dir = mkdtempSync(join(tmpdir(), 'enc-')); const base = new AtprotoBase({ path: dir, did: ID.did, privateKey: KEY.privateKey, ackInterval: 100, ...opts }); return { base, dir, close: async () => { try { await base.close() } catch {} ; rmSync(dir, { recursive: true, force: true }) } } } |
| 20 | const pipe = (a, b) => { const s1 = a.base.base.replicate(true); const s2 = b.base.base.replicate(false); s1.pipe(s2).pipe(s1); return () => { try { s1.destroy() } catch {} ; try { s2.destroy() } catch {} } } |
| 21 | async function settle (...ws) { for (let i = 0; i < 20; i++) { for (const w of ws) { try { await w.base.base.update() } catch {} } await sleep(60) } } |
| 22 | |
| 23 | test('encryption: co-owners derive the identical members-only key from the roomSeed', async (t) => { |
| 24 | const roomSeed = Buffer.alloc(32, 5) |
| 25 | const idA = crypto.keyPair(); const idB = crypto.keyPair() |
| 26 | const roster = [{ key: hex(idA.publicKey), role: 1 }, { key: hex(idB.publicKey), role: 1 }] |
| 27 | const A = mk({ roomSeed, identity: idA, members: roster }); const B = mk({ roomSeed, identity: idB, members: roster }) |
| 28 | await A.base.ready(); await B.base.ready() |
| 29 | t.teardown(async () => { await A.close(); await B.close() }) |
| 30 | |
| 31 | t.ok(A.base._encryptionKey && B.base._encryptionKey, 'both derived an encryption key from the roomSeed') |
| 32 | t.is(A.base._encryptionKey.toString('hex'), B.base._encryptionKey.toString('hex'), 'the derived members-only key is identical') |
| 33 | t.absent(A.base._encryptionKey.equals(roomSeed), 'the encryption key is DERIVED (hashed), not the raw roomSeed') |
| 34 | }) |
| 35 | |
| 36 | test('encryption: a non-member with the base key but not the roomSeed CANNOT read the content', async (t) => { |
| 37 | const roomSeed = Buffer.alloc(32, 5) |
| 38 | const idA = crypto.keyPair() |
| 39 | const A = mk({ roomSeed, identity: idA, members: [{ key: hex(idA.publicKey), role: 1 }] }) |
| 40 | await A.base.ready() |
| 41 | await A.base.putRecord('app.bsky.feed.post', '3secret', post('members only')) |
| 42 | await A.base.settled() |
| 43 | t.is((await A.base.indexedRecords()).length, 1, 'A (a member) reads its own record') |
| 44 | |
| 45 | // X: knows the base key (as if leaked) but NOT the roomSeed → a wrong/foreign encryption key. |
| 46 | const idX = crypto.keyPair() |
| 47 | const X = mk({ bootstrap: A.base.base.key, encryptionKey: Buffer.alloc(32, 99), identity: idX }) |
| 48 | let xRecords = 'unopened'; let un = null |
| 49 | try { await X.base.ready(); un = pipe(A, X); await settle(A, X); xRecords = await X.base.indexedRecords() } catch { xRecords = 'error' } |
| 50 | t.teardown(async () => { if (un) un(); await A.close(); await X.close() }) |
| 51 | |
| 52 | const leaked = Array.isArray(xRecords) && xRecords.some((r) => r && r.value && r.value.text === 'members only') |
| 53 | t.absent(leaked, 'the non-member could NOT read the members-only record (encryption gates content even with the base key)') |
| 54 | }) |