OverviewFilesCommitsIssuesDocsReleasesPeers

favela-android commits / 5a6a831

session: the phone authenticates to its own favela-web instead of browsing it anonymously
favela-web derives the viewer from a session cookie, so an anonymous request gets viewerDid: null — which silently costs liked/reposted state, per-viewer mutes and blocks, and 401s every write route. The legacy IPC verbs passed viewerDid explicitly, so porting a screen to HTTP without a session would quietly degrade it: posts would render un-liked and nothing would say why. The browser needs a pairing ceremony because it holds no DID key. The phone is not a browser — its worklet runs hpp and holds the signing key, so it mints its own delegation in-process via SwarmDevice.delegate(). No invite, no tunnel, no paste. That call also records the sub-key with addDevice, so this phone is a listable, revocable device like any other; a session that cannot be revoked is not a device. Two bare traps hit while building it, both already documented in the fleet and both worth the comments left in place: - TextEncoder is absent under Bare and @noble/hashes touches it at MODULE LOAD, so favela-web/lib/bare-globals.js must be imported FIRST or the graph throws before any assertion runs (hyper-social #40 §4). - generateAtprotoKey() is NOT bare-safe — @noble's randomSecretKey needs crypto.getRandomValues. atproto-identity's own auth.js sidesteps this by taking randomness from hypercore-crypto; mintSubKey does the same, and retries rather than assume 32 random bytes is a valid curve scalar. Tests use the REAL verifier (makeAtprotoWebVerify) rather than a mock — the entire risk is emitting a proof shape favela-web rejects, so a mock would prove nothing. 21 assertions, including the control: a delegation signed by the wrong key IS refused. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
5a6a831ad50b3154556bb2ca6b4493d43c7389e1
by plankatron <fleeky@gmail.com> · 2026-08-11T17:41:15.000Z

Changed files (3) +295 -1

lib/session.js +155 -0
……@@ -0,0 +1,155 @@
1// The phone's session with its OWN in-process favela-web.
2//
3// WHY THIS EXISTS. favela-web derives the viewer from a session cookie (`viewerOf(req)` →
4// `favela_session`). Anonymous requests get `viewerDid: null`, which silently costs:
5// - `liked` / `reposted` state on every post
6// - per-viewer mutes and blocks in withMod
7// - every write route (compose, like, repost, follow, delete) — all 401
8// The phone's legacy IPC verbs passed viewerDid explicitly, so porting a screen to HTTP without a
9// session would quietly degrade it: posts would just render un-liked and nobody would be told.
10//
11// WHY THE PHONE CAN SELF-AUTHENTICATE. The browser flow needs a pairing ceremony because the browser
12// holds no DID key. The phone is not a browser: its worklet RUNS hpp and holds the DID's signing key,
13// so it can mint its own delegation locally (SwarmDevice.delegate). No invite, no tunnel, no paste.
14//
15// THE PROOF (atproto-identity/lib/web-auth.js):
16// sig = signAssertion({ challenge, exp }, subPriv) — sub-key signs the challenge
17// delegation = signAssertion({ iss: did, sub: subKeyHex, iat, exp }, didKey) — hpp vouches for the sub-key
18// favela-web re-verifies the delegation against the DID's PLC-published #atproto key on EVERY login —
19// there is no TOFU — and separately checks the sub-key has not been revoked in the device registry.
20//
21// The sub-key is a real, listable, revocable device: `delegate` calls addDevice, so this phone shows up
22// in `pgh`/favela device lists and can be revoked like any other. That is deliberate — a session that
23// cannot be revoked is not a device.
24//
25// IO is injected (fetch, fs, the hpp node) so this is testable without a handset, an emulator, or Metro.
26
27import { signAssertion } from 'atproto-identity/lib/auth.js'
28// keyFromPrivate (not generateAtprotoKey) — it produces the 33-byte COMPRESSED pubkey delegate()
29// requires without generating anything, so the curve choice stays in atproto-identity.
30//
31// ⛔ generateAtprotoKey() is NOT bare-safe: it calls @noble's randomSecretKey, which requires
32// `crypto.getRandomValues` — absent under Bare. atproto-identity's own auth.js avoids this the same way,
33// taking randomness from hypercore-crypto (bare-native via sodium). Randomness here does likewise.
34import { keyFromPrivate } from 'atproto-identity/lib/atproto-identity.js'
35import { randomBytes } from 'hypercore-crypto'
36
37const toHex = (b) => Array.from(b).map((x) => x.toString(16).padStart(2, '0')).join('')
38const fromHex = (h) => { const b = new Uint8Array(h.length >> 1); for (let i = 0; i < b.length; i++) b[i] = parseInt(h.substr(i * 2, 2), 16); return b }
39
40// How long the challenge assertion is valid. Short: it is consumed immediately by the login that
41// follows, and favela-web burns the challenge single-use anyway. A long exp here buys nothing and
42// widens the window if the proof ever leaks.
43const SIG_TTL_SEC = 120
44
45/**
46 * A fresh secp256k1 sub-keypair, bare-safe.
47 *
48 * Randomness comes from hypercore-crypto (sodium), not @noble's randomSecretKey — see the import note.
49 * A raw 32 random bytes is not automatically a valid secp256k1 scalar (it must be non-zero and below the
50 * curve order), so retry rather than assume: the odds of a reject are ~2^-128, but "never happens" and
51 * "cannot happen" are different, and the failure would be a key that cannot sign.
52 */
53export function mintSubKey () {
54 for (let i = 0; i < 8; i++) {
55 try { return keyFromPrivate(Uint8Array.from(randomBytes(32))) } catch { /* invalid scalar — draw again */ }
56 }
57 throw new Error('mintSubKey: could not derive a valid secp256k1 key in 8 attempts')
58}
59
60/**
61 * Load the persisted UI sub-keypair, or mint one on first run.
62 *
63 * The key is the phone's IDENTITY AS A DEVICE, not a cache — regenerating it orphans the delegation
64 * recorded in the device registry and leaves a dead entry behind, so it must persist across restarts.
65 *
66 * @param {string} file path to the JSON keyfile
67 * @param {object} fs { readFileSync, writeFileSync, mkdirSync }
68 * @param {(p:string)=>string} dirnameOf
69 * @returns {{ privHex: string, pubHex: string, created: boolean }}
70 */
71export function loadOrCreateSubKey (file, fs, dirnameOf) {
72 try {
73 const parsed = JSON.parse(fs.readFileSync(file, 'utf8'))
74 if (parsed && /^[0-9a-f]{64}$/i.test(parsed.priv) && /^[0-9a-f]{66}$/i.test(parsed.pub)) {
75 return { privHex: parsed.priv.toLowerCase(), pubHex: parsed.pub.toLowerCase(), created: false }
76 }
77 // A malformed keyfile is NOT a reason to mint silently over it — that would orphan the delegation
78 // the old key holds. Fail loudly; recovery is a human decision (revoke the old device, then delete).
79 throw new Error('sub-key file exists but is malformed: ' + file)
80 } catch (e) {
81 if (e && e.message && e.message.startsWith('sub-key file exists')) throw e
82 // Genuinely absent → first run.
83 }
84 const kp = mintSubKey()
85 const rec = { priv: toHex(kp.privateKey), pub: toHex(kp.publicKey), createdAt: new Date().toISOString() }
86 try { fs.mkdirSync(dirnameOf(file), { recursive: true }) } catch {}
87 fs.writeFileSync(file, JSON.stringify(rec, null, 2))
88 return { privHex: rec.priv, pubHex: rec.pub, created: true }
89}
90
91/**
92 * Sign a favela-web challenge with the sub-key.
93 * verifyChallengeSig checks `payload.challenge === challenge`, so that field is the contract.
94 */
95export function signChallenge (challenge, privHex, nowSec = Math.floor(Date.now() / 1000)) {
96 if (!challenge) throw new Error('signChallenge: challenge required')
97 return signAssertion({ challenge, iat: nowSec, exp: nowSec + SIG_TTL_SEC }, fromHex(privHex))
98}
99
100/** Pull the session token out of a Set-Cookie header. Returns null if absent. */
101export function tokenFromSetCookie (setCookie) {
102 if (!setCookie) return null
103 const raw = Array.isArray(setCookie) ? setCookie.join('; ') : String(setCookie)
104 const m = raw.match(/(?:^|[;,\s])favela_session=([^;,\s]+)/)
105 return m ? m[1] : null
106}
107
108/**
109 * Establish a session against the in-process favela-web.
110 *
111 * @param {object} opts
112 * @param {string} opts.favelaUrl e.g. http://127.0.0.1:4041
113 * @param {object} opts.node an hpp SwarmDevice (must expose .did and .delegate)
114 * @param {string} opts.privHex sub-key private key
115 * @param {string} opts.pubHex sub-key compressed public key
116 * @param {Function} opts.fetchImpl fetch(url, init)
117 * @param {string} [opts.label] device label recorded in the registry
118 * @returns {Promise<{ token: string, did: string, handle: string|null }>}
119 */
120export async function establishSession ({ favelaUrl, node, privHex, pubHex, fetchImpl, label = 'favela-android' } = {}) {
121 if (!favelaUrl) throw new Error('establishSession: favelaUrl required')
122 if (!node || typeof node.delegate !== 'function') throw new Error('establishSession: an hpp node with delegate() required')
123 if (!privHex || !pubHex) throw new Error('establishSession: sub-keypair required')
124 if (typeof fetchImpl !== 'function') throw new Error('establishSession: fetchImpl required')
125
126 // 1. Mint (or re-mint) the delegation. Idempotent by design: delegate() re-signs and re-records the
127 // SAME sub-key, so running this every boot is safe and self-heals a registry entry that never
128 // landed (addDevice is fire-and-forget precisely because it can block on an unconverged writer).
129 const del = await node.delegate({ subKey: pubHex, label })
130 if (!del || !del.delegation) throw new Error('establishSession: hpp returned no delegation')
131
132 // 2. Fresh single-use challenge.
133 const cRes = await fetchImpl(favelaUrl + '/api/auth/challenge')
134 if (!cRes.ok) throw new Error('challenge failed: HTTP ' + cRes.status)
135 const { challenge } = await cRes.json()
136 if (!challenge) throw new Error('challenge failed: no challenge in response')
137
138 // 3. Prove live control of the sub-key, and present hpp's delegation for it.
139 const proof = { did: node.did, subKey: pubHex, challenge, sig: signChallenge(challenge, privHex), delegation: del.delegation }
140 const lRes = await fetchImpl(favelaUrl + '/api/auth/login', {
141 method: 'POST',
142 headers: { 'content-type': 'application/json' },
143 body: JSON.stringify(proof)
144 })
145 const body = await lRes.json().catch(() => ({}))
146 if (!lRes.ok) throw new Error('login rejected (HTTP ' + lRes.status + '): ' + (body.error || 'unknown'))
147
148 // 4. The cookie is the session. HttpOnly is irrelevant here — we read the header directly rather
149 // than from a document, because there is no document.
150 const getHeader = (name) => (lRes.headers && typeof lRes.headers.get === 'function' ? lRes.headers.get(name) : null)
151 const token = tokenFromSetCookie(getHeader('set-cookie'))
152 if (!token) throw new Error('login succeeded but no favela_session cookie was returned')
153
154 return { token, did: body.did || node.did, handle: body.handle || null }
155}
package.json +1 -1
……@@ -9,7 +9,7 @@
99 "pack:worklet": "bare-pack --preset android --defer bare-abstract-socket --defer pgh-runtime --out android/app/src/main/assets/worklet.bundle lib/node.js",
1010 "stage:addons": "node scripts/stage-addons.mjs",
1111 "prebuild:android": "npm run pack:worklet && npm run stamp:worklet && npm run stage:addons",
12 "test": "bare test/adopt.test.mjs && bare test/ipc.test.mjs",
12 "test": "bare test/adopt.test.mjs && bare test/ipc.test.mjs && bare test/session.test.mjs",
1313 "stamp:worklet": "node -e \"const fs=require('fs');const p='android/app/src/main/assets/worklet.bundle';fs.writeFileSync(p+'.size',String(fs.statSync(p).size))\""
1414 },
1515 "dependencies": {
test/session.test.mjs +139 -0
……@@ -0,0 +1,139 @@
1// The phone's self-authentication against its own in-process favela-web.
2//
3// The CRYPTO is real: the sub-key is a real secp256k1 keypair, the challenge signature and the
4// delegation are real ES256K assertions, and they are verified with atproto-identity's REAL verifier —
5// the same makeAtprotoWebVerify favela-web wires into FavelaAuth. Only the transport (fetch) and the
6// hpp node are stood in for, because those need a handset and a DHT respectively.
7//
8// A mock verifier would prove nothing: the whole risk here is producing a proof shape that favela-web
9// rejects, so the verifier must be the real one.
10//
11// Run: bare test/session.test.mjs
12// ⛔ FIRST — Bare omits TextEncoder/TextDecoder, which @noble/hashes touches AT MODULE LOAD. Without
13// this the import graph below throws before a single assertion runs (hyper-social #40 §4: TextEncoder
14// is guaranteed by neither Bare nor Hermes). lib/node.js imports the same shim first, for the same reason.
15import 'favela-web/lib/bare-globals.js'
16import fsm from 'bare-fs'
17import os from 'bare-os'
18import pathm from 'bare-path'
19import { keyFromPrivate } from 'atproto-identity/lib/atproto-identity.js'
20import { signAssertion } from 'atproto-identity/lib/auth.js'
21import { makeAtprotoWebVerify, verifyChallengeSig } from 'atproto-identity/lib/web-auth.js'
22import { loadOrCreateSubKey, signChallenge, tokenFromSetCookie, establishSession, mintSubKey } from '../lib/session.js'
23
24const assert = (c, m) => { if (!c) { console.error('FAIL:', m); process.exit(1) } console.log(' ok -', m) }
25const toHex = (b) => Array.from(b).map((x) => x.toString(16).padStart(2, '0')).join('')
26const fromHex = (h) => { const b = new Uint8Array(h.length >> 1); for (let i = 0; i < b.length; i++) b[i] = parseInt(h.substr(i * 2, 2), 16); return b }
27
28const tmp = fsm.mkdtempSync(pathm.join(os.tmpdir(), 'favela-session-'))
29
30// ── 1. sub-key persistence ──────────────────────────────────────────────────────────────────────────
31console.log('sub-key')
32const keyFile = pathm.join(tmp, 'sub', 'ui-subkey.json')
33const k1 = loadOrCreateSubKey(keyFile, fsm, pathm.dirname)
34assert(k1.created === true, 'mints on first run')
35assert(/^[0-9a-f]{64}$/.test(k1.privHex), 'private key is 32 bytes hex')
36assert(/^[0-9a-f]{66}$/.test(k1.pubHex), 'public key is 33-byte COMPRESSED hex (what delegate() requires)')
37assert(toHex(keyFromPrivate(fromHex(k1.privHex)).publicKey) === k1.pubHex, 'pub really derives from priv')
38
39const k2 = loadOrCreateSubKey(keyFile, fsm, pathm.dirname)
40assert(k2.created === false && k2.privHex === k1.privHex, 'PERSISTS — a regenerated key would orphan the delegation')
41
42// A malformed keyfile must NOT be silently overwritten: the old key may still hold a live delegation
43// in the device registry, and minting over it leaves a dead entry nobody can account for.
44fsm.writeFileSync(keyFile, '{"priv":"nope"}')
45let threw = null
46try { loadOrCreateSubKey(keyFile, fsm, pathm.dirname) } catch (e) { threw = e }
47assert(threw && /malformed/.test(threw.message), 'refuses to overwrite a malformed keyfile')
48fsm.writeFileSync(keyFile, JSON.stringify({ priv: k1.privHex, pub: k1.pubHex }))
49
50// ── 2. the challenge signature verifies with the REAL verifier ──────────────────────────────────────
51console.log('challenge signature')
52const challenge = 'a'.repeat(64)
53const sig = signChallenge(challenge, k1.privHex)
54assert(verifyChallengeSig({ subKey: k1.pubHex, challenge, sig }) === true, 'real verifyChallengeSig accepts it')
55assert(verifyChallengeSig({ subKey: k1.pubHex, challenge: 'b'.repeat(64), sig }) === false, 'rejects a DIFFERENT challenge (anti-replay)')
56
57// ── 3. cookie parsing ───────────────────────────────────────────────────────────────────────────────
58console.log('set-cookie')
59assert(tokenFromSetCookie('favela_session=abc123; Path=/; HttpOnly; SameSite=Lax') === 'abc123', 'extracts the token')
60assert(tokenFromSetCookie('other=1; favela_session=xyz; Path=/') === 'xyz', 'finds it among other cookies')
61assert(tokenFromSetCookie(null) === null, 'null when absent')
62
63// ── 4. the whole proof, verified as favela-web verifies it ──────────────────────────────────────────
64// A fake hpp node that mints a delegation exactly as SwarmDevice.delegate does, and a fake favela-web
65// that runs the REAL makeAtprotoWebVerify over what establishSession sends.
66console.log('end-to-end proof')
67const didKp = mintSubKey() // stands in for the DID's #atproto key
68const didPriv = didKp.privateKey
69const didPub = didKp.publicKey
70const DID = 'did:plc:testtesttesttesttesttest'
71
72const fakeNode = {
73 did: DID,
74 delegate ({ subKey }) {
75 const now = Math.floor(Date.now() / 1000)
76 return { did: DID, subKey, delegation: signAssertion({ iss: DID, sub: subKey, iat: now, exp: now + 3600 }, didPriv), exp: now + 3600 }
77 }
78}
79
80// resolveVerifyKey is what favela-web wires to the PLC directory; here it returns the DID's key.
81const webVerify = makeAtprotoWebVerify({ resolveVerifyKey: async () => didPub })
82
83let seenProof = null
84const fakeFetch = async (url, init) => {
85 if (url.endsWith('/api/auth/challenge')) {
86 return { ok: true, status: 200, json: async () => ({ challenge: 'c'.repeat(64) }) }
87 }
88 if (url.endsWith('/api/auth/login')) {
89 seenProof = JSON.parse(init.body)
90 const ok = await webVerify(seenProof) // ← the REAL verifier decides
91 if (!ok) return { ok: false, status: 401, json: async () => ({ error: 'Invalid atproto proof' }), headers: { get: () => null } }
92 return {
93 ok: true,
94 status: 200,
95 json: async () => ({ did: DID, handle: 'phone.test' }),
96 headers: { get: (h) => (h.toLowerCase() === 'set-cookie' ? 'favela_session=tok-real; Path=/; HttpOnly' : null) }
97 }
98 }
99 throw new Error('unexpected url ' + url)
100}
101
102const sess = await establishSession({
103 favelaUrl: 'http://127.0.0.1:4041', node: fakeNode, privHex: k1.privHex, pubHex: k1.pubHex, fetchImpl: fakeFetch
104})
105assert(sess.token === 'tok-real', 'returns the session token')
106assert(sess.did === DID, 'returns the DID')
107assert(seenProof.subKey === k1.pubHex, 'proof carries the compressed sub-key')
108assert(!!seenProof.delegation && !!seenProof.sig, 'proof carries both delegation and challenge sig')
109
110// ── 5. it must FAIL when the delegation is by the wrong key ─────────────────────────────────────────
111// The control. Without this the test only shows the happy path agrees with itself.
112const wrongPriv = mintSubKey().privateKey
113const badNode = {
114 did: DID,
115 delegate ({ subKey }) {
116 const now = Math.floor(Date.now() / 1000)
117 return { did: DID, subKey, delegation: signAssertion({ iss: DID, sub: subKey, iat: now, exp: now + 3600 }, wrongPriv), exp: now + 3600 }
118 }
119}
120let rejected = null
121try {
122 await establishSession({ favelaUrl: 'http://127.0.0.1:4041', node: badNode, privHex: k1.privHex, pubHex: k1.pubHex, fetchImpl: fakeFetch })
123} catch (e) { rejected = e }
124assert(rejected && /login rejected/.test(rejected.message), 'a delegation NOT signed by the DID key is refused')
125
126// ── 6. argument guards ──────────────────────────────────────────────────────────────────────────────
127console.log('guards')
128for (const [args, why] of [
129 [{ node: fakeNode, privHex: k1.privHex, pubHex: k1.pubHex, fetchImpl: fakeFetch }, 'favelaUrl'],
130 [{ favelaUrl: 'x', privHex: k1.privHex, pubHex: k1.pubHex, fetchImpl: fakeFetch }, 'node with delegate()'],
131 [{ favelaUrl: 'x', node: fakeNode, pubHex: k1.pubHex, fetchImpl: fakeFetch }, 'sub-keypair'],
132 [{ favelaUrl: 'x', node: fakeNode, privHex: k1.privHex, pubHex: k1.pubHex }, 'fetchImpl']
133]) {
134 let e = null
135 try { await establishSession(args) } catch (err) { e = err }
136 assert(e !== null, 'refuses without ' + why)
137}
138
139console.log('\nall session tests passed')