OverviewFilesCommitsIssuesDocsReleasesPeers

favela-peer commits / cf6ed27

0.0.37: attestation moves to favela-identity
One copy, re-exported. ap-bridge will need the same verifier to admit a user by their favela identity, and the keet-identity-key ProofEncoding coupling must not end up in two places. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
cf6ed2720e552b6994e643cd66f67a8df3de3bc6
by plankatron <fleeky@gmail.com> · 2026-09-08T01:52:50.000Z

Changed files (3) +94 -183

.pgh-lock.json +78 -43
……@@ -1,9 +1,9 @@
11{
22 "name": "favela-peer",
3 "version": "0.0.35",
3 "version": "0.0.36",
44 "runtime": "bare",
5 "rootTreeHash": "24f7243a6a113102f8b6d7ccfbf76d383b73386b83d012252eb7622468f4f35c",
6 "publishedAt": "2026-09-07T20:22:21.564Z",
5 "rootTreeHash": "23b06cbe3a6df55b474321e938d032a04b6eea4ab383602bc2981871b178d47e",
6 "publishedAt": "2026-09-08T01:03:14.653Z",
77 "deps": [
88 {
99 "name": "@hyperswarm/secret-stream",
……@@ -3009,8 +3009,8 @@
30093009 "tree": "c5f18b3620514f54714aeb351f074adface216a335242e323e33d28d261376fe"
30103010 }
30113011 ],
3012 "reason": "0.0.35 authorship edge",
3013 "acceptedAt": "2026-09-07T20:22:21.578Z"
3012 "reason": "0.0.36 reach",
3013 "acceptedAt": "2026-09-08T01:03:14.667Z"
30143014 },
30153015 {
30163016 "type": "VERSION_CONFLICT",
……@@ -3052,10 +3052,15 @@
30523052 "appName": "pgh-sidecar",
30533053 "version": "3.6.1",
30543054 "tree": "6bb5233025d1c30e4f181e29b6ab5af549d8909c7070c7b4438b30f1a4e76ce2"
3055 },
3056 {
3057 "appName": "favela-bridge",
3058 "version": "3.6.1",
3059 "tree": "6bb5233025d1c30e4f181e29b6ab5af549d8909c7070c7b4438b30f1a4e76ce2"
30553060 }
30563061 ],
3057 "reason": "0.0.35 authorship edge",
3058 "acceptedAt": "2026-09-07T20:22:21.578Z"
3062 "reason": "0.0.36 reach",
3063 "acceptedAt": "2026-09-08T01:03:14.667Z"
30593064 },
30603065 {
30613066 "type": "VERSION_CONFLICT",
……@@ -3069,8 +3074,8 @@
30693074 "tree": "58c0bf8698af19464d0ee8354a77ed1ac96d3a5fa18aa4e794686cfbbf080e5d"
30703075 }
30713076 ],
3072 "reason": "0.0.35 authorship edge",
3073 "acceptedAt": "2026-09-07T20:22:21.578Z"
3077 "reason": "0.0.36 reach",
3078 "acceptedAt": "2026-09-08T01:03:14.667Z"
30743079 },
30753080 {
30763081 "type": "VERSION_CONFLICT",
……@@ -3094,8 +3099,8 @@
30943099 "tree": "bdebba5a532ce4846b49f1e5ffad774becfc44182d0c3737f7e441ab42f4c43d"
30953100 }
30963101 ],
3097 "reason": "0.0.35 authorship edge",
3098 "acceptedAt": "2026-09-07T20:22:21.578Z"
3102 "reason": "0.0.36 reach",
3103 "acceptedAt": "2026-09-08T01:03:14.667Z"
30993104 },
31003105 {
31013106 "type": "VERSION_CONFLICT",
……@@ -3122,10 +3127,15 @@
31223127 "appName": "hyper-proto-peer",
31233128 "version": "0.1.4",
31243129 "tree": "b799e6ba8726aa951b45f12cc7ecffe5f4e9ee1e8adf09a34d026d9d5658828a"
3130 },
3131 {
3132 "appName": "favela-bridge",
3133 "version": "0.1.4",
3134 "tree": "b799e6ba8726aa951b45f12cc7ecffe5f4e9ee1e8adf09a34d026d9d5658828a"
31253135 }
31263136 ],
3127 "reason": "0.0.35 authorship edge",
3128 "acceptedAt": "2026-09-07T20:22:21.578Z"
3137 "reason": "0.0.36 reach",
3138 "acceptedAt": "2026-09-08T01:03:14.667Z"
31293139 },
31303140 {
31313141 "type": "VERSION_CONFLICT",
……@@ -3139,8 +3149,8 @@
31393149 "tree": "e92100a4f821a638ac99c1407e4c7e784f8eb9661e6d9119ecbec4faae77cc7e"
31403150 }
31413151 ],
3142 "reason": "0.0.35 authorship edge",
3143 "acceptedAt": "2026-09-07T20:22:21.578Z"
3152 "reason": "0.0.36 reach",
3153 "acceptedAt": "2026-09-08T01:03:14.667Z"
31443154 },
31453155 {
31463156 "type": "VERSION_CONFLICT",
……@@ -3154,8 +3164,8 @@
31543164 "tree": "1f5de2daa08643a5cb8bce2153ca4d55d4504fb366f1f1ef80bca1700ec085f7"
31553165 }
31563166 ],
3157 "reason": "0.0.35 authorship edge",
3158 "acceptedAt": "2026-09-07T20:22:21.578Z"
3167 "reason": "0.0.36 reach",
3168 "acceptedAt": "2026-09-08T01:03:14.667Z"
31593169 },
31603170 {
31613171 "type": "VERSION_CONFLICT",
……@@ -3169,8 +3179,8 @@
31693179 "tree": "579bdc1eaabbca726d3916f1fc74cfd4986653f6748632db586817a2a8caabea"
31703180 }
31713181 ],
3172 "reason": "0.0.35 authorship edge",
3173 "acceptedAt": "2026-09-07T20:22:21.578Z"
3182 "reason": "0.0.36 reach",
3183 "acceptedAt": "2026-09-08T01:03:14.668Z"
31743184 },
31753185 {
31763186 "type": "VERSION_CONFLICT",
……@@ -3184,8 +3194,8 @@
31843194 "tree": "967cf214778dbacd7cc7cc3850ffdff6a6f145b4bd73de741d0938d7d84d33d5"
31853195 }
31863196 ],
3187 "reason": "0.0.35 authorship edge",
3188 "acceptedAt": "2026-09-07T20:22:21.578Z"
3197 "reason": "0.0.36 reach",
3198 "acceptedAt": "2026-09-08T01:03:14.668Z"
31893199 },
31903200 {
31913201 "type": "VERSION_CONFLICT",
……@@ -3207,10 +3217,15 @@
32073217 "appName": "atproto-pds",
32083218 "version": "7.11.0",
32093219 "tree": "35b214bfbf85f8bbcb9f1926470e77f4e240d9b5045b07f81ce83fec24c1380c"
3220 },
3221 {
3222 "appName": "favela-bridge",
3223 "version": "7.11.0",
3224 "tree": "35b214bfbf85f8bbcb9f1926470e77f4e240d9b5045b07f81ce83fec24c1380c"
32103225 }
32113226 ],
3212 "reason": "0.0.35 authorship edge",
3213 "acceptedAt": "2026-09-07T20:22:21.578Z"
3227 "reason": "0.0.36 reach",
3228 "acceptedAt": "2026-09-08T01:03:14.668Z"
32143229 },
32153230 {
32163231 "type": "VERSION_CONFLICT",
……@@ -3224,8 +3239,8 @@
32243239 "tree": "67c4c05f69061f65a2548595c43282be2da8e0934e6ce347afa034b29cc40c81"
32253240 }
32263241 ],
3227 "reason": "0.0.35 authorship edge",
3228 "acceptedAt": "2026-09-07T20:22:21.578Z"
3242 "reason": "0.0.36 reach",
3243 "acceptedAt": "2026-09-08T01:03:14.668Z"
32293244 },
32303245 {
32313246 "type": "VERSION_CONFLICT",
……@@ -3247,10 +3262,15 @@
32473262 "appName": "atproto-pds",
32483263 "version": "11.33.5",
32493264 "tree": "4ef8a03ba96b1488cb7ee35dc582b14d096ae4b3fab5481186c94516d434fe18"
3265 },
3266 {
3267 "appName": "favela-bridge",
3268 "version": "11.33.5",
3269 "tree": "4ef8a03ba96b1488cb7ee35dc582b14d096ae4b3fab5481186c94516d434fe18"
32503270 }
32513271 ],
3252 "reason": "0.0.35 authorship edge",
3253 "acceptedAt": "2026-09-07T20:22:21.578Z"
3272 "reason": "0.0.36 reach",
3273 "acceptedAt": "2026-09-08T01:03:14.668Z"
32543274 },
32553275 {
32563276 "type": "VERSION_CONFLICT",
……@@ -3272,10 +3292,15 @@
32723292 "appName": "atproto-pds",
32733293 "version": "3.1.2",
32743294 "tree": "b7491052487798618f5759cb50e0b98c89db49c6f1b475ca1e5f8ad7893ddb06"
3295 },
3296 {
3297 "appName": "favela-bridge",
3298 "version": "3.2.0",
3299 "tree": "5fc49d698f6ddfd653b3cdaa72ec572d6804293e2e9c3e98303f69417115e903"
32753300 }
32763301 ],
3277 "reason": "0.0.35 authorship edge",
3278 "acceptedAt": "2026-09-07T20:22:21.578Z"
3302 "reason": "0.0.36 reach",
3303 "acceptedAt": "2026-09-08T01:03:14.668Z"
32793304 },
32803305 {
32813306 "type": "VERSION_CONFLICT",
……@@ -3289,8 +3314,8 @@
32893314 "tree": "d0099e1743480f28a08c7374d20ad5048088475180ab606626b63acd59e365a5"
32903315 }
32913316 ],
3292 "reason": "0.0.35 authorship edge",
3293 "acceptedAt": "2026-09-07T20:22:21.578Z"
3317 "reason": "0.0.36 reach",
3318 "acceptedAt": "2026-09-08T01:03:14.668Z"
32943319 },
32953320 {
32963321 "type": "VERSION_CONFLICT",
……@@ -3304,8 +3329,8 @@
33043329 "tree": "60429c7756f039ec2b3e5240f702dee450c1da19ad6de06900a3877c6c72bb52"
33053330 }
33063331 ],
3307 "reason": "0.0.35 authorship edge",
3308 "acceptedAt": "2026-09-07T20:22:21.578Z"
3332 "reason": "0.0.36 reach",
3333 "acceptedAt": "2026-09-08T01:03:14.668Z"
33093334 },
33103335 {
33113336 "type": "VERSION_CONFLICT",
……@@ -3327,10 +3352,15 @@
33273352 "appName": "atproto-pds",
33283353 "version": "3.17.1",
33293354 "tree": "5ad1a6959a786417767537ed91321b27eb23a8388fb53cbf868c15f901e0d3d9"
3355 },
3356 {
3357 "appName": "favela-bridge",
3358 "version": "3.17.2",
3359 "tree": "6fc80ad1ccbfb046613f36299032f21a4752e2614ff8d88a7a5bd7ce66fc1293"
33303360 }
33313361 ],
3332 "reason": "0.0.35 authorship edge",
3333 "acceptedAt": "2026-09-07T20:22:21.578Z"
3362 "reason": "0.0.36 reach",
3363 "acceptedAt": "2026-09-08T01:03:14.668Z"
33343364 },
33353365 {
33363366 "type": "VERSION_CONFLICT",
……@@ -3349,8 +3379,8 @@
33493379 "tree": "92424acff1a50468df203528e41739f44f602904abc4beb34500ce172c6d0ad2"
33503380 }
33513381 ],
3352 "reason": "0.0.35 authorship edge",
3353 "acceptedAt": "2026-09-07T20:22:21.578Z"
3382 "reason": "0.0.36 reach",
3383 "acceptedAt": "2026-09-08T01:03:14.668Z"
33543384 },
33553385 {
33563386 "type": "VERSION_CONFLICT",
……@@ -3367,10 +3397,15 @@
33673397 "appName": "hyperident",
33683398 "version": "6.30.0",
33693399 "tree": "fc36977bda7dd2c7467653aa2ef520890b3a0aae16870a9269f0f71691673dce"
3400 },
3401 {
3402 "appName": "favela-bridge",
3403 "version": "6.33.0",
3404 "tree": "36d01631ddee74ac2ce62971abd7c0ac30562da8dfd6495480da56c534eb80b7"
33703405 }
33713406 ],
3372 "reason": "0.0.35 authorship edge",
3373 "acceptedAt": "2026-09-07T20:22:21.578Z"
3407 "reason": "0.0.36 reach",
3408 "acceptedAt": "2026-09-08T01:03:14.668Z"
33743409 },
33753410 {
33763411 "type": "VERSION_CONFLICT",
……@@ -3384,8 +3419,8 @@
33843419 "tree": "bba869f5c85b11bce5671d57a87cd2793b490588867cd07fbe94cad796114970"
33853420 }
33863421 ],
3387 "reason": "0.0.35 authorship edge",
3388 "acceptedAt": "2026-09-07T20:22:21.578Z"
3422 "reason": "0.0.36 reach",
3423 "acceptedAt": "2026-09-08T01:03:14.668Z"
33893424 },
33903425 {
33913426 "type": "VERSION_CONFLICT",
……@@ -3399,8 +3434,8 @@
33993434 "tree": "2ecc91a8c3595dda5dfc86ace62a70875520adc4e588a2ac14945dbc4313c450"
34003435 }
34013436 ],
3402 "reason": "0.0.35 authorship edge",
3403 "acceptedAt": "2026-09-07T20:22:21.578Z"
3437 "reason": "0.0.36 reach",
3438 "acceptedAt": "2026-09-08T01:03:14.668Z"
34043439 }
34053440 ]
34063441}
lib/attestation.js +14 -139
……@@ -1,142 +1,17 @@
1// Attestation — the one place that knows keet-identity-key.
1// Attestation — re-exported from `favela-identity`, which is where it lives now.
22//
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`.
3// ⛔ ONE COPY. This file used to hold the implementation, and its own header explained why the
4// keet-identity-key `ProofEncoding` coupling "lives HERE so there is one place to fix if it moves".
5// Other components need the same verifier — proving who someone is from a chain proof alone — and a
6// second copy would put that coupling in two places. That is the failure
7// `favela-markdown/lib/reach.js` names: "written three times… and the third one would eventually be
8// written wrong".
69//
7// ── Why this file exists rather than calling IdentityKey directly ─────────────────────────────
10// ⚠ Kept as a re-export rather than deleted. Eight modules import it by this path, and relocating an
11// implementation is not a reason to touch eight call sites in the same change.
812//
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 }
13// ⚠ And the wording here is load-bearing: `test/boundary.test.js` refuses any source file in this
14// package that names another network's stack, and the first draft of this comment named two. The
15// daemon does not know what is downstream of it, including in its prose.
16export { readAttested, readProof, signAttested } from 'favela-identity/lib/attestation.js'
17export { default } from 'favela-identity/lib/attestation.js'
package.json +2 -1
……@@ -47,7 +47,8 @@
4747 "anproto": "file:../anproto",
4848 "blind-pairing": "^2.3.1",
4949 "sodium-hmac": "^2.0.1",
50 "bip39-mnemonic": "^2.2.0"
50 "bip39-mnemonic": "^2.2.0",
51 "favela-identity": "file:../favela-identity"
5152 },
5253 "devDependencies": {
5354 "brittle": "^4.0.2"