OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-bridge commits / 03fc17c

a peer can ask this membrane to carry it, without an operator
`allowFront()` only UN-DENIES a firehose key that `dropFront({forget})` denied. A DID enters the fronted set through `add()`, reachable from `cfg.frontedDids` at boot or from the owner-gated control socket — so being carried was an operator action on the bridge host, and a peer holding a perfectly good identity had no way to ask. That is the same gap ap-bridge had for names, and it is the reason a DID minted on a phone could be published to the global directory and still be followable by nobody. ⛔ WHAT THE PROOF IS NOT FOR. This membrane is keyless: it resolves a DID's verify key from PLC and refuses any commit that does not verify against it. A caller naming somebody else's identity cannot make it serve a forgery — it would serve that person's real, signed repo. The proof bounds ABUSE: carrying an identity costs a topic join, a corestore namespace and replication for ever, and "anyone may spend that, anonymously and unboundedly" is not a policy anyone chose. ⚠ PERSISTED, because `frontedDids` is config and this is not. A restart that silently unfronted everyone who asked would make the whole door a lie. A front that cannot be persisted answers 202 rather than claiming durability it lacks. ⚠ The test runs under BARE and the pds suite runs under Node — favela-identity's attestation only loads under Bare. It is `npm run test:bare` rather than being quietly dropped from a glob that would never have executed it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
03fc17cb05af5adb03892623b076d4e0abddbea1
by plankatron <fleeky@gmail.com> · 2026-09-11T16:34:57.000Z

Changed files (5) +294 -2

