OverviewFilesCommitsIssuesDocsReleasesPeers

favela-identity commits / 5b35af8

0.0.5: observer grant — a bridge vouches for a user's observation feed with its swarm key
A fifth versioned prefix (favela-bridge:v1:observer) bound to username, generation, feed and founder root; signObserver / verifyObserver (Ed25519, hypercore-crypto). One definition both sides share, as with the claim formats (hyper-social #82, I1 S3, O-2 option b). 11/11. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5b35af8cb1c41a645109c2f48c518bfd3ff8580c
by plankatron <fleeky@gmail.com> · 2026-10-14T19:27:11.000Z

Changed files (3) +84 -2

lib/observer-grant.js +42 -0
……@@ -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
15import b4a from 'b4a'
16import crypto from 'hypercore-crypto'
17
18export const OBSERVER = 'favela-bridge:v1:observer'
19
20const gen = (g) => { const n = Math.floor(Number(g)); return String(Number.isFinite(n) && n > 0 ? n : 0) }
21const isHex = (s, n) => typeof s === 'string' && s.length === n && /^[0-9a-f]+$/i.test(s)
22
23export 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. */
28export 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. */
33export 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
42export default { observerBytes, signObserver, verifyObserver, OBSERVER }
package.json +3 -2
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-identity",
3 "version": "0.0.4",
3 "version": "0.0.5",
44 "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.",
55 "type": "module",
66 "license": "MIT",
……@@ -10,7 +10,8 @@
1010 "./lib/attestation.js": "./lib/attestation.js",
1111 "./lib/peer-client.js": "./lib/peer-client.js",
1212 "./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"
1415 },
1516 "scripts": {
1617 "test": "brittle-bare test/*.test.js"
test/observer-grant.test.js +39 -0
……@@ -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
8import test from 'brittle'
9import b4a from 'b4a'
10import crypto from 'hypercore-crypto'
11import { observerBytes, signObserver, verifyObserver, OBSERVER } from '../lib/observer-grant.js'
12
13const G = { username: 'fleeky', generation: 1, observationFeed: 'f6'.repeat(32), observerRoot: 'e4'.repeat(32) }
14
15test('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
21test('★ 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
28test('⛔ 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})