| … | … | @@ -47,7 +47,9 @@ |
| 47 | 47 | "protomux": "^3.11.0", |
| 48 | 48 | "protomux-rpc": "^1.10.0", |
| 49 | 49 | "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" | |
| 51 | 53 | }, |
| 52 | 54 | "devDependencies": { |
| 53 | 55 | "@hyperswarm/testnet": "^3.0.0" |
| … | … | @@ -89,6 +89,43 @@ const peers = new DidPeerRouter(frontStore, { |
| 89 | 89 | onHandles: persistHandles |
| 90 | 90 | }) |
| 91 | 91 | |
| 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. | |
| 98 | const requestedFile = path.join(cfg.storage, 'fronted-requested.json') | |
| 99 | const readRequested = () => { try { return JSON.parse(fs.readFileSync(requestedFile, 'utf8')) } catch { return {} } } | |
| 100 | const requested = readRequested() | |
| 101 | for (const id of Object.keys(requested)) { try { peers.add(id) } catch {} } | |
| 102 | if (Object.keys(requested).length) console.log(JSON.stringify({ msg: 'refronted on boot', count: Object.keys(requested).length })) | |
| 103 | ||
| 104 | try { | |
| 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 | ||
| 92 | 129 | // 1. bsky HTTP+WS membrane — keyless per-DID routing to the fronted peers. |
| 93 | 130 | const server = createPdsServer({ peers, serverDid: cfg.serverDid, availableUserDomains: [cfg.userDomain], favelaWebUpstream: cfg.favelaWebUpstream }) |
| 94 | 131 | await new Promise((r) => server.listen(cfg.port, cfg.bind, r)) |
| … | … | @@ -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 | ||
| 19 | import ProtomuxRPC from 'protomux-rpc' | |
| 20 | import c from 'compact-encoding' | |
| 21 | import b4a from 'b4a' | |
| 22 | import { 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. | |
| 27 | export const PROTOCOL = 'favela/bridge-register/1' | |
| 28 | ||
| 29 | export { 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 | */ | |
| 35 | export 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 | */ | |
| 95 | export 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 | } |
| … | … | @@ -6,7 +6,8 @@ |
| 6 | 6 | "main": "lib/server.js", |
| 7 | 7 | "scripts": { |
| 8 | 8 | "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" | |
| 10 | 11 | }, |
| 11 | 12 | "dependencies": { |
| 12 | 13 | "atproto-repo": "file:../../atproto-repo", |
| … | … | @@ -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 | ||
| 12 | import test from 'brittle' | |
| 13 | import b4a from 'b4a' | |
| 14 | import IdentityKey from 'keet-identity-key' | |
| 15 | import crypto from 'hypercore-crypto' | |
| 16 | import { signAttested } from 'favela-identity/lib/attestation.js' | |
| 17 | import { Admission } from 'favela-identity/lib/admission.js' | |
| 18 | import { frontHandlers } from '../../lib/fronting.js' | |
| 19 | ||
| 20 | const ID = 'did:plc:abcdefghijklmnopqrstuvwx' | |
| 21 | ||
| 22 | async 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 | ||
| 29 | function fakePeers () { | |
| 30 | const added = [] | |
| 31 | return { added, add: (id) => { added.push(id) }, isFronted: (id) => added.includes(id) } | |
| 32 | } | |
| 33 | ||
| 34 | const deps = (extra = {}) => ({ peers: fakePeers(), admission: new Admission(), ...extra }) | |
| 35 | ||
| 36 | test('★★ 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 | ||
| 50 | test('⛔ 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 | ||
| 67 | test('⛔ 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. | |
| 79 | test('★ 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. | |
| 95 | test('⛔ 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 | ||
| 118 | test('⛔ 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 | }) |