| … | … | @@ -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 | ||
| 27 | import { 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. | |
| 34 | import { keyFromPrivate } from 'atproto-identity/lib/atproto-identity.js' | |
| 35 | import { randomBytes } from 'hypercore-crypto' | |
| 36 | ||
| 37 | const toHex = (b) => Array.from(b).map((x) => x.toString(16).padStart(2, '0')).join('') | |
| 38 | const 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. | |
| 43 | const 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 | */ | |
| 53 | export 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 | */ | |
| 71 | export 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 | */ | |
| 95 | export 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. */ | |
| 101 | export 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 | */ | |
| 120 | export 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 | } |
| … | … | @@ -9,7 +9,7 @@ |
| 9 | 9 | "pack:worklet": "bare-pack --preset android --defer bare-abstract-socket --defer pgh-runtime --out android/app/src/main/assets/worklet.bundle lib/node.js", |
| 10 | 10 | "stage:addons": "node scripts/stage-addons.mjs", |
| 11 | 11 | "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", | |
| 13 | 13 | "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))\"" |
| 14 | 14 | }, |
| 15 | 15 | "dependencies": { |
| … | … | @@ -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. | |
| 15 | import 'favela-web/lib/bare-globals.js' | |
| 16 | import fsm from 'bare-fs' | |
| 17 | import os from 'bare-os' | |
| 18 | import pathm from 'bare-path' | |
| 19 | import { keyFromPrivate } from 'atproto-identity/lib/atproto-identity.js' | |
| 20 | import { signAssertion } from 'atproto-identity/lib/auth.js' | |
| 21 | import { makeAtprotoWebVerify, verifyChallengeSig } from 'atproto-identity/lib/web-auth.js' | |
| 22 | import { loadOrCreateSubKey, signChallenge, tokenFromSetCookie, establishSession, mintSubKey } from '../lib/session.js' | |
| 23 | ||
| 24 | const assert = (c, m) => { if (!c) { console.error('FAIL:', m); process.exit(1) } console.log(' ok -', m) } | |
| 25 | const toHex = (b) => Array.from(b).map((x) => x.toString(16).padStart(2, '0')).join('') | |
| 26 | const 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 | ||
| 28 | const tmp = fsm.mkdtempSync(pathm.join(os.tmpdir(), 'favela-session-')) | |
| 29 | ||
| 30 | // ── 1. sub-key persistence ────────────────────────────────────────────────────────────────────────── | |
| 31 | console.log('sub-key') | |
| 32 | const keyFile = pathm.join(tmp, 'sub', 'ui-subkey.json') | |
| 33 | const k1 = loadOrCreateSubKey(keyFile, fsm, pathm.dirname) | |
| 34 | assert(k1.created === true, 'mints on first run') | |
| 35 | assert(/^[0-9a-f]{64}$/.test(k1.privHex), 'private key is 32 bytes hex') | |
| 36 | assert(/^[0-9a-f]{66}$/.test(k1.pubHex), 'public key is 33-byte COMPRESSED hex (what delegate() requires)') | |
| 37 | assert(toHex(keyFromPrivate(fromHex(k1.privHex)).publicKey) === k1.pubHex, 'pub really derives from priv') | |
| 38 | ||
| 39 | const k2 = loadOrCreateSubKey(keyFile, fsm, pathm.dirname) | |
| 40 | assert(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. | |
| 44 | fsm.writeFileSync(keyFile, '{"priv":"nope"}') | |
| 45 | let threw = null | |
| 46 | try { loadOrCreateSubKey(keyFile, fsm, pathm.dirname) } catch (e) { threw = e } | |
| 47 | assert(threw && /malformed/.test(threw.message), 'refuses to overwrite a malformed keyfile') | |
| 48 | fsm.writeFileSync(keyFile, JSON.stringify({ priv: k1.privHex, pub: k1.pubHex })) | |
| 49 | ||
| 50 | // ── 2. the challenge signature verifies with the REAL verifier ────────────────────────────────────── | |
| 51 | console.log('challenge signature') | |
| 52 | const challenge = 'a'.repeat(64) | |
| 53 | const sig = signChallenge(challenge, k1.privHex) | |
| 54 | assert(verifyChallengeSig({ subKey: k1.pubHex, challenge, sig }) === true, 'real verifyChallengeSig accepts it') | |
| 55 | assert(verifyChallengeSig({ subKey: k1.pubHex, challenge: 'b'.repeat(64), sig }) === false, 'rejects a DIFFERENT challenge (anti-replay)') | |
| 56 | ||
| 57 | // ── 3. cookie parsing ─────────────────────────────────────────────────────────────────────────────── | |
| 58 | console.log('set-cookie') | |
| 59 | assert(tokenFromSetCookie('favela_session=abc123; Path=/; HttpOnly; SameSite=Lax') === 'abc123', 'extracts the token') | |
| 60 | assert(tokenFromSetCookie('other=1; favela_session=xyz; Path=/') === 'xyz', 'finds it among other cookies') | |
| 61 | assert(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. | |
| 66 | console.log('end-to-end proof') | |
| 67 | const didKp = mintSubKey() // stands in for the DID's #atproto key | |
| 68 | const didPriv = didKp.privateKey | |
| 69 | const didPub = didKp.publicKey | |
| 70 | const DID = 'did:plc:testtesttesttesttesttest' | |
| 71 | ||
| 72 | const 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. | |
| 81 | const webVerify = makeAtprotoWebVerify({ resolveVerifyKey: async () => didPub }) | |
| 82 | ||
| 83 | let seenProof = null | |
| 84 | const 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 | ||
| 102 | const sess = await establishSession({ | |
| 103 | favelaUrl: 'http://127.0.0.1:4041', node: fakeNode, privHex: k1.privHex, pubHex: k1.pubHex, fetchImpl: fakeFetch | |
| 104 | }) | |
| 105 | assert(sess.token === 'tok-real', 'returns the session token') | |
| 106 | assert(sess.did === DID, 'returns the DID') | |
| 107 | assert(seenProof.subKey === k1.pubHex, 'proof carries the compressed sub-key') | |
| 108 | assert(!!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. | |
| 112 | const wrongPriv = mintSubKey().privateKey | |
| 113 | const 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 | } | |
| 120 | let rejected = null | |
| 121 | try { | |
| 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 } | |
| 124 | assert(rejected && /login rejected/.test(rejected.message), 'a delegation NOT signed by the DID key is refused') | |
| 125 | ||
| 126 | // ── 6. argument guards ────────────────────────────────────────────────────────────────────────────── | |
| 127 | console.log('guards') | |
| 128 | for (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 | ||
| 139 | console.log('\nall session tests passed') |