OverviewFilesCommitsIssuesDocsReleasesPeers

ap-bridge commits / 2ceb4e9

the bridge key stops changing on every restart
`new Hyperswarm()` mints a RANDOM keypair per boot, and that swarm is now how a peer reaches this bridge to ask for a name. Measured by booting it twice: a different `bridgeKey` each run. A peer that recorded one would silently stop being able to reach the bridge, and "did not answer" looks identical to "is down" — which is the worst available failure mode. atproto-bridge already hit this for its own reason and wrote it down: "an allowlist is only meaningful against a STABLE key". Same fix, same rationale. ⚠ Transport identity, not account key material: a Noise/DHT identity that never signs a record and grants nothing on its own. It only makes this bridge NAMEABLE. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2ceb4e9bd27b0d80c77b3be34a53b1cf116999dc
by plankatron <fleeky@gmail.com> · 2026-09-11T16:46:27.000Z

Changed files (2) +45 -1

lib/feed-holder.js +7 -1
……@@ -131,7 +131,13 @@ export class FeedHolder {
131131 await this.overlay.ready()
132132 this.files = new Files(this.storage, this.overlay)
133133
134 if (!this.swarm) this.swarm = new Hyperswarm()
134 if (!this.swarm) {
135 // ⛔ A PERSISTENT keypair. `new Hyperswarm()` alone mints a random one per boot, and this
136 // swarm is how peers reach this bridge to ask for a name — an address that changes on every
137 // restart is not an address. See lib/swarm-keypair.js.
138 const { loadOrCreateSwarmKeyPair } = await import('./swarm-keypair.js')
139 this.swarm = new Hyperswarm({ keyPair: loadOrCreateSwarmKeyPair(join(this.storage, 'swarm-key')) })
140 }
135141 // ⛔ Every connection replicates every held feed, and the overlay attaches to all of them. One
136142 // connection carries all of it — hypercore multiplexes, and piping each feed separately onto one
137143 // socket would give each its own stream over a shared transport.
lib/swarm-keypair.js +38 -0
……@@ -0,0 +1,38 @@
1// Persistent Hyperswarm keypair for this bridge.
2//
3// ⛔ A BRIDGE ADDRESS IS ONLY MEANINGFUL IF IT IS STABLE. `new Hyperswarm()` mints a RANDOM keypair
4// per boot, so the key this bridge tells peers to find it on changed on every restart. A peer that
5// recorded it would silently stop being able to reach the bridge — and "the bridge did not answer"
6// looks identical to "the bridge is down", which is the worst available failure mode.
7//
8// Measured, not assumed: booting with the swarm door wired printed a different `bridgeKey` each run.
9//
10// ⚠ A TRANSPORT identity, not account key material. It is a DHT/Noise identity that never signs a
11// record, never touches a DID and grants nothing on its own; it only makes this bridge NAMEABLE so a
12// peer can choose to ask it for something. Kept as a plain file beside the held-feed store so it
13// survives a corestore reset.
14//
15// ⚠ This is the THIRD copy of this utility in the tree (hyper-proto-peer and atproto-bridge have the
16// same one, and atproto-bridge's says so of hpp's). It stays local because there is no shared home
17// for transport identity — favela-identity is about who someone IS, not how to reach them — and
18// inventing one to hold thirty lines is a bigger change than the thing it would tidy.
19
20import fs from 'bare-fs'
21import { dirname } from 'bare-path'
22import b4a from 'b4a'
23import crypto from 'hypercore-crypto'
24
25export function loadOrCreateSwarmKeyPair (file) {
26 try {
27 const seed = b4a.from(fs.readFileSync(file, 'utf8').trim(), 'hex')
28 if (seed.length === 32) return crypto.keyPair(seed)
29 } catch { /* first boot, or unreadable — mint below */ }
30
31 const seed = crypto.randomBytes(32)
32 fs.mkdirSync(dirname(file), { recursive: true })
33 // 0600: it is not account key material, but it is still this bridge's name and nobody else's.
34 fs.writeFileSync(file, b4a.toString(seed, 'hex') + '\n', { mode: 0o600 })
35 return crypto.keyPair(seed)
36}
37
38export default { loadOrCreateSwarmKeyPair }