| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "atproto-bridge", |
| 3 | "version": "0.9.32", | |
| 3 | "version": "0.9.33", | |
| 4 | 4 | "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.", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "main": "index.js", |
| … | … | @@ -12,8 +12,10 @@ import fs from '#node:fs' |
| 12 | 12 | import path from '#node:path' |
| 13 | 13 | import { fileURLToPath } from '#node:url' |
| 14 | 14 | import Hyperswarm from 'hyperswarm' |
| 15 | import b4a from 'b4a' | |
| 15 | 16 | import { openCorestore } from 'atproto-store/lib/corestore.js' |
| 16 | 17 | import { loadConfig } from '../lib/config.js' |
| 18 | import { loadOrCreateSwarmKeyPair } from '../lib/swarm-keypair.js' | |
| 17 | 19 | import { startControl } from '../lib/control.js' |
| 18 | 20 | import { FleetRelay } from '../lib/fleet-relay.js' |
| 19 | 21 | import { DidPeerRouter } from 'atproto-did-peer' |
| … | … | @@ -41,9 +43,20 @@ try { |
| 41 | 43 | } catch {} |
| 42 | 44 | |
| 43 | 45 | // 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.) | |
| 45 | 49 | const frontStore = openCorestore(path.join(cfg.storage, 'front-store')) |
| 46 | const 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. | |
| 54 | const swarmKeyPair = loadOrCreateSwarmKeyPair(cfg.storage) | |
| 55 | const 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. | |
| 59 | console.log(JSON.stringify({ msg: 'swarm identity', publicKey: b4a.toString(swarmKeyPair.publicKey, 'hex') })) | |
| 47 | 60 | // Durable emitted-seq floor — kept in a plain file BESIDE the front-store (survives a corestore |
| 48 | 61 | // reset), so subscribeRepos seq stays monotonic across redeploys and can be lifted above a relay's |
| 49 | 62 | // stored cursor for recovery (see FleetRelay seqBase / the getHostStatus recovery). |
| … | … | @@ -137,6 +150,7 @@ console.log(JSON.stringify({ |
| 137 | 150 | msg: 'membrane listening', |
| 138 | 151 | http: `${cfg.bind}:${cfg.port}`, |
| 139 | 152 | uds: cfg.uds, |
| 153 | swarmKey: b4a.toString(swarmKeyPair.publicKey, 'hex'), | |
| 140 | 154 | serverDid: cfg.serverDid, |
| 141 | 155 | frontedDids: cfg.frontedDids, |
| 142 | 156 | feedRegistries: cfg.feedRegistries |
| … | … | @@ -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 | ||
| 22 | import fs from '#node:fs' | |
| 23 | import path from '#node:path' | |
| 24 | import b4a from 'b4a' | |
| 25 | import crypto from 'hypercore-crypto' | |
| 26 | ||
| 27 | const 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. | |
| 34 | export 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 | } |
| … | … | @@ -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 | ||
| 10 | import test from 'brittle' | |
| 11 | import { mkdtempSync, rmSync, readFileSync, statSync, existsSync } from 'node:fs' | |
| 12 | import { tmpdir } from 'node:os' | |
| 13 | import { join } from 'node:path' | |
| 14 | import b4a from 'b4a' | |
| 15 | import Hyperswarm from 'hyperswarm' | |
| 16 | import { loadOrCreateSwarmKeyPair } from '../lib/swarm-keypair.js' | |
| 17 | ||
| 18 | test('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 | ||
| 49 | test('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 | ||
| 57 | test('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 | }) |