OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-base commits / 6c1959b

atproto-base: Phase 0 #2 — members-only encryption derived from the roomSeed
The base encryption key is now derived from the roomSeed (deriveEncKey = crypto.hash([ENC_NAMESPACE, roomSeed]), mirroring device-node.js's crypto.hash([NS('enc'), sharedKey])), so holding the roomSeed gives BOTH the base key AND the encryption key. A non-member has neither — they can't locate the base NOR decrypt it. Explicit encryptionKey still overrides (migration/BYO). Proven: co-owners derive the identical members-only key (derived, not the raw seed) and converge transparently; a non-member holding the (leaked) base key but a foreign key canNOT read the members-only content. Phase 0 gap #2 closed. test/encryption.test.mjs (2 tests / 5 asserts). Suite now 23 tests / 103 asserts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
6c1959bea2b30facff1c056761194f60cc7a0dfd
by plankatron <fleeky@gmail.com> · 2026-07-23T19:27:57.000Z

Changed files (2) +61 -1

lib/base.js +7 -1
……@@ -49,8 +49,13 @@ const toBuf = (k) => (Buffer.isBuffer(k) ? k : Buffer.from(String(k), 'hex'))
4949// writer — the deterministic genesis/bootstrap indexer; other co-owners open with a fresh writer and
5050// join via admission. gsNamespace makes the key roomSeed-space-specific (override for migration).
5151const GS_NAMESPACE = Buffer.from('atproto-base/state/v1', 'utf-8')
52const ENC_NAMESPACE = Buffer.from('atproto-base/enc/v1', 'utf-8') // members-only encryption-key namespace
5253function deriveGsKeyPair (roomSeed, ns = GS_NAMESPACE) { return crypto.keyPair(crypto.hash([ns, toBuf(roomSeed)])) }
5354function deriveBaseKey (gsPublicKey) { return Hypercore.key({ version: 1, signers: [{ publicKey: gsPublicKey }] }) }
55// The members-only base encryption key, derived from the roomSeed (mirrors device-node.js enc derivation:
56// crypto.hash([NS('enc'), sharedKey])). Holding the roomSeed gives BOTH the base key AND the encryption
57// key — a non-member has neither, so they can neither locate nor decrypt the co-owned base.
58function deriveEncKey (roomSeed) { return crypto.hash([ENC_NAMESPACE, toBuf(roomSeed)]) }
5459
5560export class AtprotoBase {
5661 // opts: { path | store, bootstrap?, keyPair?, encryptionKey?, did, privateKey, identity?, members? }
……@@ -72,7 +77,8 @@ export class AtprotoBase {
7277 this._detBaseKey = this._gsKeyPair ? deriveBaseKey(this._gsKeyPair.publicKey) : null
7378 this._forceCreator = opts.forceCreator === true // assume-genesis heal (re-author on fresh storage)
7479 this._forceNonCreator = opts.forceNonCreator === true // resync heal (join as a fresh non-creator writer)
75 this._encryptionKey = opts.encryptionKey || null
80 // Members-only encryption: derive from the roomSeed unless an explicit key is given (migration/BYO).
81 this._encryptionKey = opts.encryptionKey ? toBuf(opts.encryptionKey) : (this._roomSeed ? deriveEncKey(this._roomSeed) : null)
7682 this._identity = opts.identity ? { publicKey: toBuf(opts.identity.publicKey), secretKey: toBuf(opts.identity.secretKey) } : null
7783 this._members = opts.members || null
7884 this._ackInterval = opts.ackInterval || 1000
test/encryption.test.mjs +54 -0
……@@ -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
5import test from 'brittle'
6import { mkdtempSync, rmSync } from 'node:fs'
7import { tmpdir } from 'node:os'
8import { join } from 'node:path'
9import crypto from 'hypercore-crypto'
10import { deriveAtprotoKey, createAtprotoIdentity } from 'atproto-identity'
11import { AtprotoBase } from '../lib/base.js'
12
13const MNEMONIC = 'test test test test test test test test test test test junk'
14const ID = createAtprotoIdentity(MNEMONIC, { handle: 'x.7rnx.net', pdsUrl: 'https://pds.7rnx.net' })
15const KEY = deriveAtprotoKey(MNEMONIC)
16const post = (text) => ({ $type: 'app.bsky.feed.post', text, createdAt: '2026-07-23T00:00:00.000Z', langs: ['en'] })
17const hex = (b) => b.toString('hex')
18const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
19function 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 }) } } }
20const 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 {} } }
21async 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
23test('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
36test('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})