OverviewFilesCommitsIssuesDocsReleasesPeers

favela-android / lib / session.js

175 lines · javascript ref:
// 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 }
}