OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-base commits / 6c30abb

atproto-base: Phase 0 #1 — deterministic base key (co-owners share a base via roomSeed)
Ported byte-exact from atproto-room/StateBaseV3: every co-owner holding the members-only roomSeed derives the IDENTICAL autobase base key (deriveGsKeyPair(roomSeed, GS_NAMESPACE) -> deriveBaseKey via Hypercore.key), so they open the SAME base with NO shared bootstrap. The CREATOR (lowest-key admin, or forceCreator) opens as gsKeyPair (the bootstrap indexer); others open a fresh writer and join via admission. forceCreator/forceNonCreator postures added for the fresh-corestore resync heal. Deterministic creator election (_amCreator, hex-ordered). The path is opt-in via `roomSeed` and OVERRIDES an explicit bootstrap/keyPair; the existing explicit-bootstrap path is unchanged (all prior tests still green). Proven: two co-owners, same roomSeed, no shared bootstrap -> identical base key, one bootstrap creator, joiner admitted, converge to identical head; different roomSeed -> different base. First blocking gap of EXTRACTION-PLAN Phase 0 closed. test/deterministic-key.test.mjs (2 tests / 5 asserts). Suite now 21 tests / 98 asserts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
6c30abbd5e01413dea92cbec53a2a321a63bbfe6
by plankatron <fleeky@gmail.com> · 2026-07-23T19:22:42.000Z

Changed files (2) +104 -2

