OverviewFilesCommitsIssuesDocsReleasesPeers

favela-identity commits / 8dfa055

0.0.1: attestation, extracted so there is one copy
`readAttested(proof, bytes, expectedRoot)` is a PURE function of the proof's bytes — no disk, no network, no clock — which is the property that lets a service establish who somebody is while holding nothing of theirs. That is why admission to a bridge can be proved by a favela identity rather than by an atproto DID, and why the DID stops needing to be load-bearing anywhere outside atproto itself (identity.md §4.1: a user with no atproto credential is a perfectly valid favela account). ⛔ EXTRACTED, NOT COPIED. It decodes ProofEncoding from keet-identity-key's internal lib/, and its own header says that coupling "lives HERE so there is one place to fix if it moves". A second consumer meant either one shared module or two copies of that coupling — the failure favela-markdown/lib/reach.js names: "written three times… and the third one would eventually be written wrong". favela-peer keeps lib/attestation.js as a re-export: eight modules import it by that path, and relocating an implementation is not a reason to touch eight call sites in one change. ⚠ favela-peer's own boundary test caught the first draft of that re-export, because the comment named two other networks. The daemon does not know what is downstream of it, including in its prose. The tests here are the primitive alone — a device proving membership over an arbitrary challenge, a proof refusing a different challenge, a proof refusing another identity, malformed input refused rather than thrown, and no filesystem, network or clock in the source. favela-peer's attest.test.js stays where it is: it tests the feed's roster apply, which is a different thing that uses this. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
8dfa055dfa5566f9bdb7d9857626914aeb4c4547
by plankatron <noreply@anthropic.com> · 2026-09-08T01:52:50.000Z

Changed files (4) +268 -3

