OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-bridge commits / 782cb38

Give the membrane a PERSISTENT swarm identity (hyper-social#21 P0)
`pds-serve.js` built its Hyperswarm with no keypair, so Hyperswarm minted a random one per boot. That made the bridge's Noise public key differ on every restart and persisted nowhere — which is why PEER_WRITERS could never be populated: hyper-proto-peer's device surface gates apply-write, delegate and get-service-auth on canWrite(remote) (device-node.js:500-501), and an entry in that allowlist authorised exactly ONE boot before silently going dead. The handler's refusal is indistinguishable from "not allowlisted yet", so this was the worst available failure mode. It is what blocks app.bsky.notification.listNotifications end to end; every other link in the chain was already confirmed working. loadOrCreateSwarmKeyPair persists <storage>/swarm-keypair.json (0600) beside the front-store, the same durability place and rationale as jwt-secret and relay-seq-base — survives a corestore reset. Mirrors hyper-proto-peer's lib/swarm-keypair.js rather than inventing a scheme, but written against the #node:* import map so it runs under bare (prod) and node (tests) alike. The public key is logged at boot on its own line AND in the `membrane listening` summary. Without that, populating PEER_WRITERS means fishing a key out of a peer's connection logs and hoping it was ours — a near-miss that actually happened while diagnosing this. Amends pds-serve.js:44's "no key material ever lives here" to "no ACCOUNT key material". A swarm keypair is a transport/DHT identity: it never signs a repo commit, holds no account, and grants nothing on its own — it only makes the bridge NAMEABLE so a peer's operator can choose to trust it. Left unamended, the comment misleads the next reader into thinking this file is a violation. fleet-serve.js:38 and fleet-replicate.js:21 also derive a topic from swarm.keyPair.publicKey; both are prototypes not imported by pds-serve.js, so the only effect there is that their serverKey becomes stable too. Test proves the restart property directly (same storage -> same key), that the swarm actually PRESENTS the persisted key (a persisted file the swarm ignored would pass every other assertion and still fail in the field), 0600 mode, and separate storage -> separate identity. Control test demonstrates the pre-fix behaviour so a silent revert to random keys cannot pass. 12/12; full suite 48/48, 205/205. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
782cb3826d83f7facfa2a01658d16e65d30f0e70
by plankatron <fleeky@gmail.com> · 2026-07-30T14:05:59.000Z

Changed files (4) +127 -3

package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "atproto-bridge",
3 "version": "0.9.32",
3 "version": "0.9.33",
44 "description": "Stateless keyless atproto MEMBRANE — fronts self-custody hyper-proto-peers to bluesky over HTTP/WS, holds no keys and never signs. Bare-runnable. Deploy root / flat closure over the identity+repo+store+pds packages.",
55 "type": "module",
66 "main": "index.js",
pds/bin/pds-serve.js +16 -2
……@@ -12,8 +12,10 @@ import fs from '#node:fs'
1212import path from '#node:path'
1313import { fileURLToPath } from '#node:url'
1414import Hyperswarm from 'hyperswarm'
15import b4a from 'b4a'
1516import { openCorestore } from 'atproto-store/lib/corestore.js'
1617import { loadConfig } from '../lib/config.js'
18import { loadOrCreateSwarmKeyPair } from '../lib/swarm-keypair.js'
1719import { startControl } from '../lib/control.js'
1820import { FleetRelay } from '../lib/fleet-relay.js'
1921import { DidPeerRouter } from 'atproto-did-peer'
……@@ -41,9 +43,20 @@ try {
4143} catch {}
4244
4345// The front-store holds the aggregate firehose (relay) + replicated peer cores. It is content,
44// NOT accounts — no key material ever lives here.
46// NOT accounts — no ACCOUNT key material ever lives here. (The swarm keypair below is the one
47// piece of key material the membrane does keep: a transport/DHT identity that never signs a repo
48// commit and holds no account. Amended from "no key material ever" — see swarm-keypair.js.)
4549const frontStore = openCorestore(path.join(cfg.storage, 'front-store'))
46const swarm = new Hyperswarm()
50// PERSISTENT swarm identity. A random per-boot keypair (plain `new Hyperswarm()`) made the bridge's
51// Noise public key unstable, so hyper-proto-peer's PEER_WRITERS allowlist — which gates apply-write,
52// delegate and get-service-auth on the CLIENT's Noise key — could never be populated: any entry
53// authorised exactly one boot. Stable key ⇒ the bridge is allowlistable.
54const swarmKeyPair = loadOrCreateSwarmKeyPair(cfg.storage)
55const swarm = new Hyperswarm({ keyPair: swarmKeyPair })
56// Logged at BOOT, deliberately: this is the value an operator must paste into each DID's `writers`
57// array. Without it, populating PEER_WRITERS means fishing a key out of a peer's connection logs and
58// hoping it was ours. Public key only — the secret stays in the file.
59console.log(JSON.stringify({ msg: 'swarm identity', publicKey: b4a.toString(swarmKeyPair.publicKey, 'hex') }))
4760// Durable emitted-seq floor — kept in a plain file BESIDE the front-store (survives a corestore
4861// reset), so subscribeRepos seq stays monotonic across redeploys and can be lifted above a relay's
4962// stored cursor for recovery (see FleetRelay seqBase / the getHostStatus recovery).
……@@ -137,6 +150,7 @@ console.log(JSON.stringify({
137150 msg: 'membrane listening',
138151 http: `${cfg.bind}:${cfg.port}`,
139152 uds: cfg.uds,
153 swarmKey: b4a.toString(swarmKeyPair.publicKey, 'hex'),
140154 serverDid: cfg.serverDid,
141155 frontedDids: cfg.frontedDids,
142156 feedRegistries: cfg.feedRegistries
pds/lib/swarm-keypair.js +50 -0
……@@ -0,0 +1,50 @@
1// Persistent Hyperswarm keypair for the MEMBRANE.
2//
3// The bridge is a CLIENT of every fronted peer, and hyper-proto-peer's privileged device-surface
4// RPCs (apply-write, delegate, pointer-diag, get-service-auth) gate on `canWrite(remote)` — an
5// allowlist of client Noise public keys (PEER_WRITERS, device-node.js:500-501). An allowlist is
6// only meaningful against a STABLE key.
7//
8// `new Hyperswarm()` mints a RANDOM keypair per boot, so the bridge's Noise public key differed on
9// every restart and was persisted nowhere. Anything added to PEER_WRITERS therefore authorised
10// exactly one boot and then silently stopped — the worst available failure mode, because the
11// handler's refusal looks identical to "not allowlisted yet". That is what blocked
12// app.bsky.notification.listNotifications end to end (atproto-bridge#21 / hyper-social#21).
13//
14// This is a TRANSPORT identity, not account key material: it is a DHT/Noise identity that never
15// signs a repo commit, never touches a DID, and grants nothing on its own — it only makes the
16// bridge NAMEABLE so a peer's operator can choose to trust it. Kept as a plain file beside the
17// front-store (like jwt-secret and relay-seq-base) so it survives a corestore reset.
18//
19// Same utility + rationale as hyper-proto-peer's lib/swarm-keypair.js; written against the
20// #node:* import map so it runs under both bare (prod) and node (tests) rather than lifted verbatim.
21
22import fs from '#node:fs'
23import path from '#node:path'
24import b4a from 'b4a'
25import crypto from 'hypercore-crypto'
26
27const KEYPAIR_FILE = 'swarm-keypair.json'
28
29// Load the membrane's persistent swarm keypair from <storage>/swarm-keypair.json, creating and
30// persisting one on first boot. Returns a hypercore-crypto keypair ({ publicKey, secretKey }).
31// `name` selects WHICH stable identity, so a second Hyperswarm (a tool, a probe) can be separately
32// allowlistable rather than borrowing — two swarms on one keypair put two DHT identities behind the
33// same key.
34export function loadOrCreateSwarmKeyPair (storage, { name = KEYPAIR_FILE } = {}) {
35 if (typeof storage !== 'string') throw new Error('loadOrCreateSwarmKeyPair: storage must be a directory path')
36 const file = path.join(storage, name)
37 try {
38 const saved = JSON.parse(fs.readFileSync(file, 'utf8'))
39 return { publicKey: b4a.from(saved.publicKey, 'hex'), secretKey: b4a.from(saved.secretKey, 'hex') }
40 } catch {
41 const keyPair = crypto.keyPair()
42 try { fs.mkdirSync(storage, { recursive: true }) } catch {}
43 fs.writeFileSync(file, JSON.stringify({
44 publicKey: b4a.toString(keyPair.publicKey, 'hex'),
45 secretKey: b4a.toString(keyPair.secretKey, 'hex'),
46 createdAt: Date.now()
47 }, null, 2), { mode: 0o600 })
48 return keyPair
49 }
50}
pds/test/swarm-keypair.test.js +60 -0
……@@ -0,0 +1,60 @@
1// The membrane's PERSISTENT swarm identity (hyper-social#21 P0).
2//
3// The property under test is the one the P0 turned on: the Noise public key a peer would allowlist
4// in PEER_WRITERS must be the SAME after a restart. The old `new Hyperswarm()` failed exactly here —
5// a fresh random key per boot, so an allowlist entry authorised one boot and then silently stopped.
6//
7// The control (second test) is what makes the first meaningful: it demonstrates the pre-fix
8// behaviour, so a regression that quietly reverts to random keys cannot pass.
9
10import test from 'brittle'
11import { mkdtempSync, rmSync, readFileSync, statSync, existsSync } from 'node:fs'
12import { tmpdir } from 'node:os'
13import { join } from 'node:path'
14import b4a from 'b4a'
15import Hyperswarm from 'hyperswarm'
16import { loadOrCreateSwarmKeyPair } from '../lib/swarm-keypair.js'
17
18test('swarm keypair: created once, then STABLE across restarts (the PEER_WRITERS contract)', async (t) => {
19 const dir = mkdtempSync(join(tmpdir(), 'bridge-swarmkey-'))
20 t.teardown(() => rmSync(dir, { recursive: true, force: true }))
21
22 const first = loadOrCreateSwarmKeyPair(dir)
23 const file = join(dir, 'swarm-keypair.json')
24 t.ok(existsSync(file), 'first boot persists <storage>/swarm-keypair.json')
25 t.is(first.publicKey.length, 32, 'publicKey is 32 bytes')
26 t.is(first.secretKey.length, 64, 'secretKey is 64 bytes')
27
28 // The restart. Same storage dir → the SAME key, which is the whole point.
29 const second = loadOrCreateSwarmKeyPair(dir)
30 t.ok(b4a.equals(first.publicKey, second.publicKey), 'RESTART: public key is unchanged')
31 t.ok(b4a.equals(first.secretKey, second.secretKey), 'RESTART: secret key is unchanged')
32
33 // It must be the key Hyperswarm actually presents to a peer — a persisted file that the swarm
34 // ignores would pass every assertion above and still fail in the field.
35 const swarm = new Hyperswarm({ keyPair: second })
36 t.teardown(() => swarm.destroy())
37 t.ok(b4a.equals(swarm.keyPair.publicKey, first.publicKey), 'the swarm presents the persisted key')
38
39 const saved = JSON.parse(readFileSync(file, 'utf8'))
40 t.is(saved.publicKey, b4a.toString(first.publicKey, 'hex'), 'file holds the hex public key an operator pastes into PEER_WRITERS')
41 t.is(statSync(file).mode & 0o777, 0o600, 'secret-bearing file is 0600')
42
43 // A different storage dir is a different membrane, not the same one.
44 const other = mkdtempSync(join(tmpdir(), 'bridge-swarmkey-'))
45 t.teardown(() => rmSync(other, { recursive: true, force: true }))
46 t.absent(b4a.equals(loadOrCreateSwarmKeyPair(other).publicKey, first.publicKey), 'separate storage → separate identity')
47})
48
49test('control: plain `new Hyperswarm()` mints a NEW key per boot — the bug being fixed', async (t) => {
50 const a = new Hyperswarm()
51 const b = new Hyperswarm()
52 t.teardown(async () => { await a.destroy(); await b.destroy() })
53 t.absent(b4a.equals(a.keyPair.publicKey, b.keyPair.publicKey),
54 'two unkeyed swarms differ — an allowlist entry would authorise exactly one boot')
55})
56
57test('storage must be a path — refuse rather than write a keypair somewhere unintended', async (t) => {
58 t.exception(() => loadOrCreateSwarmKeyPair(null), /storage must be a directory path/)
59 t.exception(() => loadOrCreateSwarmKeyPair({ storage: '/tmp' }), /storage must be a directory path/)
60})