// The phone's session with its OWN in-process favela-web.
//
// WHY THIS EXISTS. favela-web derives the viewer from a session cookie (`viewerOf(req)` →
// `favela_session`). Anonymous requests get `viewerDid: null`, which silently costs:
// - `liked` / `reposted` state on every post
// - per-viewer mutes and blocks in withMod
// - every write route (compose, like, repost, follow, delete) — all 401
// The phone's legacy IPC verbs passed viewerDid explicitly, so porting a screen to HTTP without a
// session would quietly degrade it: posts would just render un-liked and nobody would be told.
//
// WHY THE PHONE CAN SELF-AUTHENTICATE. The browser flow needs a pairing ceremony because the browser
// holds no DID key. The phone is not a browser: its worklet RUNS hpp and holds the DID's signing key,
// so it can mint its own delegation locally (SwarmDevice.delegate). No invite, no tunnel, no paste.
//
// THE PROOF (atproto-identity/lib/web-auth.js):
// sig = signAssertion({ challenge, exp }, subPriv) — sub-key signs the challenge
// delegation = signAssertion({ iss: did, sub: subKeyHex, iat, exp }, didKey) — hpp vouches for the sub-key
// favela-web re-verifies the delegation against the DID's PLC-published #atproto key on EVERY login —
// there is no TOFU — and separately checks the sub-key has not been revoked in the device registry.
//
// The sub-key is a real, listable, revocable device: `delegate` calls addDevice, so this phone shows up
// in `pgh`/favela device lists and can be revoked like any other. That is deliberate — a session that
// cannot be revoked is not a device.
//
// IO is injected (fetch, fs, the hpp node) so this is testable without a handset, an emulator, or Metro.
import { signAssertion } from 'atproto-identity/lib/auth.js'
// keyFromPrivate (not generateAtprotoKey) — it produces the 33-byte COMPRESSED pubkey delegate()
// requires without generating anything, so the curve choice stays in atproto-identity.
//
// ⛔ generateAtprotoKey() is NOT bare-safe: it calls @noble's randomSecretKey, which requires
// `crypto.getRandomValues` — absent under Bare. atproto-identity's own auth.js avoids this the same way,
// taking randomness from hypercore-crypto (bare-native via sodium). Randomness here does likewise.
import { keyFromPrivate } from 'atproto-identity/lib/atproto-identity.js'
import { randomBytes } from 'hypercore-crypto'
const toHex = (b) => Array.from(b).map((x) => x.toString(16).padStart(2, '0')).join('')
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 }
// How long the challenge assertion is valid. Short: it is consumed immediately by the login that
// follows, and favela-web burns the challenge single-use anyway. A long exp here buys nothing and
// widens the window if the proof ever leaks.
const SIG_TTL_SEC = 120
/**
* A fresh secp256k1 sub-keypair, bare-safe.
*
* Randomness comes from hypercore-crypto (sodium), not @noble's randomSecretKey — see the import note.
* A raw 32 random bytes is not automatically a valid secp256k1 scalar (it must be non-zero and below the
* curve order), so retry rather than assume: the odds of a reject are ~2^-128, but "never happens" and
* "cannot happen" are different, and the failure would be a key that cannot sign.
*/
export function mintSubKey () {
for (let i = 0; i < 8; i++) {
try { return keyFromPrivate(Uint8Array.from(randomBytes(32))) } catch { /* invalid scalar — draw again */ }
}
throw new Error('mintSubKey: could not derive a valid secp256k1 key in 8 attempts')
}
/**
* Load the persisted UI sub-keypair, or mint one on first run.
*
* The key is the phone's IDENTITY AS A DEVICE, not a cache — regenerating it orphans the delegation
* recorded in the device registry and leaves a dead entry behind, so it must persist across restarts.
*
* @param {string} file path to the JSON keyfile
* @param {object} fs { readFileSync, writeFileSync, mkdirSync }
* @param {(p:string)=>string} dirnameOf
* @returns {{ privHex: string, pubHex: string, created: boolean }}
*/
export function loadOrCreateSubKey (file, fs, dirnameOf) {
try {
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'))
if (parsed && /^[0-9a-f]{64}$/i.test(parsed.priv) && /^[0-9a-f]{66}$/i.test(parsed.pub)) {
return { privHex: parsed.priv.toLowerCase(), pubHex: parsed.pub.toLowerCase(), created: false }
}
// A malformed keyfile is NOT a reason to mint silently over it — that would orphan the delegation
// the old key holds. Fail loudly; recovery is a human decision (revoke the old device, then delete).
throw new Error('sub-key file exists but is malformed: ' + file)
} catch (e) {
if (e && e.message && e.message.startsWith('sub-key file exists')) throw e
// Genuinely absent → first run.
}
const kp = mintSubKey()
const rec = { priv: toHex(kp.privateKey), pub: toHex(kp.publicKey), createdAt: new Date().toISOString() }
try { fs.mkdirSync(dirnameOf(file), { recursive: true }) } catch {}
fs.writeFileSync(file, JSON.stringify(rec, null, 2))
return { privHex: rec.priv, pubHex: rec.pub, created: true }
}
/**
* Sign a favela-web challenge with the sub-key.
* verifyChallengeSig checks `payload.challenge === challenge`, so that field is the contract.
*/
export function signChallenge (challenge, privHex, nowSec = Math.floor(Date.now() / 1000)) {
if (!challenge) throw new Error('signChallenge: challenge required')
return signAssertion({ challenge, iat: nowSec, exp: nowSec + SIG_TTL_SEC }, fromHex(privHex))
}
/** Pull the session token out of a Set-Cookie header. Returns null if absent. */
export function tokenFromSetCookie (setCookie) {
if (!setCookie) return null
const raw = Array.isArray(setCookie) ? setCookie.join('; ') : String(setCookie)
const m = raw.match(/(?:^|[;,\s])favela_session=([^;,\s]+)/)
return m ? m[1] : null
}
/**
* Pick the SwarmDevice out of whatever hpp handed us.
*
* ⛔ THE SHAPE IS NOT WHAT IT LOOKS LIKE. `bootMultiDevice().nodes[i]` is NOT a SwarmDevice — it is the
* WRAPPER `bootSwarmDevice` returns: `{ device, sb, did, self, blobs, control, membrane, overlay, … }`
* (hyper-proto-peer/lib/device-node.js:595). `delegate()` lives on the SwarmDevice INSIDE it
* (swarm-device.js:699). The wrapper DOES carry `.did`, which is exactly why this survived review and
* every test: it reads like a node right up until the delegate() guard rejects it — on a handset, at
* boot, with the UI silently downgraded to anonymous reads.
*
* Accept either shape, and refuse rather than guess when neither has delegate().
*/
export function sessionNodeOf (entry) {
if (!entry) return null
if (typeof entry.delegate === 'function') return entry // already a SwarmDevice
if (entry.device && typeof entry.device.delegate === 'function') return entry.device // hpp's wrapper
return null
}
/**
* Establish a session against the in-process favela-web.
*
* @param {object} opts
* @param {string} opts.favelaUrl e.g. http://127.0.0.1:4041
* @param {object} opts.node an hpp SwarmDevice (must expose .did and .delegate)
* @param {string} opts.privHex sub-key private key
* @param {string} opts.pubHex sub-key compressed public key
* @param {Function} opts.fetchImpl fetch(url, init)
* @param {string} [opts.label] device label recorded in the registry
* @returns {Promise<{ token: string, did: string, handle: string|null }>}
*/
export async function establishSession ({ favelaUrl, node, privHex, pubHex, fetchImpl, label = 'favela-android' } = {}) {
if (!favelaUrl) throw new Error('establishSession: favelaUrl required')
if (!node || typeof node.delegate !== 'function') throw new Error('establishSession: an hpp node with delegate() required')
if (!privHex || !pubHex) throw new Error('establishSession: sub-keypair required')
if (typeof fetchImpl !== 'function') throw new Error('establishSession: fetchImpl required')
// 1. Mint (or re-mint) the delegation. Idempotent by design: delegate() re-signs and re-records the
// SAME sub-key, so running this every boot is safe and self-heals a registry entry that never
// landed (addDevice is fire-and-forget precisely because it can block on an unconverged writer).
const del = await node.delegate({ subKey: pubHex, label })
if (!del || !del.delegation) throw new Error('establishSession: hpp returned no delegation')
// 2. Fresh single-use challenge.
const cRes = await fetchImpl(favelaUrl + '/api/auth/challenge')
if (!cRes.ok) throw new Error('challenge failed: HTTP ' + cRes.status)
const { challenge } = await cRes.json()
if (!challenge) throw new Error('challenge failed: no challenge in response')
// 3. Prove live control of the sub-key, and present hpp's delegation for it.
const proof = { did: node.did, subKey: pubHex, challenge, sig: signChallenge(challenge, privHex), delegation: del.delegation }
const lRes = await fetchImpl(favelaUrl + '/api/auth/login', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(proof)
})
const body = await lRes.json().catch(() => ({}))
if (!lRes.ok) throw new Error('login rejected (HTTP ' + lRes.status + '): ' + (body.error || 'unknown'))
// 4. The cookie is the session. HttpOnly is irrelevant here — we read the header directly rather
// than from a document, because there is no document.
const getHeader = (name) => (lRes.headers && typeof lRes.headers.get === 'function' ? lRes.headers.get(name) : null)
const token = tokenFromSetCookie(getHeader('set-cookie'))
if (!token) throw new Error('login succeeded but no favela_session cookie was returned')
return { token, did: body.did || node.did, handle: body.handle || null }
}