index.js +13 -0
……@@ -0,0 +1,13 @@
1// favela-identity — the identity primitives, shared.
2//
3// ⛔ EXTRACTED SO THERE IS ONE COPY. `lib/attestation.js` decodes `ProofEncoding` from
4// keet-identity-key's internal `lib/`, and its own header says that coupling "lives HERE so there is
5// one place to fix if it moves". A second copy in ap-bridge would put it in two, which is exactly
6// the failure `favela-markdown/lib/reach.js` names: "written three times… and the third one would
7// eventually be written wrong".
8//
9// ⚠ It is a PURE VERIFIER. `readAttested` is a function of the proof's bytes — no disk, no network,
10// no clock — which is what lets a bridge establish who someone is while holding nothing of theirs.
11// That property is the reason admission to a bridge can be proved by a favela identity rather than
12// by an atproto DID.
13export { readAttested, readProof, signAttested } from './lib/attestation.js'
lib/attestation.js +142 -0
……@@ -0,0 +1,142 @@
1// Attestation — the one place that knows keet-identity-key.
2//
3// A device proves it belongs to an identity by carrying a CHAIN PROOF: a signed path from the
4// identity root down to the device's own public key. Verifying one is a pure function of the proof's
5// bytes — no disk, no network, no clock — which is the property that lets it run inside `apply`.
6//
7// ── Why this file exists rather than calling IdentityKey directly ─────────────────────────────
8//
9// 1. ⛔ `IdentityKey.verify` THROWS on malformed input. Measured, not assumed: an empty buffer and
10// a truncated one both raise "Out of bounds", and random bytes raise "uint is greater than the
11// maximum safe integer". apply must be TOTAL — a throw mid-replay wedges the base and the op
12// that caused it is already durable, so there is no version of "fix it and retry". Every call
13// is wrapped here, once, instead of at each site.
14//
15// 2. It returns the CHAIN, which `verify` does not. `verify` gives back
16// `{ receipt, identityPublicKey, devicePublicKey }` and drops the ancestry — but the ancestry is
17// exactly what revocation needs, because `attestDevice(publicKey, parent, proof)` lets any
18// device mint further devices using only its own keypair and its own proof. No mnemonic. So a
19// compromised device can have descendants, and revoking it has to reach them.
20// ⚠ That means decoding `ProofEncoding` from the package's `lib/`, which is internal. It is
21// reachable (keet-identity-key declares no `exports` map), and 3.1↔3.2 are wire-identical, but
22// it is a coupling and it lives HERE so there is one place to fix if it moves.
23//
24// 3. Everything comes back as hex STRINGS. The view stores strings, apply compares strings, and a
25// Buffer that compared by identity instead of by value would be a silent authorisation bug.
26
27import IdentityKey from 'keet-identity-key'
28import { ProofEncoding } from 'keet-identity-key/lib/encoding'
29import c from 'compact-encoding'
30import b4a from 'b4a'
31
32const hex = (k) => (typeof k === 'string' ? k : b4a.toString(k, 'hex'))
33const isKeyHex = (s) => typeof s === 'string' && /^[0-9a-f]{64}$/i.test(s)
34
35/**
36 * readAttested(proof, attestedData, expectedRootHex) -> { root, device, chain } | null
37 *
38 * root hex identity public key the chain resolves to — the ANCHOR
39 * device hex DEVICE key this proof attests — a real Ed25519 keypair, one that can sign
40 * chain hex device keys from the root's first child down to `device`. The ANCESTRY.
41 *
42 * The general form: verify that some identity attested `attestedData`, optionally pinning WHICH
43 * identity. One `verify` call settles both halves — it walks root → … → device checking each
44 * link's signature, then checks `proof.data.signature` against the LAST device in that chain. So a
45 * non-null return means exactly: *this root attested this device, and that device signed these
46 * bytes.* Neither half is trusted separately, and there is no second primitive to keep in step.
47 *
48 * ⛔ `expectedRootHex` is pinned INSIDE verify (`opts.expectedIdentity`) rather than string-compared
49 * afterwards. keet does the same at `keet-core/lib/core/invitations/index.js:122`. A comparison the
50 * caller has to remember to write is a comparison someone eventually forgets.
51 *
52 * ⚠ `attestedData` is not optional for a proof that carries `data`: `verify` on one, called without
53 * it, throws inside blake2b ("input must be a typed array") rather than returning null. Guarded
54 * here, because the caller is apply and apply may not throw for any reason at all.
55 *
56 * TOTAL: returns null for anything it cannot verify, and never throws. `proof` may be a Buffer or
57 * base64 — a daemon stores it as text and hands it back over a socket, so both arrive in practice.
58 */
59export function readAttested (proof, attestedData, expectedRootHex) {
60 if (!proof) return null
61
62 let buf
63 try {
64 buf = typeof proof === 'string' ? b4a.from(proof, 'base64') : proof
65 } catch {
66 return null
67 }
68 if (!buf || buf.length === 0) return null
69
70 if (attestedData !== undefined && (!attestedData || attestedData.length === 0)) return null
71
72 const opts = {}
73 if (expectedRootHex !== undefined) {
74 if (!isKeyHex(expectedRootHex)) return null
75 opts.expectedIdentity = b4a.from(expectedRootHex, 'hex')
76 }
77
78 // Two independent throwers: verify() on malformed bytes, and decode() on bytes that verify
79 // happened to accept. Both are inside the same guard.
80 try {
81 const verified = attestedData === undefined
82 ? IdentityKey.verify(buf, null, opts)
83 : IdentityKey.verify(buf, attestedData, opts)
84 if (!verified) return null
85
86 const decoded = c.decode(ProofEncoding, buf)
87 if (!decoded || !Array.isArray(decoded.chain)) return null
88
89 return {
90 root: hex(verified.identityPublicKey),
91 device: hex(verified.devicePublicKey),
92 chain: decoded.chain.map((link) => hex(link.publicKey))
93 }
94 } catch {
95 return null
96 }
97}
98
99/**
100 * readProof(proof, writerKeyHex) -> { root, device, chain } | null
101 *
102 * The admission case: `writerKeyHex` is the hypercore writer key being admitted, verified as
103 * ATTESTED DATA. That indirection is the whole design:
104 *
105 * - A writer core key is MANIFEST-derived. Nobody holds its secret half, so it can never sign,
106 * so a chain naming it can never be extended — measured: attesting one and then trying to
107 * attest from it is REFUSED.
108 * - A device key is an ordinary keypair. It CAN sign, so an already-linked device can attest the
109 * next one with no mnemonic — which is exactly what pear-git-v3's DeviceLinker does over
110 * blind-pairing, shipping { deviceSecretKey, deviceProof } and never the root.
111 *
112 * So the device key carries the identity, and the writer key is bound to it as data.
113 */
114export function readProof (proof, writerKeyHex) {
115 if (writerKeyHex === undefined) return readAttested(proof, undefined)
116 if (typeof writerKeyHex !== 'string' || writerKeyHex.length === 0) return null
117
118 let attested
119 try { attested = b4a.from(writerKeyHex, 'hex') } catch { return null }
120 if (attested.length === 0) return null
121
122 return readAttested(proof, attested)
123}
124
125/**
126 * Sign arbitrary bytes as this device, carrying the chain that proves who it is.
127 *
128 * The inverse of `readAttested`. `attestData` re-encodes the credential's proof with a `data`
129 * signature over `hash(bytes)` made by the device's own keypair — so the result is a self-contained
130 * "identity R, via device D, signed these bytes", verifiable with no lookup and no state.
131 *
132 * ⚠ It OVERWRITES `proof.data`, so one credential proof yields one signature per call and each call
133 * must start from the stored credential rather than from a previous result. Decoding from the
134 * stored base64 each time is what keeps that true.
135 */
136export function signAttested (bytes, cred) {
137 if (!cred || !cred.keyPair || !cred.proof) throw new Error('signing needs a device credential { keyPair, proof }')
138 const proof = typeof cred.proof === 'string' ? b4a.from(cred.proof, 'base64') : cred.proof
139 return b4a.toString(IdentityKey.attestData(bytes, cred.keyPair, proof), 'base64')
140}
141
142export default { readProof, readAttested, signAttested }
package.json +32 -3
……@@ -1,7 +1,36 @@
11{
22 "name": "favela-identity",
3 "version": "0.0.0",
4 "description": "favela's account layer — a User is durable, a DID is one credential among several, and a bridge is a resource. Runs hyper-identity's IdentityRegistry as its own separate instance.",
3 "version": "0.0.1",
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.",
55 "type": "module",
6 "license": "MIT"
6 "license": "MIT",
7 "main": "index.js",
8 "exports": {
9 ".": "./index.js",
10 "./lib/attestation.js": "./lib/attestation.js"
11 },
12 "scripts": {
13 "test": "brittle-bare test/*.test.js"
14 },
15 "dependencies": {
16 "b4a": "^1.6.0",
17 "keet-identity-key": "3.2.0",
18 "compact-encoding": "^3.3.0",
19 "bare-fs": "^4.0.0",
20 "bare-path": "^3.0.0",
21 "hypercore-crypto": "^3.4.0"
22 },
23 "devDependencies": {
24 "brittle": "^3.5.0"
25 },
26 "imports": {
27 "#node:fs": {
28 "bare": "bare-fs",
29 "default": "fs"
30 },
31 "#node:path": {
32 "bare": "bare-path",
33 "default": "path"
34 }
35 }
736}
test/attestation.test.js +81 -0
……@@ -0,0 +1,81 @@
1// The PRIMITIVE, on its own — what a verifier needs and nothing else.
2//
3// ⛔ favela-peer's `attest.test.js` stays where it is: it tests the FEED's roster apply, which is a
4// different thing that happens to use this. What is asserted here is the property that lets a bridge
5// admit somebody: verifying a chain proof is a pure function of the proof's bytes — no disk, no
6// network, no clock — so a service can establish who you are while holding nothing of yours.
7//
8// ⛔ AND IT NEVER THROWS. `IdentityKey.verify` raises on malformed input (measured: an empty buffer
9// and a truncated one both give "Out of bounds", random bytes give "uint is greater than the maximum
10// safe integer"). This wrapper exists so a caller can treat a bad proof as a refusal rather than an
11// exception — which matters most in exactly the place a hostile one arrives.
12
13import test from 'brittle'
14import b4a from 'b4a'
15import crypto from 'hypercore-crypto'
16import IdentityKey from 'keet-identity-key'
17import { readAttested, signAttested } from '../lib/attestation.js'
18
19// ⚠ A DEVICE is a real Ed25519 keypair — one that can sign. `bootstrap` is what a vault does once,
20// attesting the first device directly from the root.
21async function identity () {
22 const idk = await IdentityKey.from({ mnemonic: IdentityKey.generateMnemonic() })
23 const root = idk.identityKeyPair
24 const keyPair = crypto.keyPair()
25 const proof = await IdentityKey.bootstrap({ identity: root }, keyPair.publicKey)
26 return {
27 rootHex: b4a.toString(root.publicKey, 'hex'),
28 cred: { keyPair, proof: b4a.toString(proof, 'base64') }
29 }
30}
31
32test('★★ a device proves it belongs to an identity, over arbitrary bytes', async (t) => {
33 // This is the admission ceremony in miniature: a bridge hands out a challenge, the device signs
34 // it, and the bridge learns the identity root without ever holding a key.
35 const id = await identity()
36 const challenge = b4a.from('ap-bridge:invite:9f2c1a', 'utf8')
37 const signed = signAttested(challenge, id.cred)
38
39 const got = readAttested(signed, challenge, id.rootHex)
40 t.ok(got, 'the proof verifies')
41 t.is(got.root, id.rootHex, 'and resolves to the identity, not to the device')
42 t.ok(got.device, 'while naming which device signed')
43})
44
45test('⛔ a proof over DIFFERENT bytes does not verify', async (t) => {
46 // Otherwise a captured proof would be a bearer token: replayable against any challenge.
47 const id = await identity()
48 const signed = signAttested(b4a.from('challenge-a'), id.cred)
49 t.absent(readAttested(signed, b4a.from('challenge-b'), id.rootHex))
50})
51
52test('⛔ a proof for ANOTHER identity does not verify against this root', async (t) => {
53 const mine = await identity()
54 const theirs = await identity()
55 const challenge = b4a.from('same-challenge')
56 const signed = signAttested(challenge, theirs.cred)
57 t.absent(readAttested(signed, challenge, mine.rootHex), 'their proof is not my identity')
58 t.ok(readAttested(signed, challenge, theirs.rootHex), 'and is their own')
59})
60
61test('⛔ malformed input is REFUSED, never thrown', async (t) => {
62 // The wrapper's whole reason to exist. A verifier that throws on a hostile proof turns a refusal
63 // into an outage.
64 const challenge = b4a.from('x')
65 for (const bad of [null, undefined, '', 'not-base64!!', b4a.from([1, 2, 3]), b4a.alloc(0)]) {
66 let out
67 t.execution(() => { out = readAttested(bad, challenge, 'ab'.repeat(32)) }, 'survives ' + JSON.stringify(String(bad).slice(0, 12)))
68 t.absent(out, 'and refuses it')
69 }
70})
71
72test('⚠ no disk, no network, no clock — the property a bridge depends on', async (t) => {
73 // Asserted on the source, because it is the reason admission can be proved to a service that
74 // holds nothing: a verifier needing storage or a fetch could not be keyless.
75 const fs = await import('#node:fs')
76 const src = fs.default.readFileSync(new URL('../lib/attestation.js', import.meta.url).pathname, 'utf8')
77 const code = src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*/g, '')
78 for (const forbidden of ['readFile', 'writeFile', 'fetch(', 'Date.now', 'new Date']) {
79 t.absent(code.includes(forbidden), 'no ' + forbidden)
80 }
81})