OverviewFilesCommitsIssuesDocsReleasesPeers

favela-identity commits / 9c9ac1c

0.0.4: the bytes for collecting what a bridge observed
A fourth prefix, `favela-bridge:v1:collect`, with `collectBytes` and `verifyCollect` beside the three that were already here. ⛔ A PROOF FOR ONE ACT MUST NEVER VERIFY AS ANOTHER. Releasing a name BURNS it permanently and there is no way back, so a routine poll whose bytes could be repurposed as a release would be a peer handing strangers a weapon every few minutes. That is the whole reason this is a new prefix rather than a reuse. ⛔ THE CURSOR IS INSIDE THE SIGNED BYTES, for the same reason the username is. A proof covering only the challenge could be lifted in flight and resubmitted with `since` wound back to 0, turning an incremental poll into a demand for the whole buffer. Binding it costs one concatenation. ⚠ AND IT NORMALISES HERE, not in the callers. The peer holds a number and the bridge holds whatever came off the wire; `0`, `'0'`, absent and negative must all produce the SAME bytes or the two sides disagree — and the only symptom of that is "proof does not attest that identity", which reads as a hostile caller rather than a format skew. ⛔ verifyCollect PROVES ONE HALF ONLY, and says so. It answers "does this caller control this root" and nothing about whether that root holds the name it is asking about — this package has no registry and must not grow one. The bridge owns the second half, and it is the half that matters. test/bridge-claim.test.js pins all four formats to their exact strings. Two packages build these bytes and neither may import the other, so no test can drive both sides at once; this package is the only place they agree. A drift now fails here, with a diff a person can read, instead of in production as a forged-proof error. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
9c9ac1c85a09b7abf4dff88b0e4ecf44a29d860d
by plankatron <fleeky@gmail.com> · 2026-09-16T01:53:03.000Z

Changed files (4) +95 -3