lib/base.js +43 -2
……@@ -19,6 +19,7 @@
1919import Corestore from 'corestore'
2020import Autobase from 'autobase'
2121import Hyperbee from 'hyperbee'
22import Hypercore from 'hypercore'
2223import b4a from 'b4a'
2324import crypto from 'hypercore-crypto'
2425import { FirehoseSequencer } from 'atproto-store/lib/sequencer.js'
……@@ -42,6 +43,15 @@ const REMOVED = '__removed__' // reserved view key: { identityHex: { ts } } —
4243const RKEY = (collection, rkey) => `${collection}/${rkey}`
4344const toBuf = (k) => (Buffer.isBuffer(k) ? k : Buffer.from(String(k), 'hex'))
4445
46// DETERMINISTIC base key (ported byte-exact from atproto-room/StateBaseV3): every co-owner that holds
47// the members-only `roomSeed` derives the SAME autobase base key, so they open the SAME base with NO
48// shared bootstrap. The CREATOR (lowest-key admin, or forceCreator) opens with `gsKeyPair` as its local
49// writer — the deterministic genesis/bootstrap indexer; other co-owners open with a fresh writer and
50// join via admission. gsNamespace makes the key roomSeed-space-specific (override for migration).
51const GS_NAMESPACE = Buffer.from('atproto-base/state/v1', 'utf-8')
52function deriveGsKeyPair (roomSeed, ns = GS_NAMESPACE) { return crypto.keyPair(crypto.hash([ns, toBuf(roomSeed)])) }
53function deriveBaseKey (gsPublicKey) { return Hypercore.key({ version: 1, signers: [{ publicKey: gsPublicKey }] }) }
54
4555export class AtprotoBase {
4656 // opts: { path | store, bootstrap?, keyPair?, encryptionKey?, did, privateKey, identity?, members? }
4757 // identity: { publicKey, secretKey } — this co-owner's membership key (ed25519, drives admission).
……@@ -55,6 +65,13 @@ export class AtprotoBase {
5565 this._path = opts.path || null
5666 this._bootstrap = opts.bootstrap || null
5767 this._keyPair = opts.keyPair || null
68 // Deterministic base key from a members-only roomSeed (co-owners share the base without a bootstrap).
69 this._roomSeed = opts.roomSeed ? toBuf(opts.roomSeed) : null
70 this._gsNamespace = opts.gsNamespace ? toBuf(opts.gsNamespace) : GS_NAMESPACE
71 this._gsKeyPair = this._roomSeed ? deriveGsKeyPair(this._roomSeed, this._gsNamespace) : null
72 this._detBaseKey = this._gsKeyPair ? deriveBaseKey(this._gsKeyPair.publicKey) : null
73 this._forceCreator = opts.forceCreator === true // assume-genesis heal (re-author on fresh storage)
74 this._forceNonCreator = opts.forceNonCreator === true // resync heal (join as a fresh non-creator writer)
5875 this._encryptionKey = opts.encryptionKey || null
5976 this._identity = opts.identity ? { publicKey: toBuf(opts.identity.publicKey), secretKey: toBuf(opts.identity.secretKey) } : null
6077 this._members = opts.members || null
……@@ -80,9 +97,19 @@ export class AtprotoBase {
8097 ackInterval: this._ackInterval,
8198 ackThreshold: 4
8299 }
83 if (this._keyPair) baseOpts.keyPair = this._keyPair
100 // Resolve bootstrap + local writer keypair. The deterministic-base-key path (roomSeed) OVERRIDES an
101 // explicit bootstrap/keyPair: every co-owner opens the same base; the CREATOR (lowest-key admin, or
102 // forceCreator) writes as gsKeyPair (the bootstrap indexer), others open a fresh writer.
103 let bootstrap = this._bootstrap
104 let keyPair = this._keyPair
105 if (this._detBaseKey) {
106 const amCreator = this._forceCreator ? true : (this._forceNonCreator ? false : this._amCreator())
107 bootstrap = this._detBaseKey
108 keyPair = amCreator ? this._gsKeyPair : null
109 }
110 if (keyPair) baseOpts.keyPair = keyPair
84111 if (this._encryptionKey) { baseOpts.encryptionKey = this._encryptionKey; baseOpts.encrypted = true }
85 this.base = new Autobase(this.store, this._bootstrap, baseOpts)
112 this.base = new Autobase(this.store, bootstrap, baseOpts)
86113 await this.base.ready()
87114 // The public firehose: a plain Hypercore of #commit/#sync frames the bridge replicates + serves as
88115 // com.atproto.sync.subscribeRepos. Separate from the autobase (a projection of it), like the membrane.
……@@ -217,6 +244,20 @@ export class AtprotoBase {
217244 return role != null ? Number(role) : null
218245 }
219246
247 // Deterministic creator election (ported from StateBaseV3 _amCreator): the LOWEST-key eligible
248 // (admin/moderator) member is the bootstrap creator; hex-ordered so every co-owner agrees offline. No
249 // members/identity → default to creator (single-owner DID). Used only on the deterministic-base-key path.
250 _amCreator () {
251 if (!this._identity || !Array.isArray(this._members) || this._members.length === 0) return true
252 const selfHex = this._identity.publicKey.toString('hex').toLowerCase()
253 const eligible = this._members
254 .map((m) => ({ key: toBuf(m.key).toString('hex').toLowerCase(), role: Number(m.role) }))
255 .filter((m) => m.key && (m.role === 1 || m.role === 2))
256 .sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0))
257 if (eligible.length === 0) return true
258 return eligible[0].key === selfHex
259 }
260
220261 // The creator/admin projects the roster into the shared view (memberKey→role) + its own device
221262 // mapping (writerKey→identity), so admission can resolve a joiner's role deterministically in apply.
222263 async projectMembers (members) {
test/deterministic-key.test.mjs +61 -0
……@@ -0,0 +1,61 @@
1// PHASE 0 — DETERMINISTIC BASE KEY. Two co-owners holding the same members-only `roomSeed` derive the
2// IDENTICAL autobase base key independently (no shared bootstrap), open the SAME base, and converge —
3// the lowest-key admin is the bootstrap creator; the other joins via admission. This is what lets a
4// co-owned DID exist without one peer handing the other a bootstrap key out-of-band.
5
6import test from 'brittle'
7import { mkdtempSync, rmSync } from 'node:fs'
8import { tmpdir } from 'node:os'
9import { join } from 'node:path'
10import crypto from 'hypercore-crypto'
11import { deriveAtprotoKey, createAtprotoIdentity } from 'atproto-identity'
12import { AtprotoBase } from '../lib/base.js'
13
14const MNEMONIC = 'test test test test test test test test test test test junk'
15const ID = createAtprotoIdentity(MNEMONIC, { handle: 'x.7rnx.net', pdsUrl: 'https://pds.7rnx.net' })
16const KEY = deriveAtprotoKey(MNEMONIC)
17const post = (text) => ({ $type: 'app.bsky.feed.post', text, createdAt: '2026-07-23T00:00:00.000Z', langs: ['en'] })
18const hex = (b) => b.toString('hex')
19const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
20function mk (opts) { const dir = mkdtempSync(join(tmpdir(), 'detkey-')); const base = new AtprotoBase({ path: dir, did: ID.did, privateKey: KEY.privateKey, ackInterval: 100, ...opts }); return { base, dir, close: async () => { await base.close(); rmSync(dir, { recursive: true, force: true }) } } }
21const 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 {} } }
22async 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) } }
23
24test('deterministic base key: same roomSeed → same base key, no shared bootstrap, converges', async (t) => {
25 const roomSeed = Buffer.alloc(32, 7) // the members-only secret both hold
26 const idA = crypto.keyPair(); const idB = crypto.keyPair()
27 const roster = [{ key: hex(idA.publicKey), role: 1 }, { key: hex(idB.publicKey), role: 1 }] // both admins
28
29 // Both open with ONLY the roomSeed + roster — NO bootstrap passed between them.
30 const A = mk({ roomSeed, identity: idA, members: roster })
31 const B = mk({ roomSeed, identity: idB, members: roster })
32 await A.base.ready(); await B.base.ready()
33 const unpipe = pipe(A, B)
34 t.teardown(async () => { unpipe(); await A.close(); await B.close() })
35
36 t.is(A.base.base.key.toString('hex'), B.base.base.key.toString('hex'), 'both independently derived the IDENTICAL base key from the roomSeed')
37 t.is([A, B].filter((x) => x.base.writable).length, 1, 'exactly ONE is the bootstrap creator (lowest-key admin) at boot')
38
39 const creator = A.base.writable ? A : B
40 const joiner = A.base.writable ? B : A
41 await creator.base.projectMembers(roster); await settle(A, B)
42
43 // Both post: creator directly; joiner via block-until-admitted (its record is still finalized).
44 await creator.base.putRecord('app.bsky.feed.post', '3c', post('creator'))
45 await joiner.base.putRecord('app.bsky.feed.post', '3j', post('joiner'))
46 await settle(A, B); await A.base.settled(); await B.base.settled()
47
48 t.ok(joiner.base.writable, 'the joiner was admitted onto the shared base (derived, not bootstrapped)')
49 const hA = await A.base.head(); const hB = await B.base.head()
50 t.is(hA.commitCid.toString(), hB.commitCid.toString(), 'co-owners converge to the identical finalized head')
51})
52
53test('deterministic base key: a DIFFERENT roomSeed yields a different base (isolation)', async (t) => {
54 const idA = crypto.keyPair()
55 const roster = [{ key: hex(idA.publicKey), role: 1 }]
56 const A = mk({ roomSeed: Buffer.alloc(32, 1), identity: idA, members: roster })
57 const B = mk({ roomSeed: Buffer.alloc(32, 2), identity: idA, members: roster })
58 await A.base.ready(); await B.base.ready()
59 t.teardown(async () => { await A.close(); await B.close() })
60 t.not(A.base.base.key.toString('hex'), B.base.base.key.toString('hex'), 'different roomSeed → different base key')
61})