OverviewFilesCommitsIssuesDocsReleasesPeers

favela-identity commits / c7dc4a4

README: "Design. Nothing built." described four shipped modules
The status line said "Design. Nothing built. Start at spec/identity.md" for a package at 0.0.4 with lib/attestation.js, lib/bridge-claim.js, lib/admission.js and lib/peer-client.js, all shipped and imported by three other repos. Measured: 8/8 tests, 37 asserts. Two other false claims in eleven lines. It said "License: Not yet declared" while package.json carries "license": "MIT". And it pointed at "packages/hyper-identity (the registry implementation)" — the package is hyper-identity2, the registry is the autobee binding, and favela-peer uses it for BROWSER identities, not for the favela account registry the spec describes. That account registry genuinely is still design, and the README now says which half is which instead of calling the whole thing design. Rewritten as a reference in the holepunch shape, per favela-markdown 462e960: the API section is the informational core. Every export gets a signature, a return shape and the sentence a caller needs, grouped by module — including the four byte builders and the four prefixes, with the reason each act gets its own prefix. Documents what index.js does NOT export, since that decides how a consumer imports: collectBytes, COLLECT, cursor, Admission and FavelaPeer are deep-import only. Added a Consumers table. Each of the five sites re-exports rather than reimplements, which is the property this package exists to hold, and nothing recorded it. Test section states that Admission and FavelaPeer have no tests HERE and names the five ap-bridge suites that exercise Admission, rather than letting 8/8 imply coverage it does not have. Glyph count 0 -> 0; there were none to cut. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
c7dc4a4a02cdb73d3498819991b102c4eafaab63
by plankatron <fleeky@gmail.com> · 2026-09-20T20:36:37.000Z

Changed files (1) +201 -18

