OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-base commits / 3986d85

atproto-base: Phase 0 #3 — keet-attestation admission (injectable attest/verifyAttest)
Real co-owner devices admit via a DEVICE (attested to the member identity) signing the doorbell, not the member key directly. atproto-base now threads injectable attest/verifyAttest — the model hpp uses (device-node.js:131 attest: makeAttest(deviceKp, proof), from atproto-room/lib/membership.js). Contract (from StateBaseV3): attest(signData)->Buffer; verifyAttest(proof, signData, identity, receipt)->bool. When absent, admission falls back to raw ed25519 (unchanged). Identity secretKey is now optional (the attested device signs, not the member key). _buildAdmissionProof is async; call site awaited. (SET_EPOCH/receipt mass-revocation still deferred — passes null.) Proven: an attested device is admitted + its write converges; a device NOT attested to the member is rejected. Raw-path governance still green. Phase 0 gap #3 closed. test/attestation.test.mjs (2 tests / 4 asserts). Suite now 25 tests / 107 asserts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
3986d85c75dff2435aa3d8e8ff4e2faf8f95a3e4
by plankatron <fleeky@gmail.com> · 2026-07-23T19:49:33.000Z

Changed files (2) +91 -4

lib/base.js +21 -4
……@@ -79,8 +79,15 @@ export class AtprotoBase {
7979 this._forceNonCreator = opts.forceNonCreator === true // resync heal (join as a fresh non-creator writer)
8080 // Members-only encryption: derive from the roomSeed unless an explicit key is given (migration/BYO).
8181 this._encryptionKey = opts.encryptionKey ? toBuf(opts.encryptionKey) : (this._roomSeed ? deriveEncKey(this._roomSeed) : null)
82 this._identity = opts.identity ? { publicKey: toBuf(opts.identity.publicKey), secretKey: toBuf(opts.identity.secretKey) } : null
82 this._identity = opts.identity ? { publicKey: toBuf(opts.identity.publicKey), secretKey: opts.identity.secretKey ? toBuf(opts.identity.secretKey) : null } : null
8383 this._members = opts.members || null
84 // keet-attestation admission (the model hpp uses): injectable attest/verifyAttest (from
85 // atproto-room/lib/membership.js makeAttest/makeVerifyAttest — a device attested to the root
86 // identity). Contract (from StateBaseV3): attest(signData)->Buffer proof; verifyAttest(proof,
87 // signData, identity, receipt)->bool. When absent, admission falls back to raw ed25519 (identity
88 // key signs localKey‖base.key). All co-owners must be configured the SAME way.
89 this._attest = typeof opts.attest === 'function' ? opts.attest : null
90 this._verifyAttest = typeof opts.verifyAttest === 'function' ? opts.verifyAttest : null
8491 this._ackInterval = opts.ackInterval || 1000
8592 this._admitted = new Set()
8693 this._firehose = null // FirehoseSequencer (Hypercore of #commit frames)
……@@ -219,9 +226,15 @@ export class AtprotoBase {
219226 get writable () { return this.base ? this.base.writable : false }
220227
221228 // ── Governance: admission (golden rule = admit FIRST, write AFTER) ──────────────────────────────
222 _buildAdmissionProof () {
229 async _buildAdmissionProof () {
223230 if (!this._identity || !this.base || !this.base.local || !this.base.local.key || !this.base.key) return null
224231 const signData = Buffer.concat([this.base.local.key, this.base.key]) // bound to THIS base — not replayable
232 if (this._attest) { // keet-attestation: a device attested to the member identity signs signData
233 const proof = await this._attest(signData)
234 if (!proof) return null
235 return { identity: this._identity.publicKey.toString('hex'), proof: (Buffer.isBuffer(proof) ? proof : Buffer.from(proof)).toString('hex') }
236 }
237 if (!this._identity.secretKey) return null // raw path needs the identity secret key
225238 const sig = crypto.sign(signData, this._identity.secretKey)
226239 return { identity: this._identity.publicKey.toString('hex'), proof: sig.toString('hex') }
227240 }
……@@ -230,7 +243,11 @@ export class AtprotoBase {
230243 if (!admit || !admit.identity || !admit.proof) return null
231244 const signData = Buffer.concat([writerKey, this.base.key])
232245 const identity = Buffer.from(admit.identity, 'hex')
233 if (!crypto.verify(signData, Buffer.from(admit.proof, 'hex'), identity)) return null
246 const proof = Buffer.from(admit.proof, 'hex')
247 // Verify via the injected keet-attestation verifier if configured, else raw ed25519. (receipt/epoch
248 // = null for now — the SET_EPOCH mass-revocation is deferred, EXTRACTION-PLAN note.)
249 const ok = this._verifyAttest ? await this._verifyAttest(proof, signData, identity, null) : crypto.verify(signData, proof, identity)
250 if (!ok) return null
234251 const rrec = await view.get(REMOVED); const removed = (rrec && rrec.value) || {}
235252 if (removed[admit.identity]) return null // banned identity cannot (re)admit even with a valid proof
236253 const mrec = await view.get(MEMBERS)
……@@ -324,7 +341,7 @@ export class AtprotoBase {
324341 }
325342
326343 async _requestWriter ({ timeoutMs = 15000 } = {}) {
327 const admit = this._buildAdmissionProof()
344 const admit = await this._buildAdmissionProof()
328345 if (!admit) throw new Error('AtprotoBase: no identity to build an admission proof')
329346 const ring = () => this.base.append(b4a.from(JSON.stringify({ t: OP.ADMIT, admit })), { optimistic: true }).catch(() => {})
330347 await ring(); await this._updateBounded()
test/attestation.test.mjs +70 -0
……@@ -0,0 +1,70 @@
1// PHASE 0 #3 — keet-ATTESTATION ADMISSION. Real co-owner devices don't sign admission with the member
2// key directly; a DEVICE (attested to the member identity) signs, and the verifier checks the
3// attestation. atproto-base threads injectable attest/verifyAttest (the model hpp uses via
4// atproto-room/lib/membership.js). Here a mock attestation scheme exercises the wiring: a device
5// attested to its member is admitted; a device NOT attested is rejected.
6
7import test from 'brittle'
8import { mkdtempSync, rmSync } from 'node:fs'
9import { tmpdir } from 'node:os'
10import { join } from 'node:path'
11import crypto from 'hypercore-crypto'
12import { deriveAtprotoKey, createAtprotoIdentity } from 'atproto-identity'
13import { AtprotoBase } from '../lib/base.js'
14
15const MNEMONIC = 'test test test test test test test test test test test junk'
16const ID = createAtprotoIdentity(MNEMONIC, { handle: 'x.7rnx.net', pdsUrl: 'https://pds.7rnx.net' })
17const KEY = deriveAtprotoKey(MNEMONIC)
18const post = (text) => ({ $type: 'app.bsky.feed.post', text, createdAt: '2026-07-23T00:00:00.000Z', langs: ['en'] })
19const hex = (b) => b.toString('hex')
20const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
21function mk (opts) { const dir = mkdtempSync(join(tmpdir(), 'att-')); 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 }) } } }
22const 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 {} } }
23async 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) } }
24
25// Mock keet-attestation: a member key ATTESTS a device (cert = member signs deviceKey). attest signs
26// signData with the device; verifyAttest checks (a) the member attested the device, (b) the device
27// signed signData. Contract: attest(signData)->Buffer; verifyAttest(proof, signData, identity)->bool.
28const makeAttest = (deviceKp, memberKp) => {
29 const cert = crypto.sign(deviceKp.publicKey, memberKp.secretKey)
30 return async (signData) => Buffer.from(JSON.stringify({ device: hex(deviceKp.publicKey), cert: hex(cert), sig: hex(crypto.sign(signData, deviceKp.secretKey)) }))
31}
32const makeUnattested = (deviceKp) => { // cert signed by a RANDOM key → the member never attested this device
33 const cert = crypto.sign(deviceKp.publicKey, crypto.keyPair().secretKey)
34 return async (signData) => Buffer.from(JSON.stringify({ device: hex(deviceKp.publicKey), cert: hex(cert), sig: hex(crypto.sign(signData, deviceKp.secretKey)) }))
35}
36const verifyAttest = async (proof, signData, identity) => {
37 try {
38 const p = JSON.parse(proof.toString()); const device = Buffer.from(p.device, 'hex')
39 if (!crypto.verify(device, Buffer.from(p.cert, 'hex'), identity)) return false // did `identity` attest this device?
40 return crypto.verify(signData, Buffer.from(p.sig, 'hex'), device) // did the device sign signData?
41 } catch { return false }
42}
43
44test('keet-attestation: an attested device is admitted and its write converges', async (t) => {
45 const cM = crypto.keyPair(); const cD = crypto.keyPair(); const jM = crypto.keyPair(); const jD = crypto.keyPair()
46 const roster = [{ key: hex(cM.publicKey), role: 1 }, { key: hex(jM.publicKey), role: 1 }]
47
48 const C = mk({ identity: { publicKey: cM.publicKey }, members: roster, attest: makeAttest(cD, cM), verifyAttest }); await C.base.ready()
49 const J = mk({ bootstrap: C.base.base.key, identity: { publicKey: jM.publicKey }, attest: makeAttest(jD, jM), verifyAttest }); await J.base.ready()
50 const un = pipe(C, J); t.teardown(async () => { un(); await C.close(); await J.close() })
51
52 await C.base.projectMembers(roster); await settle(C, J)
53 await J.base.putRecord('app.bsky.feed.post', '3j', post('attested joiner'))
54 t.ok(J.base.writable, 'attested device was admitted (keet-attestation proof accepted)')
55 await settle(C, J); await C.base.settled()
56 t.ok((await C.base.indexedRecords()).find((r) => r.key.endsWith('3j')), "joiner's record converged into the shared view")
57})
58
59test('keet-attestation: a device NOT attested to the member is rejected', async (t) => {
60 const cM = crypto.keyPair(); const cD = crypto.keyPair(); const jM = crypto.keyPair(); const jD = crypto.keyPair()
61 const roster = [{ key: hex(cM.publicKey), role: 1 }, { key: hex(jM.publicKey), role: 1 }]
62
63 const C = mk({ identity: { publicKey: cM.publicKey }, members: roster, attest: makeAttest(cD, cM), verifyAttest }); await C.base.ready()
64 const J = mk({ bootstrap: C.base.base.key, identity: { publicKey: jM.publicKey }, attest: makeUnattested(jD), verifyAttest }); await J.base.ready()
65 const un = pipe(C, J); t.teardown(async () => { un(); await C.close(); await J.close() })
66
67 await C.base.projectMembers(roster); await settle(C, J)
68 await t.exception(() => J.base.ensureWritable({ timeoutMs: 2500 }), 'unattested device is rejected (throws — not admitted)')
69 t.absent(J.base.writable, 'never became a writer')
70})