| … | … | @@ -0,0 +1,42 @@ |
| 1 | // observer-grant.js — a bridge tells a peer which observation feed is theirs and who founded it, signed with the | |
| 2 | // bridge's own swarm key (hyper-social #82, I1 S3, operator decision O-2 option b). | |
| 3 | // | |
| 4 | // ⛔ WHY SIGNED, AND WHY BY THAT KEY. A bridge writes what the fediverse does to a user into a feed it founds under | |
| 5 | // its own favela identity; the user's node holds that feed and renders it as notifications. The node must only | |
| 6 | // accept a feed the bridge it CHOSE vouches for. It chose the bridge by its swarm key (`bridgeKey` — the key it | |
| 7 | // claimed its name against), so the grant is signed with exactly that key, and verified against it. | |
| 8 | // | |
| 9 | // ⛔ ONE DEFINITION, BECAUSE A MISMATCH IS SILENT (the rule bridge-claim.js states). ap-bridge signs these bytes | |
| 10 | // and favela-peer verifies them; neither may import the other, so the format lives here, beside the claim formats. | |
| 11 | // A fifth prefix: a grant must never verify as any proof a peer signs. | |
| 12 | // | |
| 13 | // ⚠ BOUND TO username AND generation, so a grant cannot be replayed to another user or to a re-claimed name. | |
| 14 | ||
| 15 | import b4a from 'b4a' | |
| 16 | import crypto from 'hypercore-crypto' | |
| 17 | ||
| 18 | export const OBSERVER = 'favela-bridge:v1:observer' | |
| 19 | ||
| 20 | const gen = (g) => { const n = Math.floor(Number(g)); return String(Number.isFinite(n) && n > 0 ? n : 0) } | |
| 21 | const isHex = (s, n) => typeof s === 'string' && s.length === n && /^[0-9a-f]+$/i.test(s) | |
| 22 | ||
| 23 | export function observerBytes ({ username, generation, observationFeed, observerRoot }) { | |
| 24 | return b4a.from([OBSERVER, String(username || ''), gen(generation), String(observationFeed || ''), String(observerRoot || '')].join(':'), 'utf8') | |
| 25 | } | |
| 26 | ||
| 27 | /** Sign a grant with the bridge's swarm keypair. → hex signature. */ | |
| 28 | export function signObserver (grant, keyPair) { | |
| 29 | return b4a.toString(crypto.sign(observerBytes(grant), keyPair.secretKey), 'hex') | |
| 30 | } | |
| 31 | ||
| 32 | /** Does `sigHex` sign this grant under `bridgeKeyHex`? Never throws. */ | |
| 33 | export function verifyObserver (grant, sigHex, bridgeKeyHex) { | |
| 34 | try { | |
| 35 | if (!isHex(sigHex, 128) || !isHex(bridgeKeyHex, 64)) return false | |
| 36 | return crypto.verify(observerBytes(grant), b4a.from(sigHex, 'hex'), b4a.from(bridgeKeyHex, 'hex')) | |
| 37 | } catch { | |
| 38 | return false | |
| 39 | } | |
| 40 | } | |
| 41 | ||
| 42 | export default { observerBytes, signObserver, verifyObserver, OBSERVER } |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "favela-identity", |
| 3 | "version": "0.0.4", | |
| 3 | "version": "0.0.5", | |
| 4 | 4 | "description": "favela's identity primitives. Attestation today; the account layer (a User is durable, a DID is one credential, a bridge is a resource) as it lands.", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "license": "MIT", |
| … | … | @@ -10,7 +10,8 @@ |
| 10 | 10 | "./lib/attestation.js": "./lib/attestation.js", |
| 11 | 11 | "./lib/peer-client.js": "./lib/peer-client.js", |
| 12 | 12 | "./lib/bridge-claim.js": "./lib/bridge-claim.js", |
| 13 | "./lib/admission.js": "./lib/admission.js" | |
| 13 | "./lib/admission.js": "./lib/admission.js", | |
| 14 | "./lib/observer-grant.js": "./lib/observer-grant.js" | |
| 14 | 15 | }, |
| 15 | 16 | "scripts": { |
| 16 | 17 | "test": "brittle-bare test/*.test.js" |
| … | … | @@ -0,0 +1,39 @@ |
| 1 | // observer-grant.test.js — the bridge tells a peer which observation feed is theirs and who founded it, signed | |
| 2 | // with the bridge's own swarm key (hyper-social #82, I1 S3, operator decision O-2 option b). | |
| 3 | // | |
| 4 | // ⛔ WHY SIGNED. The peer trusts the observer root it is handed only because it arrives signed by the bridge key it | |
| 5 | // chose to claim against. A grant that could be forged, or lifted from one user and replayed to another, would let | |
| 6 | // anyone point a node at a feed of their choosing and have it rendered as that user's notifications. | |
| 7 | ||
| 8 | import test from 'brittle' | |
| 9 | import b4a from 'b4a' | |
| 10 | import crypto from 'hypercore-crypto' | |
| 11 | import { observerBytes, signObserver, verifyObserver, OBSERVER } from '../lib/observer-grant.js' | |
| 12 | ||
| 13 | const G = { username: 'fleeky', generation: 1, observationFeed: 'f6'.repeat(32), observerRoot: 'e4'.repeat(32) } | |
| 14 | ||
| 15 | test('the exact bytes, pinned — a drift on either side fails here, with a readable diff', (t) => { | |
| 16 | t.is(OBSERVER, 'favela-bridge:v1:observer') | |
| 17 | t.is(b4a.toString(observerBytes(G)), 'favela-bridge:v1:observer:fleeky:1:' + 'f6'.repeat(32) + ':' + 'e4'.repeat(32)) | |
| 18 | t.is(b4a.toString(observerBytes({ ...G, generation: '1' })), b4a.toString(observerBytes(G)), 'the generation is normalised') | |
| 19 | }) | |
| 20 | ||
| 21 | test('★ signed with the bridge key, verified against the bridge key the peer claimed with', (t) => { | |
| 22 | const bridge = crypto.keyPair() | |
| 23 | const sig = signObserver(G, bridge) | |
| 24 | t.ok(/^[0-9a-f]{128}$/.test(sig), 'a hex signature') | |
| 25 | t.is(verifyObserver(G, sig, b4a.toString(bridge.publicKey, 'hex')), true) | |
| 26 | }) | |
| 27 | ||
| 28 | test('⛔ another key, another user, another generation, another feed or root: refused', (t) => { | |
| 29 | const bridge = crypto.keyPair() | |
| 30 | const sig = signObserver(G, bridge) | |
| 31 | const pub = b4a.toString(bridge.publicKey, 'hex') | |
| 32 | t.is(verifyObserver(G, sig, b4a.toString(crypto.keyPair().publicKey, 'hex')), false, 'a different bridge') | |
| 33 | t.is(verifyObserver({ ...G, username: 'ashatron' }, sig, pub), false, 'replayed to another user') | |
| 34 | t.is(verifyObserver({ ...G, generation: 2 }, sig, pub), false, 'replayed to a re-claimed name') | |
| 35 | t.is(verifyObserver({ ...G, observationFeed: 'aa'.repeat(32) }, sig, pub), false, 'pointed at another feed') | |
| 36 | t.is(verifyObserver({ ...G, observerRoot: 'bb'.repeat(32) }, sig, pub), false, 'claimed another founder') | |
| 37 | t.is(verifyObserver(G, 'zz', pub), false, 'junk signature') | |
| 38 | t.is(verifyObserver(G, sig, 'nope'), false, 'junk key') | |
| 39 | }) |