package.json +3 -1
……@@ -47,7 +47,9 @@
4747 "protomux": "^3.11.0",
4848 "protomux-rpc": "^1.10.0",
4949 "random-access-memory": "^6.2.0",
50 "ws": "^8.18.0"
50 "ws": "^8.18.0",
51 "favela-identity": "file:../favela-identity",
52 "bare-crypto": "^1.5.0"
5153 },
5254 "devDependencies": {
5355 "@hyperswarm/testnet": "^3.0.0"
pds/bin/pds-serve.js +37 -0
……@@ -89,6 +89,43 @@ const peers = new DidPeerRouter(frontStore, {
8989 onHandles: persistHandles
9090})
9191
92// ⛔ THE SWARM DOOR onto fronting. `allowFront()` only UN-DENIES; a DID enters the fronted set
93// through `add()`, reachable from config at boot or the owner-gated control socket — so being
94// carried was an operator action and a peer had no way to ask. This is that way.
95//
96// ⚠ PERSISTED, exactly like the handle map above it: `frontedDids` is CONFIG, and what peers asked
97// for is not. A restart that silently unfronted everyone who asked would make the door a lie.
98const requestedFile = path.join(cfg.storage, 'fronted-requested.json')
99const readRequested = () => { try { return JSON.parse(fs.readFileSync(requestedFile, 'utf8')) } catch { return {} } }
100const requested = readRequested()
101for (const id of Object.keys(requested)) { try { peers.add(id) } catch {} }
102if (Object.keys(requested).length) console.log(JSON.stringify({ msg: 'refronted on boot', count: Object.keys(requested).length }))
103
104try {
105 const { serveFronting, Admission } = await import('../lib/fronting.js')
106 const door = serveFronting({
107 swarm,
108 peers,
109 admission: new Admission(),
110 policy: process.env.PDS_FRONT_POLICY || 'open',
111 onFronted: ({ id, root }) => {
112 const all = readRequested()
113 all[id] = { root, at: Date.now() }
114 fs.mkdirSync(cfg.storage, { recursive: true })
115 fs.writeFileSync(requestedFile, JSON.stringify(all, null, 2))
116 },
117 log: (m) => console.log(JSON.stringify(m))
118 })
119 await door.ready()
120 // ⛔ PRINTED: it is what a peer needs in order to find this bridge at all, and a key written
121 // nowhere is the favela-android device-key mistake one layer over.
122 console.log(JSON.stringify({ msg: 'fronting open over the swarm', bridgeKey: door.key }))
123} catch (err) {
124 // Non-fatal: the membrane serving what it already fronts is worth more than one that refuses to
125 // boot because a second door would not open.
126 console.log(JSON.stringify({ msg: 'swarm fronting unavailable', error: (err && err.message) || String(err) }))
127}
128
92129// 1. bsky HTTP+WS membrane — keyless per-DID routing to the fronted peers.
93130const server = createPdsServer({ peers, serverDid: cfg.serverDid, availableUserDomains: [cfg.userDomain], favelaWebUpstream: cfg.favelaWebUpstream })
94131await new Promise((r) => server.listen(cfg.port, cfg.bind, r))
pds/lib/fronting.js +126 -0
……@@ -0,0 +1,126 @@
1// fronting.js — asking this membrane to CARRY an identity, without an operator in the loop.
2//
3// ⛔ WHAT WAS MISSING. `allowFront()` only UN-DENIES a firehose key that `dropFront({forget})` denied;
4// it does not allow-list anything. `_isCared(did)` is `_fronted || _peers || _opts`, and a DID enters
5// `_fronted` through `add()` — reachable only from `cfg.frontedDids` at boot or from the owner-gated
6// control socket. So being carried was an operator action on the bridge host, and a peer holding a
7// perfectly good identity had no way to ask. That is the same gap ap-bridge had for names.
8//
9// ⛔ WHAT THE PROOF IS AND IS NOT FOR. This membrane is KEYLESS: it resolves a DID's verify key from
10// PLC and refuses any commit that does not verify against it. So a caller naming somebody else's
11// identity cannot make this bridge serve a forgery — it would serve that person's real, signed repo.
12// The proof is therefore not what protects the identity; the signature chain already does. It bounds
13// ABUSE: carrying an identity costs a topic join, a corestore namespace and replication for ever,
14// and "anyone may spend that, anonymously, unboundedly" is not a policy anyone chose.
15//
16// ⚠ Which is why refusing is cheap and being wrong is cheap. An identity wrongly carried serves
17// correct bytes to whoever asks; an identity wrongly refused just is not fronted here yet.
18
19import ProtomuxRPC from 'protomux-rpc'
20import c from 'compact-encoding'
21import b4a from 'b4a'
22import { Admission } from 'favela-identity/lib/admission.js'
23
24// ⚠ THE SAME PROTOCOL STRING as ap-bridge's registration door, deliberately: it is one ceremony with
25// one set of verbs, and a peer should not have to know which kind of bridge it is talking to before
26// it can ask what that bridge does.
27export const PROTOCOL = 'favela/bridge-register/1'
28
29export { Admission }
30
31/**
32 * The verbs, as plain async functions — testable without a DHT. The transport below is thin, and the
33 * rules are not in it.
34 */
35export function frontHandlers ({ peers, admission, onFronted = null, policy = 'open', log = () => {} }) {
36 return {
37 policy: async () => ({ policy, proof: !!admission }),
38
39 challenge: async () => {
40 if (!admission) throw new Error('fronting is not configured on this bridge')
41 return admission.challenge()
42 },
43
44 /**
45 * ⚠ IDEMPOTENT. `add()` on an already-fronted identity returns the existing peer, and a peer
46 * that retries after a dropped connection must not be told it did something wrong.
47 */
48 front: async (params = {}) => {
49 if (!peers) return { status: 503, body: { error: 'this bridge fronts nothing' } }
50 if (!admission) return { status: 503, body: { error: 'fronting is not configured on this bridge' } }
51 if (policy !== 'open') return { status: 403, body: { error: 'this bridge does not take requests' } }
52
53 const ok = admission.verifyFront({ nonce: params.nonce, id: params.id, root: params.root, proof: params.proof })
54 if (!ok.ok) return { status: 403, body: { error: ok.error } }
55
56 const already = peers.isFronted ? peers.isFronted(params.id) : false
57 try {
58 // ⛔ No verifyKey supplied — the lazy allow-set add. The key resolves from PLC when something
59 // actually asks for the repo, which is the keyless property: this bridge never holds one.
60 peers.add(params.id)
61 } catch (err) {
62 return { status: 500, body: { error: (err && err.message) || String(err) } }
63 }
64
65 // ⛔ PERSISTED BY THE CALLER, or a restart silently unfronts everyone who asked. `frontedDids`
66 // is config and this is not config — it is what peers requested, and it has to outlive a boot
67 // the same way the handle map does.
68 if (onFronted) {
69 try {
70 await onFronted({ id: params.id, root: ok.root })
71 } catch (err) {
72 // ⚠ It IS fronted at this point. Reporting failure would send a peer away from something
73 // that is working; reporting plain success would promise it survives a restart.
74 return { status: 202, body: { id: params.id, fronted: true, durable: false, reason: String(err.message || err) } }
75 }
76 }
77
78 log({ msg: 'fronting', id: params.id, root: ok.root, already })
79 return { status: 200, body: { id: params.id, fronted: true, durable: true, already } }
80 }
81 }
82}
83
84/**
85 * Serve those verbs to anyone who connects on this bridge's own key.
86 *
87 * ⛔ ON THE EXISTING SWARM. This membrane already runs one to reach the peers it fronts; a second
88 * would be a second NAT mapping and a second set of connections for nothing.
89 *
90 * ⛔ `{ server: true, client: true }`, both load-bearing — hyper-proto-peer measured it: a member
91 * that joins `client: false` never sends outbound holepunch packets, so its NAT mapping never opens
92 * and a peer punching from the other side is dropped. A NAT'd handset asking a NAT'd bridge host to
93 * carry it is exactly that case.
94 */
95export function serveFronting ({ swarm, log = () => {}, ...deps }) {
96 if (!swarm) throw new Error('serveFronting: a swarm is required')
97 const handlers = frontHandlers({ ...deps, log })
98
99 const onconnection = (conn) => {
100 let rpc
101 try {
102 rpc = new ProtomuxRPC(conn, { protocol: PROTOCOL, valueEncoding: c.json })
103 } catch { return }
104 for (const [name, fn] of Object.entries(handlers)) {
105 rpc.respond(name, async (params) => {
106 try {
107 return await fn(params || {})
108 } catch (err) {
109 // ⚠ Answered, never left as a dead channel: a peer that gets no reply cannot tell a refusal
110 // from a bridge that is down, and those need different actions.
111 return { status: 500, body: { error: (err && err.message) || String(err) } }
112 }
113 })
114 }
115 }
116
117 swarm.on('connection', onconnection)
118 const topic = swarm.keyPair.publicKey
119 const disc = swarm.join(topic, { server: true, client: true })
120
121 return {
122 key: b4a.toString(topic, 'hex'),
123 ready: () => disc.flushed(),
124 close: () => { try { swarm.off('connection', onconnection) } catch {} }
125 }
126}
pds/package.json +2 -1
……@@ -6,7 +6,8 @@
66 "main": "lib/server.js",
77 "scripts": {
88 "test": "brittle-node test/*.test.js",
9 "postinstall": "node ../scripts/bare-deps.mjs"
9 "postinstall": "node ../scripts/bare-deps.mjs",
10 "test:bare": "brittle-bare test/bare/*.test.js"
1011 },
1112 "dependencies": {
1213 "atproto-repo": "file:../../atproto-repo",
pds/test/bare/fronting.test.js +126 -0
……@@ -0,0 +1,126 @@
1// Asking this membrane to carry an identity, with no operator in the loop.
2//
3// ⛔ WHAT WAS MISSING. allowFront() only un-denies; a DID enters the fronted set through add(),
4// reachable only from config at boot or from the owner-gated control socket. So a peer holding a
5// perfectly good identity had no way to ask to be carried — the same gap ap-bridge had for names.
6//
7// ⚠ The proof here is NOT what protects the identity: this membrane is keyless and refuses any
8// commit that does not verify against the PLC-resolved key, so naming someone else's identity would
9// only make it serve their real, signed repo. The proof bounds ABUSE — carrying an identity costs a
10// topic join and replication for ever.
11
12import test from 'brittle'
13import b4a from 'b4a'
14import IdentityKey from 'keet-identity-key'
15import crypto from 'hypercore-crypto'
16import { signAttested } from 'favela-identity/lib/attestation.js'
17import { Admission } from 'favela-identity/lib/admission.js'
18import { frontHandlers } from '../../lib/fronting.js'
19
20const ID = 'did:plc:abcdefghijklmnopqrstuvwx'
21
22async function identity () {
23 const idk = await IdentityKey.from({ mnemonic: IdentityKey.generateMnemonic() })
24 const kp = crypto.keyPair()
25 const proof = await IdentityKey.bootstrap({ identity: idk.identityKeyPair }, kp.publicKey)
26 return { rootHex: b4a.toString(idk.identityKeyPair.publicKey, 'hex'), cred: { keyPair: kp, proof: b4a.toString(proof, 'base64') } }
27}
28
29function fakePeers () {
30 const added = []
31 return { added, add: (id) => { added.push(id) }, isFronted: (id) => added.includes(id) }
32}
33
34const deps = (extra = {}) => ({ peers: fakePeers(), admission: new Admission(), ...extra })
35
36test('★★ a peer asks to be carried, and is', async (t) => {
37 const d = deps()
38 const h = frontHandlers(d)
39 const me = await identity()
40
41 const { nonce } = await h.challenge()
42 const proof = signAttested(d.admission.frontBytes({ nonce, id: ID }), me.cred)
43 const r = await h.front({ id: ID, root: me.rootHex, nonce, proof })
44
45 t.is(r.status, 200)
46 t.ok(r.body.fronted)
47 t.alike(d.peers.added, [ID], 'and the router was told to carry it')
48})
49
50test('⛔ an unproven request carries nothing', async (t) => {
51 const d = deps()
52 const h = frontHandlers(d)
53 const me = await identity()
54
55 t.is((await h.front({ id: ID, root: me.rootHex })).status, 403, 'no challenge, no proof')
56 t.alike(d.peers.added, [], 'nothing was fronted')
57
58 const { nonce } = await h.challenge()
59 const other = await identity()
60 const proof = signAttested(d.admission.frontBytes({ nonce, id: ID }), other.cred)
61 const r = await h.front({ id: ID, root: me.rootHex, nonce, proof })
62 t.is(r.status, 403, 'a proof from somebody else')
63 t.is(r.body.error, 'proof does not attest that identity')
64 t.alike(d.peers.added, [])
65})
66
67test('⛔ a proof cannot be replayed for a different identity', async (t) => {
68 const d = deps()
69 const h = frontHandlers(d)
70 const me = await identity()
71 const { nonce } = await h.challenge()
72 const proof = signAttested(d.admission.frontBytes({ nonce, id: ID }), me.cred)
73
74 t.is((await h.front({ id: 'did:plc:somebodyelseentirely00', root: me.rootHex, nonce, proof })).status, 403)
75 t.alike(d.peers.added, [])
76})
77
78// ⚠ A peer that retried after a dropped connection must not be told it did something wrong.
79test('★ asking twice is not an error', async (t) => {
80 const d = deps()
81 const h = frontHandlers(d)
82 const me = await identity()
83
84 for (let i = 0; i < 2; i++) {
85 const { nonce } = await h.challenge()
86 const proof = signAttested(d.admission.frontBytes({ nonce, id: ID }), me.cred)
87 const r = await h.front({ id: ID, root: me.rootHex, nonce, proof })
88 t.is(r.status, 200)
89 if (i === 1) t.ok(r.body.already, 'and it says it was already carried')
90 }
91})
92
93// ⛔ frontedDids is CONFIG; this is not. A restart that silently unfronted everyone who asked would
94// make the whole door a lie.
95test('⛔ what a peer asked for is persisted, and it says so when it cannot be', async (t) => {
96 const saved = []
97 const d = deps({ onFronted: async (x) => { saved.push(x) } })
98 const h = frontHandlers(d)
99 const me = await identity()
100
101 let { nonce } = await h.challenge()
102 let proof = signAttested(d.admission.frontBytes({ nonce, id: ID }), me.cred)
103 let r = await h.front({ id: ID, root: me.rootHex, nonce, proof })
104 t.ok(r.body.durable)
105 t.is(saved.length, 1)
106 t.is(saved[0].root, me.rootHex, 'and WHO asked is recorded, not only what')
107
108 const d2 = deps({ onFronted: async () => { throw new Error('disk full') } })
109 const h2 = frontHandlers(d2)
110 ;({ nonce } = await h2.challenge())
111 proof = signAttested(d2.admission.frontBytes({ nonce, id: ID }), me.cred)
112 r = await h2.front({ id: ID, root: me.rootHex, nonce, proof })
113 t.is(r.status, 202, 'carried, and honest that it will not survive a restart')
114 t.absent(r.body.durable)
115 t.ok(d2.peers.isFronted(ID), 'it IS fronted — sending the peer away would be the wrong lie')
116})
117
118test('⛔ a bridge that does not take requests says so', async (t) => {
119 const d = deps()
120 const h = frontHandlers({ ...d, policy: 'closed' })
121 const me = await identity()
122 const { nonce } = await h.challenge()
123 const proof = signAttested(d.admission.frontBytes({ nonce, id: ID }), me.cred)
124 t.is((await h.front({ id: ID, root: me.rootHex, nonce, proof })).status, 403)
125 t.alike(d.peers.added, [])
126})