OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-base commits / 21fa874

atproto-base: revocation — keet remove-member (ban + __removed__ re-admit gate)
Grounded in keet-core 4.16 members/index.js handler W (remove-member), read in full: a moderator+ deactivates a member — mark it in a durable __removed__ set (blocks re-admission even with a valid proof), drop it from the roster, and removeWriter ALL its devices (removeable-gated so quorum is never broken). An admin can only be removed by ITSELF (keet SELF_ONLY). _verifyAdmission now rejects any identity in __removed__. ⚠ ban removeWriters a device from THIS autobase; it does NOT revoke the shared #atproto key at bsky (federation revocation = a PLC rotation, a separate ceremony; architecture-holes #6). Documented in code. Proven: admin bans a member → writer removed + roster dropped + __removed__ + re-admit throws; can't ban another admin (SELF_ONLY). set-epoch (attestation-epoch mass-revocation) deferred — it's keet-attestation-specific and doesn't map to our raw-ed25519 admission proof. test/revocation.test.mjs (2 tests / 7 asserts). Suite now 17 tests / 70 asserts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
21fa874d0f6aa513d8cb17309891032015dd8b15
by plankatron <fleeky@gmail.com> · 2026-07-23T18:54:41.000Z

Changed files (2) +106 -1

lib/base.js +42 -1
……@@ -29,9 +29,13 @@ import { projectCommit, nextRev } from './project.js'
2929// verified by reading keet-core 4.16 members/index.js in full). This is NOT StateBaseV3's move-indexer,
3030// which was a MISREAD (it demoted the creator cross-member; keet's move-indexer is a same-member
3131// device→device handoff — deliberately NOT ported).
32const OP = { SET_RECORD: 'set-record', DEL_RECORD: 'del-record', ADMIT: 'admit', SET_MEMBERS: 'set-members', START_INDEXING: 'start-indexing', STOP_INDEXING: 'stop-indexing' }
32const OP = { SET_RECORD: 'set-record', DEL_RECORD: 'del-record', ADMIT: 'admit', SET_MEMBERS: 'set-members', START_INDEXING: 'start-indexing', STOP_INDEXING: 'stop-indexing', BAN: 'ban' }
3333const MEMBERS = '__members__' // reserved view key: { identityHex: role }
3434const DEVICES = '__devices__' // reserved view key: { writerKeyHex: identityHex }
35const REMOVED = '__removed__' // reserved view key: { identityHex: { ts } } — banned; blocks re-admission
36// NOTE: ban() removeWriters a device from THIS autobase (can't write the shared view) + blocks re-admit.
37// It does NOT revoke the SHARED #atproto signing key at the FEDERATION layer — a lost device still holds
38// that key until a PLC rotation (a separate, unbuilt ceremony; architecture-holes #6). Document loudly.
3539const RKEY = (collection, rkey) => `${collection}/${rkey}`
3640const toBuf = (k) => (Buffer.isBuffer(k) ? k : Buffer.from(String(k), 'hex'))
3741
……@@ -134,6 +138,31 @@ export class AtprotoBase {
134138 continue
135139 }
136140
141 if (op.t === OP.BAN) {
142 // keet members/index.js `W` (remove-member): a moderator+ deactivates a member — mark it removed
143 // (blocks re-admission), drop from the roster, and removeWriter ALL its devices (removeable-gated).
144 // An admin can only be removed by ITSELF (keet SELF_ONLY).
145 const fromKey = node.from && node.from.key
146 const target = String(op.target || '')
147 if (!fromKey || !target) continue
148 const fromHex = fromKey.toString('hex')
149 const callerRole = await this._writerRole(view, fromHex)
150 if (callerRole == null || callerRole > 2) continue // must be admin(1) or moderator(2)
151 const drec = await view.get(DEVICES); const dev = (drec && drec.value) || {}
152 const mrec = await view.get(MEMBERS); const members = (mrec && mrec.value) || {}
153 const isSelf = dev[fromHex] === target
154 if (Number(members[target]) === 1 && !isSelf) continue // can't ban another admin (keet SELF_ONLY)
155 const rrec = await view.get(REMOVED); const removed = (rrec && rrec.value) || {}
156 removed[target] = { ts: op.ts || 0 }; await view.put(REMOVED, removed)
157 if (members[target] != null) { delete members[target]; await view.put(MEMBERS, members) }
158 for (const [writerHex, idHex] of Object.entries(dev)) {
159 if (idHex !== target) continue
160 try { const wk = b4a.from(writerHex, 'hex'); if (host.removeable(wk)) await host.removeWriter(wk) } catch {}
161 this._admitted.delete(writerHex)
162 }
163 continue
164 }
165
137166 if (op.t === OP.SET_RECORD) {
138167 const existing = await view.get(op.key)
139168 if (existing && existing.value && typeof existing.value.w === 'number' && existing.value.w > op.w) continue // LWW
……@@ -161,6 +190,8 @@ export class AtprotoBase {
161190 const signData = Buffer.concat([writerKey, this.base.key])
162191 const identity = Buffer.from(admit.identity, 'hex')
163192 if (!crypto.verify(signData, Buffer.from(admit.proof, 'hex'), identity)) return null
193 const rrec = await view.get(REMOVED); const removed = (rrec && rrec.value) || {}
194 if (removed[admit.identity]) return null // banned identity cannot (re)admit even with a valid proof
164195 const mrec = await view.get(MEMBERS)
165196 const members = (mrec && mrec.value) || {}
166197 const role = members[admit.identity]
……@@ -218,6 +249,16 @@ export class AtprotoBase {
218249 await this.base.update()
219250 }
220251
252 // Ban a member by identity (keet remove-member): removeWriter its devices from THIS autobase + block
253 // re-admission. Caller must be admin/moderator (enforced in apply). ⚠ Does NOT revoke the shared
254 // #atproto key at bsky — that needs a PLC rotation (separate ceremony). ts stays fixed (op-carried,
255 // applied identically on every co-owner) so governance state converges deterministically.
256 async ban (targetIdentityHex) {
257 await this.ensureWritable()
258 await this.base.append(b4a.from(JSON.stringify({ t: OP.BAN, target: String(targetIdentityHex), ts: 0 })))
259 await this.base.update()
260 }
261
221262 // Block until this co-owner is a genuinely ADMITTED writer, then return. No-op if already writable.
222263 // Rings the CONTENT-FREE admission doorbell and waits; THROWS if no indexer admits us within the
223264 // timeout (never a silent content loss). This is what makes putRecord/delRecord durable.
test/revocation.test.mjs +64 -0
……@@ -0,0 +1,64 @@
1// REVOCATION (keet remove-member). An admin bans a member: its writer is removeWritered from the
2// autobase, it's dropped from the roster, and it CANNOT re-admit (the __removed__ gate) even with a
3// valid proof. An admin cannot ban another admin (keet SELF_ONLY).
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(), 'rev-')); 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 }) } } }
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) } }
22const view = async (bs, key) => { const r = await bs.base.base.view.get(key); return r && r.value }
23
24test('ban: an admin removes a member — writer removed, roster dropped, re-admission blocked', async (t) => {
25 const cId = crypto.keyPair(); const mId = crypto.keyPair()
26 const roster = [{ key: hex(cId.publicKey), role: 1 }, { key: hex(mId.publicKey), role: 3 }]
27
28 const C = mk({ identity: cId, members: roster }); await C.base.ready()
29 const M = mk({ bootstrap: C.base.base.key, identity: mId }); await M.base.ready()
30 const unpipe = pipe(C, M)
31 t.teardown(async () => { unpipe(); await C.close(); await M.close() })
32
33 await C.base.projectMembers(roster); await settle(C, M)
34 await M.base.putRecord('app.bsky.feed.post', '3m', post('from M')); await settle(C, M)
35 t.ok(M.base.writable, 'M was an admitted writer before the ban')
36
37 await C.base.ban(hex(mId.publicKey)); await settle(C, M)
38
39 const members = await view(C, '__members__'); const removed = await view(C, '__removed__')
40 t.absent(members[hex(mId.publicKey)], 'M dropped from the roster')
41 t.ok(removed && removed[hex(mId.publicKey)], 'M recorded in the __removed__ set')
42 t.absent(M.base.writable, 'M is no longer a writer (removeWritered)')
43
44 // M cannot re-admit even with a valid proof — the __removed__ gate rejects it.
45 await t.exception(() => M.base.ensureWritable({ timeoutMs: 2500 }), 'banned member cannot re-admit (throws)')
46})
47
48test('ban: an admin cannot ban another admin (SELF_ONLY)', async (t) => {
49 const cId = crypto.keyPair(); const aId = crypto.keyPair()
50 const roster = [{ key: hex(cId.publicKey), role: 1 }, { key: hex(aId.publicKey), role: 1 }] // both admins
51
52 const C = mk({ identity: cId, members: roster }); await C.base.ready()
53 const A = mk({ bootstrap: C.base.base.key, identity: aId }); await A.base.ready()
54 const unpipe = pipe(C, A)
55 t.teardown(async () => { unpipe(); await C.close(); await A.close() })
56
57 await C.base.projectMembers(roster); await settle(C, A)
58 await A.base.putRecord('app.bsky.feed.post', '3a', post('from A')); await settle(C, A)
59
60 await C.base.ban(hex(aId.publicKey)); await settle(C, A)
61 const members = await view(C, '__members__'); const removed = await view(C, '__removed__')
62 t.ok(members[hex(aId.publicKey)], 'the other admin is STILL in the roster (SELF_ONLY blocked the ban)')
63 t.absent(removed && removed[hex(aId.publicKey)], 'the other admin was NOT added to __removed__')
64})