README.md +201 -18
……@@ -1,31 +1,214 @@
11# favela-identity
22
3**An account is a User. A DID is a credential attached to it. A bridge is a resource it holds.**
3The identity primitives favela shares: verifying that a device belongs to an identity, the exact
4bytes a peer signs to act on a bridge, and the ceremony that proves control of an identity root.
45
5favela made the atproto DID the root of identity. That is why the DID cannot be rotated, why
6destroying a vault destroys the account, and why there is no way to add or subtract a credential.
7pear-git-v3 has never worked that way: its `IdentityRegistry` resolves a DID with
8`getIdentityByDid` sitting beside `getIdentityBySSHKey` and `getIdentityByNoiseKey` — a DID is a
9leaf there, one credential among several hanging off a durable user record.
6No storage, no network, no keys of its own. `readAttested` is a pure function of a proof's bytes —
7no disk, no network, no clock — which is what lets it run inside an autobase `apply` and lets a
8bridge establish who someone is while holding nothing of theirs.
109
11This package adopts that model for favela.
10## Install
1211
13## Separate registry, shared code
12```sh
13npm install favela-identity
14```
1415
15favela runs its **own IdentityRegistry autobase**, not pear-git-v3's. A forge account and a favela
16account are different accounts, and interlinking them is deliberately left for later.
16`index.js` exports the attestation functions and the claim/release/front byte builders. Everything
17else is deep-imported, and that path is the contract:
1718
18That costs nothing to build: `IdentityRegistry` already takes `(store, { prefix, bootstrapKey,
19keyPair, db, fastForwardTo })`, so a separate instance is a separate corestore and a separate
20bootstrap key. **The code is reused; only the instance is new.**
19```js
20import { readAttested, signAttested } from 'favela-identity'
21import { Admission } from 'favela-identity/lib/admission.js'
22import { FavelaPeer } from 'favela-identity/lib/peer-client.js'
23import { collectBytes, COLLECT } from 'favela-identity/lib/bridge-claim.js'
24```
2125
22## Status
26## API
2327
24Design. Nothing built. Start at `spec/identity.md`.
28### `lib/attestation.js`
2529
26Related: `favela-markdown` (the record — its author reference is decided here),
27`packages/hyper-identity` (the registry implementation), hyper-social #47, #48, #49.
30The one place that knows `keet-identity-key`. It decodes `ProofEncoding` from that package's
31internal `lib/`, which is a coupling kept here so there is one place to fix if it moves.
32
33#### `readAttested(proof, attestedData, expectedRootHex) -> { root, device, chain } | null`
34
35Verify that some identity attested `attestedData`, optionally pinning which identity.
36
37| | |
38|---|---|
39| `root` | hex identity public key the chain resolves to — the anchor |
40| `device` | hex device key this proof attests; a real Ed25519 keypair, one that can sign |
41| `chain` | hex device keys from the root's first child down to `device` — the ancestry |
42
43A non-null return means exactly: *this root attested this device, and that device signed these
44bytes.* One `verify` call settles both halves, so neither is trusted separately.
45
46`expectedRootHex` is pinned inside `verify` as `opts.expectedIdentity` rather than string-compared
47afterwards — a comparison the caller has to remember to write is a comparison someone eventually
48forgets. `proof` may be a Buffer or base64, because a daemon stores it as text and hands it back
49over a socket.
50
51Total: returns `null` for anything it cannot verify and never throws. `IdentityKey.verify` does
52throw — measured, an empty buffer and a truncated one both raise "Out of bounds" and random bytes
53raise "uint is greater than the maximum safe integer" — and `apply` may not throw for any reason,
54because a throw mid-replay wedges the base while the op that caused it is already durable.
55
56It returns the chain, which `verify` does not. `attestDevice(publicKey, parent, proof)` lets any
57device mint further devices from its own keypair and proof, with no mnemonic, so a compromised
58device can have descendants and revoking it has to reach them.
59
60#### `readProof(proof, writerKeyHex) -> { root, device, chain } | null`
61
62The admission case: `writerKeyHex` is the hypercore writer key being admitted, verified as attested
63data rather than as the chain's tip. A writer core key is manifest-derived, so nobody holds its
64secret half, so it can never sign and a chain naming it can never be extended — measured, attesting
65one and then attesting from it is refused. A device key is an ordinary keypair and can sign. So the
66device key carries the identity and the writer key is bound to it as data.
67
68#### `signAttested(bytes, cred) -> string`
69
70Sign arbitrary bytes as this device, carrying the chain that proves who it is. `cred` is
71`{ keyPair, proof }`; the result is base64 and self-contained — "identity R, via device D, signed
72these bytes", verifiable with no lookup and no state. Throws if `cred` is incomplete.
73
74It overwrites `proof.data`, so one credential proof yields one signature per call and each call must
75start from the stored credential rather than from a previous result.
76
77### `lib/bridge-claim.js`
78
79The exact bytes a peer signs to act on a bridge. One definition, because the claimant builds these
80bytes and the bridge rebuilds them: if the two disagree by a byte, `readAttested` returns `null` and
81the bridge reports "proof does not attest that identity" — which reads as a hostile caller, not as a
82version skew. There is no error that says "we disagree about the format".
83
84| constant | value | act |
85|---|---|---|
86| `CLAIM` | `favela-bridge:v1:claim` | take a name on a bridge |
87| `RELEASE` | `favela-bridge:v1:release` | give one up — permanent |
88| `FRONT` | `favela-bridge:v1:front` | ask a bridge to carry an identity this node holds |
89| `COLLECT` | `favela-bridge:v1:collect` | read what a bridge observed for a name |
90
91Four prefixes, not one reused. Releasing burns a name permanently, so a proof that could be
92repurposed between claim and release would let a captured claim destroy what it was meant to create.
93Collecting is a read, so a proof that could serve as either would let a request to read someone's
94inbound graph be replayed as a request to burn their actor.
95
96```js
97claimBytes({ nonce, username, feedKey }) // CLAIM:nonce:username:feedKey
98releaseBytes({ nonce, username }) // RELEASE:nonce:username — no feed
99frontBytes({ nonce, id }) // FRONT:nonce:id — id is opaque here
100collectBytes({ nonce, username, since }) // COLLECT:nonce:username:cursor(since)
101cursor(since) // non-finite, negative and absent all mean '0'
102```
103
104The request is inside the signed bytes, not just the nonce. A proof covering only the challenge
105could be captured and replayed to claim a *different* name under the signer's root — consent proven,
106control proven, wrong name registered. The same argument binds the cursor: otherwise a proof lifted
107in flight could be resubmitted with `since` wound back to 0, turning an incremental poll into a
108demand for the whole buffer.
109
110`cursor` normalises here rather than in the callers, because a claimant has a number and a bridge has
111whatever came off the wire, and `0`, `'0'` and `undefined` must all produce the same bytes.
112
113Colons are safe separators: a bridge username cannot contain one (it must be a usable path segment),
114and nonces and feed keys are hex.
115
116### `lib/admission.js`
117
118`class Admission({ ttlMs = 120000, now = Date.now })` — the challenge/response ceremony that proves
119a caller controls the identity root it names. Shared because there are two bridges and there must
120not be two ceremonies.
121
122| | |
123|---|---|
124| `challenge()` | mint a single-use nonce from a CSPRNG (`bare-crypto`) |
125| `verifyClaim({ nonce, username, feedKey, root, proof })` | `{ ok, root, device }` or `{ ok: false, error }` |
126| `verifyRelease({ nonce, username, root, proof })` | same |
127| `verifyFront({ nonce, id, root, proof })` | same; `id` is rejected over 256 chars or containing `:::` |
128| `verifyCollect({ nonce, username, since, root, proof })` | same |
129| `claimBytes` `releaseBytes` `frontBytes` `collectBytes` | pass-throughs, so one object builds and verifies |
130| `open` | count of live challenges |
131
132A challenge is required rather than decorative: `readAttested` has no clock, and a verifier with no
133clock cannot detect replay, so freshness has to come from something the bridge issues and remembers.
134
135The nonce is burned whether or not the proof verifies. Consuming only on success would leave a live
136challenge for an attacker to keep trying proofs against.
137
138`verifyCollect` proves one half only — that the caller controls this root. It says nothing about
139whether that root holds that name, because this package has no registry and must not grow one. An
140identity root is public, so a bridge that skips the second half hands a stranger the entire inbound
141social graph of anyone whose username they can read off a web page. The second half lives in
142ap-bridge's `collect.js`.
143
144Total: every path returns `{ ok, error }` and nothing throws. This runs on a request path fed by
145strangers.
146
147### `lib/peer-client.js`
148
149`class FavelaPeer(sock)` — how anything reaches its own favela-peer over the local unix socket.
150Newline-delimited JSON, `{id, cmd, args}` → `{id, ok, value|error}`, one request per connection,
15130 s timeout. Not hyper-svc: the two daemons in this path speak different protocols and favela-peer's
152is deliberately the smallest thing that works.
153
154| | |
155|---|---|
156| `call(cmd, args)` | one request; rejects on refusal or timeout |
157| `whoami()` | |
158| `reach()` | `{ networks, credential }`, plus `unknown: true` on failure |
159| `ownPosts()` | `{ feed, rows }` — this node's own posts, newest last |
160
161Read only, and pull. The peer is never asked to push and never told a bridge exists — that boundary
162is enforced by `favela-peer/test/boundary.test.js`, because the moment the daemon imports the
163projection the dependency has been inverted.
164
165`reach()` must be read every cycle and never cached: someone who unticks a network in settings has
166withdrawn consent, and a stale copy would keep publishing. A failure reads as off — unknown consent
167is not consent.
168
169`ownPosts()` is our own only. Forwarding a followed feed's posts under our own identity would be
170republishing someone else's words as ours, which is a different act.
171
172## Consumers
173
174Each one re-exports rather than reimplements:
175
176| | |
177|---|---|
178| `favela-peer/lib/attestation.js` | `readAttested`, `readProof`, `signAttested` |
179| `favela-peer/lib/bridge.js` | `signAttested` + all four byte builders |
180| `ap-bridge/lib/admission.js` | `Admission` |
181| `ap-bridge/lib/favela-source.js`, `lib/per-user.js` | `FavelaPeer` |
182| `favela-bridge/lib/peer.js` | `FavelaPeer` |
183
184## Design
185
186`spec/identity.md` holds the account model this package belongs to: an account is a User, a DID is a
187credential attached to it, a bridge is a resource it holds. favela made the atproto DID the root of
188identity, which is why the DID cannot be rotated, why destroying a vault destroys the account, and
189why there is no way to add or subtract a credential. pear-git-v3 has never worked that way —
190`getIdentityByDid` sits beside `getIdentityBySSHKey` and `getIdentityByNoiseKey`, so a DID is a leaf
191there, one credential among several hanging off a durable user record.
192
193That account registry is still a design. What is built is this package's four modules and the
194ceremony they carry. favela-peer does run its own `IdentityRegistry` instance from `hyper-identity2`
195(the autobee binding, because the daemon runs autobee and must not carry autobase beside it), but it
196registers *browsers* — controllers that authenticate so the daemon can write — not favela accounts.
197
198`spec/onboarding.md` covers minting a DID and claiming a name on a bridge. Related: hyper-social #47,
199#48, #49, #58.
200
201## Test
202
203```sh
204npm test # brittle-bare test/*.test.js
205```
206
2078 tests, 37 asserts, ~0.2 s, over `attestation.js` and `bridge-claim.js`. `Admission` and
208`FavelaPeer` have no tests here; `Admission` is exercised by ap-bridge's `admission.test.js`,
209`collect.test.js`, `register-rpc.test.js`, `register-route.test.js` and
210`reclaim-and-pending.test.js`.
28211
29212## License
30213
31Not yet declared.
214MIT