| … | … | @@ -33,7 +33,7 @@ |
| 33 | 33 | import crypto from 'bare-crypto' |
| 34 | 34 | import b4a from 'b4a' |
| 35 | 35 | import { readAttested } from './attestation.js' |
| 36 | import { claimBytes, releaseBytes, frontBytes } from './bridge-claim.js' | |
| 36 | import { claimBytes, releaseBytes, frontBytes, collectBytes } from './bridge-claim.js' | |
| 37 | 37 | |
| 38 | 38 | const HEX64 = /^[0-9a-f]{64}$/i |
| 39 | 39 | // Deliberately the same shape registrations.js enforces, so bytes signed here can never describe a |
| … | … | @@ -96,6 +96,22 @@ export class Admission { |
| 96 | 96 | |
| 97 | 97 | frontBytes (params) { return frontBytes(params) } |
| 98 | 98 | |
| 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 | ||
| 99 | 115 | /** |
| 100 | 116 | * ⛔ THE NONCE IS BURNED WHETHER OR NOT THE PROOF VERIFIES. Consuming only on success would leave a |
| 101 | 117 | * live challenge for an attacker to keep trying proofs against; consuming always costs a legitimate |
| … | … | @@ -23,6 +23,10 @@ export const RELEASE = 'favela-bridge:v1:release' |
| 23 | 23 | // from taking a NAME on one, and a proof that could serve as either would let a request for one be |
| 24 | 24 | // replayed as a request for the other. |
| 25 | 25 | export 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. | |
| 29 | export const COLLECT = 'favela-bridge:v1:collect' | |
| 26 | 30 | |
| 27 | 31 | /** |
| 28 | 32 | * 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 }) { |
| 48 | 52 | return b4a.from([FRONT, nonce, id].join(':'), 'utf8') |
| 49 | 53 | } |
| 50 | 54 | |
| 51 | export 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 | */ | |
| 67 | export 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. */ | |
| 72 | export function cursor (since) { | |
| 73 | const n = Math.floor(Number(since)) | |
| 74 | return String(Number.isFinite(n) && n > 0 ? n : 0) | |
| 75 | } | |
| 76 | ||
| 77 | export default { claimBytes, releaseBytes, frontBytes, collectBytes, cursor, CLAIM, RELEASE, FRONT, COLLECT } |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "favela-identity", |
| 3 | "version": "0.0.3", | |
| 3 | "version": "0.0.4", | |
| 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", |
| … | … | @@ -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 | ||
| 13 | import test from 'brittle' | |
| 14 | import b4a from 'b4a' | |
| 15 | import { claimBytes, releaseBytes, frontBytes, collectBytes, cursor } from '../lib/bridge-claim.js' | |
| 16 | ||
| 17 | const s = (buf) => b4a.toString(buf, 'utf8') | |
| 18 | const N = 'ab12' | |
| 19 | ||
| 20 | test('★★ 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. | |
| 30 | test('⛔ 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. | |
| 43 | test('⛔ 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 | }) |