lib/admission.js +17 -1
……@@ -33,7 +33,7 @@
3333import crypto from 'bare-crypto'
3434import b4a from 'b4a'
3535import { readAttested } from './attestation.js'
36import { claimBytes, releaseBytes, frontBytes } from './bridge-claim.js'
36import { claimBytes, releaseBytes, frontBytes, collectBytes } from './bridge-claim.js'
3737
3838const HEX64 = /^[0-9a-f]{64}$/i
3939// Deliberately the same shape registrations.js enforces, so bytes signed here can never describe a
……@@ -96,6 +96,22 @@ export class Admission {
9696
9797 frontBytes (params) { return frontBytes(params) }
9898
99 /**
100 * Asking a bridge for what it has HELD for a name.
101 *
102 * ⚠ THIS PROVES ONE HALF ONLY. It answers "does this caller control this root" — it says nothing
103 * about whether that root holds that name, because this package has no registry and must not grow
104 * one. The bridge owns the second half and it is the half that matters here: roots are public, so a
105 * caller who proved control of their own while naming somebody else's username would otherwise be
106 * handed that person's entire inbound social graph. See ap-bridge's `collect.js`.
107 */
108 verifyCollect ({ nonce, username, since, root, proof } = {}) {
109 if (badName(username)) return fail('bad username')
110 return this._verify(nonce, root, proof, () => this.collectBytes({ nonce, username, since }))
111 }
112
113 collectBytes (params) { return collectBytes(params) }
114
99115 /**
100116 * ⛔ THE NONCE IS BURNED WHETHER OR NOT THE PROOF VERIFIES. Consuming only on success would leave a
101117 * live challenge for an attacker to keep trying proofs against; consuming always costs a legitimate
lib/bridge-claim.js +27 -1
……@@ -23,6 +23,10 @@ export const RELEASE = 'favela-bridge:v1:release'
2323// from taking a NAME on one, and a proof that could serve as either would let a request for one be
2424// replayed as a request for the other.
2525export const FRONT = 'favela-bridge:v1:front'
26// ⛔ A FOURTH PREFIX. Collecting is a READ of what a bridge holds for a name, not an act on the name
27// itself, and a proof that could serve as either would let a request to read someone's inbound graph
28// be replayed as a request to BURN their actor. Each act gets its own prefix for that reason.
29export const COLLECT = 'favela-bridge:v1:collect'
2630
2731/**
2832 * What a claimant signs. The REQUEST is inside it, not just the nonce: a proof covering only the
……@@ -48,4 +52,26 @@ export function frontBytes ({ nonce, id }) {
4852 return b4a.from([FRONT, nonce, id].join(':'), 'utf8')
4953}
5054
51export default { claimBytes, releaseBytes, frontBytes, CLAIM, RELEASE, FRONT }
55/**
56 * What a peer signs to collect the observations a bridge holds for its name.
57 *
58 * ⛔ THE CURSOR IS INSIDE THE BYTES, for the same reason the username is. A proof covering only the
59 * challenge could be lifted in flight and resubmitted with `since` wound back to 0, turning a routine
60 * incremental poll into a demand for the whole buffer. Binding it costs one concatenation.
61 *
62 * ⚠ NORMALISED HERE, not by the callers. The claimant has a number and the bridge has whatever came
63 * off the wire — JSON will hand it back as a number, but `0` and `'0'` and `undefined` must all
64 * produce the SAME bytes or the two sides disagree and the only symptom is "proof does not attest
65 * that identity", which reads as a hostile caller rather than a format skew.
66 */
67export function collectBytes ({ nonce, username, since }) {
68 return b4a.from([COLLECT, nonce, username, cursor(since)].join(':'), 'utf8')
69}
70
71/** One definition of what a cursor looks like as text. Non-finite, negative and absent all mean 0. */
72export function cursor (since) {
73 const n = Math.floor(Number(since))
74 return String(Number.isFinite(n) && n > 0 ? n : 0)
75}
76
77export default { claimBytes, releaseBytes, frontBytes, collectBytes, cursor, CLAIM, RELEASE, FRONT, COLLECT }
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-identity",
3 "version": "0.0.3",
3 "version": "0.0.4",
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",
test/bridge-claim.test.js +50 -0
……@@ -0,0 +1,50 @@
1// bridge-claim.test.js — the wire format itself, pinned.
2//
3// ⛔ WHY THIS FILE EXISTS AND WHY IT IS HERE. Two packages sign and verify these bytes — favela-peer
4// builds them, ap-bridge rebuilds them — and NEITHER may import the other: favela-peer's
5// boundary.test.js forbids naming a projection, and ap-bridge has no business depending on the
6// daemon. So no test can drive both sides at once, and this package is the only place both agree on.
7//
8// ⛔ AND A DISAGREEMENT IS SILENT. If the two sides ever differ by a byte, `readAttested` returns
9// null and the bridge answers "proof does not attest that identity" — which reads as a forged or
10// hostile caller. There is no error that says "we disagree about the format", so the format is pinned
11// to its exact string here and a drift on either side fails HERE, with a diff a person can read.
12
13import test from 'brittle'
14import b4a from 'b4a'
15import { claimBytes, releaseBytes, frontBytes, collectBytes, cursor } from '../lib/bridge-claim.js'
16
17const s = (buf) => b4a.toString(buf, 'utf8')
18const N = 'ab12'
19
20test('★★ the four formats, exactly', async (t) => {
21 t.is(s(claimBytes({ nonce: N, username: 'alice', feedKey: 'ff' })), 'favela-bridge:v1:claim:ab12:alice:ff')
22 t.is(s(releaseBytes({ nonce: N, username: 'alice' })), 'favela-bridge:v1:release:ab12:alice')
23 t.is(s(frontBytes({ nonce: N, id: 'did:plc:x' })), 'favela-bridge:v1:front:ab12:did:plc:x')
24 t.is(s(collectBytes({ nonce: N, username: 'alice', since: 7 })), 'favela-bridge:v1:collect:ab12:alice:7')
25})
26
27// ⛔ THE PREFIXES ARE WHAT KEEP ONE ACT FROM BECOMING ANOTHER. Releasing BURNS a name permanently, so
28// a routine collect whose bytes could be repurposed as a release would be a peer handing strangers a
29// weapon on every poll.
30test('⛔ no two acts ever produce the same bytes', async (t) => {
31 const all = [
32 s(claimBytes({ nonce: N, username: 'alice', feedKey: '' })),
33 s(releaseBytes({ nonce: N, username: 'alice' })),
34 s(frontBytes({ nonce: N, id: 'alice' })),
35 s(collectBytes({ nonce: N, username: 'alice', since: 0 }))
36 ]
37 t.is(new Set(all).size, all.length, 'four acts, four distinct strings')
38})
39
40// ⛔ THE CURSOR NORMALISES ON BOTH SIDES OR NEITHER. The peer has a number; the bridge has whatever
41// JSON handed back. If `0`, `'0'`, `undefined` and `-1` did not all produce the same text, an
42// ordinary first collect would fail as a bad proof.
43test('⛔ every way of saying "from the start" produces the same bytes', async (t) => {
44 const base = s(collectBytes({ nonce: N, username: 'alice', since: 0 }))
45 for (const since of [0, '0', undefined, null, -1, NaN, 0.4]) {
46 t.is(s(collectBytes({ nonce: N, username: 'alice', since })), base, 'since=' + String(since))
47 }
48 t.is(cursor('12'), '12', 'and a real cursor survives as itself')
49 t.is(cursor(12.9), '12', 'floored, never rounded up past a row we were not given')
50})