| … | … | @@ -1,31 +1,214 @@ |
| 1 | 1 | # favela-identity |
| 2 | 2 | |
| 3 | | **An account is a User. A DID is a credential attached to it. A bridge is a resource it holds.** |
| 3 | The identity primitives favela shares: verifying that a device belongs to an identity, the exact |
| 4 | bytes a peer signs to act on a bridge, and the ceremony that proves control of an identity root. |
| 4 | 5 | |
| 5 | | favela made the atproto DID the root of identity. That is why the DID cannot be rotated, why |
| 6 | | destroying a vault destroys the account, and why there is no way to add or subtract a credential. |
| 7 | | pear-git-v3 has never worked that way: its `IdentityRegistry` resolves a DID with |
| 8 | | `getIdentityByDid` sitting beside `getIdentityBySSHKey` and `getIdentityByNoiseKey` — a DID is a |
| 9 | | leaf there, one credential among several hanging off a durable user record. |
| 6 | No storage, no network, no keys of its own. `readAttested` is a pure function of a proof's bytes — |
| 7 | no disk, no network, no clock — which is what lets it run inside an autobase `apply` and lets a |
| 8 | bridge establish who someone is while holding nothing of theirs. |
| 10 | 9 | |
| 11 | | This package adopts that model for favela. |
| 10 | ## Install |
| 12 | 11 | |
| 13 | | ## Separate registry, shared code |
| 12 | ```sh |
| 13 | npm install favela-identity |
| 14 | ``` |
| 14 | 15 | |
| 15 | | favela runs its **own IdentityRegistry autobase**, not pear-git-v3's. A forge account and a favela |
| 16 | | account 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 |
| 17 | else is deep-imported, and that path is the contract: |
| 17 | 18 | |
| 18 | | That costs nothing to build: `IdentityRegistry` already takes `(store, { prefix, bootstrapKey, |
| 19 | | keyPair, db, fastForwardTo })`, so a separate instance is a separate corestore and a separate |
| 20 | | bootstrap key. **The code is reused; only the instance is new.** |
| 19 | ```js |
| 20 | import { readAttested, signAttested } from 'favela-identity' |
| 21 | import { Admission } from 'favela-identity/lib/admission.js' |
| 22 | import { FavelaPeer } from 'favela-identity/lib/peer-client.js' |
| 23 | import { collectBytes, COLLECT } from 'favela-identity/lib/bridge-claim.js' |
| 24 | ``` |
| 21 | 25 | |
| 22 | | ## Status |
| 26 | ## API |
| 23 | 27 | |
| 24 | | Design. Nothing built. Start at `spec/identity.md`. |
| 28 | ### `lib/attestation.js` |
| 25 | 29 | |
| 26 | | Related: `favela-markdown` (the record — its author reference is decided here), |
| 27 | | `packages/hyper-identity` (the registry implementation), hyper-social #47, #48, #49. |
| 30 | The one place that knows `keet-identity-key`. It decodes `ProofEncoding` from that package's |
| 31 | internal `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 | |
| 35 | Verify 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 | |
| 43 | A non-null return means exactly: *this root attested this device, and that device signed these |
| 44 | bytes.* 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 |
| 47 | afterwards — a comparison the caller has to remember to write is a comparison someone eventually |
| 48 | forgets. `proof` may be a Buffer or base64, because a daemon stores it as text and hands it back |
| 49 | over a socket. |
| 50 | |
| 51 | Total: returns `null` for anything it cannot verify and never throws. `IdentityKey.verify` does |
| 52 | throw — measured, an empty buffer and a truncated one both raise "Out of bounds" and random bytes |
| 53 | raise "uint is greater than the maximum safe integer" — and `apply` may not throw for any reason, |
| 54 | because a throw mid-replay wedges the base while the op that caused it is already durable. |
| 55 | |
| 56 | It returns the chain, which `verify` does not. `attestDevice(publicKey, parent, proof)` lets any |
| 57 | device mint further devices from its own keypair and proof, with no mnemonic, so a compromised |
| 58 | device can have descendants and revoking it has to reach them. |
| 59 | |
| 60 | #### `readProof(proof, writerKeyHex) -> { root, device, chain } | null` |
| 61 | |
| 62 | The admission case: `writerKeyHex` is the hypercore writer key being admitted, verified as attested |
| 63 | data rather than as the chain's tip. A writer core key is manifest-derived, so nobody holds its |
| 64 | secret half, so it can never sign and a chain naming it can never be extended — measured, attesting |
| 65 | one and then attesting from it is refused. A device key is an ordinary keypair and can sign. So the |
| 66 | device key carries the identity and the writer key is bound to it as data. |
| 67 | |
| 68 | #### `signAttested(bytes, cred) -> string` |
| 69 | |
| 70 | Sign 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 |
| 72 | these bytes", verifiable with no lookup and no state. Throws if `cred` is incomplete. |
| 73 | |
| 74 | It overwrites `proof.data`, so one credential proof yields one signature per call and each call must |
| 75 | start from the stored credential rather than from a previous result. |
| 76 | |
| 77 | ### `lib/bridge-claim.js` |
| 78 | |
| 79 | The exact bytes a peer signs to act on a bridge. One definition, because the claimant builds these |
| 80 | bytes and the bridge rebuilds them: if the two disagree by a byte, `readAttested` returns `null` and |
| 81 | the bridge reports "proof does not attest that identity" — which reads as a hostile caller, not as a |
| 82 | version 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 | |
| 91 | Four prefixes, not one reused. Releasing burns a name permanently, so a proof that could be |
| 92 | repurposed between claim and release would let a captured claim destroy what it was meant to create. |
| 93 | Collecting is a read, so a proof that could serve as either would let a request to read someone's |
| 94 | inbound graph be replayed as a request to burn their actor. |
| 95 | |
| 96 | ```js |
| 97 | claimBytes({ nonce, username, feedKey }) // CLAIM:nonce:username:feedKey |
| 98 | releaseBytes({ nonce, username }) // RELEASE:nonce:username — no feed |
| 99 | frontBytes({ nonce, id }) // FRONT:nonce:id — id is opaque here |
| 100 | collectBytes({ nonce, username, since }) // COLLECT:nonce:username:cursor(since) |
| 101 | cursor(since) // non-finite, negative and absent all mean '0' |
| 102 | ``` |
| 103 | |
| 104 | The request is inside the signed bytes, not just the nonce. A proof covering only the challenge |
| 105 | could be captured and replayed to claim a *different* name under the signer's root — consent proven, |
| 106 | control proven, wrong name registered. The same argument binds the cursor: otherwise a proof lifted |
| 107 | in flight could be resubmitted with `since` wound back to 0, turning an incremental poll into a |
| 108 | demand for the whole buffer. |
| 109 | |
| 110 | `cursor` normalises here rather than in the callers, because a claimant has a number and a bridge has |
| 111 | whatever came off the wire, and `0`, `'0'` and `undefined` must all produce the same bytes. |
| 112 | |
| 113 | Colons are safe separators: a bridge username cannot contain one (it must be a usable path segment), |
| 114 | and 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 |
| 119 | a caller controls the identity root it names. Shared because there are two bridges and there must |
| 120 | not 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 | |
| 132 | A challenge is required rather than decorative: `readAttested` has no clock, and a verifier with no |
| 133 | clock cannot detect replay, so freshness has to come from something the bridge issues and remembers. |
| 134 | |
| 135 | The nonce is burned whether or not the proof verifies. Consuming only on success would leave a live |
| 136 | challenge 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 |
| 139 | whether that root holds that name, because this package has no registry and must not grow one. An |
| 140 | identity root is public, so a bridge that skips the second half hands a stranger the entire inbound |
| 141 | social graph of anyone whose username they can read off a web page. The second half lives in |
| 142 | ap-bridge's `collect.js`. |
| 143 | |
| 144 | Total: every path returns `{ ok, error }` and nothing throws. This runs on a request path fed by |
| 145 | strangers. |
| 146 | |
| 147 | ### `lib/peer-client.js` |
| 148 | |
| 149 | `class FavelaPeer(sock)` — how anything reaches its own favela-peer over the local unix socket. |
| 150 | Newline-delimited JSON, `{id, cmd, args}` → `{id, ok, value|error}`, one request per connection, |
| 151 | 30 s timeout. Not hyper-svc: the two daemons in this path speak different protocols and favela-peer's |
| 152 | is 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 | |
| 161 | Read only, and pull. The peer is never asked to push and never told a bridge exists — that boundary |
| 162 | is enforced by `favela-peer/test/boundary.test.js`, because the moment the daemon imports the |
| 163 | projection the dependency has been inverted. |
| 164 | |
| 165 | `reach()` must be read every cycle and never cached: someone who unticks a network in settings has |
| 166 | withdrawn consent, and a stale copy would keep publishing. A failure reads as off — unknown consent |
| 167 | is not consent. |
| 168 | |
| 169 | `ownPosts()` is our own only. Forwarding a followed feed's posts under our own identity would be |
| 170 | republishing someone else's words as ours, which is a different act. |
| 171 | |
| 172 | ## Consumers |
| 173 | |
| 174 | Each 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 |
| 187 | credential attached to it, a bridge is a resource it holds. favela made the atproto DID the root of |
| 188 | identity, which is why the DID cannot be rotated, why destroying a vault destroys the account, and |
| 189 | why 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 |
| 191 | there, one credential among several hanging off a durable user record. |
| 192 | |
| 193 | That account registry is still a design. What is built is this package's four modules and the |
| 194 | ceremony 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 |
| 196 | registers *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 |
| 204 | npm test # brittle-bare test/*.test.js |
| 205 | ``` |
| 206 | |
| 207 | 8 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`. |
| 28 | 211 | |
| 29 | 212 | ## License |
| 30 | 213 | |
| 31 | | Not yet declared. |
| 214 | MIT |