OverviewFilesCommitsIssuesDocsReleasesPeers

favela-web / web / static / browser-identity.js

144 lines · javascript ref:
// AtprotoIdentity — the browser sign-in client for favela (the atproto-native "pgv3 method").
//
// Mirrors pear-git-v3's browser-identity.js, adapted from ed25519/TOFU to atproto's DELEGATED model:
//   - A per-origin secp256k1 SUB-KEY lives in localStorage; its secret NEVER leaves the browser.
//   - favela is NOT TOFU (unlike pgv3): the sub-key is powerless until the DID's #atproto key
//     DELEGATES it. That delegation is minted by the KEYHOLDER (the hpp `delegate` RPC) out-of-band —
//     the browser is "paired" to a DID once by storing { did, delegation }, then logs in repeatedly
//     until the delegation expires (default 30d). This is the atproto analog of pgv3 registering a
//     device under the root identity.
//   - Login: GET /api/auth/challenge → the sub-key signs it (ES256K) → POST /api/auth/login. The
//     server sets an HttpOnly session cookie (favela_session), so SSR navigation is authenticated.
//     favela verifies keylessly against the DID's PLC-published key; it holds no key of ours.
//
// The pairing (obtaining a delegation) is deliberately out-of-band for now: on your own machine, where
// your hpp holds the DID key, run its `delegate` RPC for THIS browser's sub-key and paste the result.
// A one-click in-browser "authorize this device" needs the hpp reachable from the browser — deferred.

import { generateSubKey, subKeyOf, signAssertion } from './atproto-client.js'

const SUBKEY_KEY = 'favela-subkey'       // { privHex, subKey } — the browser's secp256k1 sub-key
const PAIRING_KEY = 'favela-pairing'     // { did, delegation, handle? } — the DID that delegated it

export class AtprotoIdentity {
  constructor (opts = {}) {
    this._baseUrl = opts.baseUrl || ''
    this._subkey = null
  }

  // Get or generate the persistent per-origin sub-key. Secret stays in localStorage.
  getSubKey () {
    if (this._subkey) return this._subkey
    if (typeof localStorage !== 'undefined') {
      const raw = localStorage.getItem(SUBKEY_KEY)
      if (raw) {
        try {
          const kp = JSON.parse(raw)
          if (kp && kp.privHex) {
            // Re-derive subKey from the secret so a hand-edited/legacy record can't desync pub↔priv.
            this._subkey = { privHex: kp.privHex, subKey: subKeyOf(kp.privHex) }
            return this._subkey
          }
        } catch {}
      }
    }
    this._subkey = generateSubKey()
    if (typeof localStorage !== 'undefined') localStorage.setItem(SUBKEY_KEY, JSON.stringify(this._subkey))
    return this._subkey
  }

  // The compressed sub-key pubkey hex — this is what you feed to the hpp `delegate` RPC.
  subKeyHex () { return this.getSubKey().subKey }

  // The stored pairing { did, delegation, handle? }, or null if this browser isn't paired yet.
  getPairing () {
    if (typeof localStorage === 'undefined') return null
    try { return JSON.parse(localStorage.getItem(PAIRING_KEY) || 'null') } catch { return null }
  }

  isPaired () { const p = this.getPairing(); return !!(p && p.did && p.delegation) }

  // Pair this browser to a DID by storing the hpp-minted delegation. Light client-side sanity only —
  // the real cryptographic check (the delegation names THIS sub-key and is signed by the DID's PLC key)
  // happens server-side on every login, so a bad paste simply fails login, it never grants access.
  pair ({ did, delegation, handle } = {}) {
    if (!did || !/^did:/.test(String(did))) throw new Error('a valid did: is required')
    if (!delegation || String(delegation).split('.').length !== 3) throw new Error('delegation must be an ES256K assertion (head.body.sig)')
    const rec = { did: String(did), delegation: String(delegation), handle: handle ? String(handle) : null }
    if (typeof localStorage !== 'undefined') localStorage.setItem(PAIRING_KEY, JSON.stringify(rec))
    return rec
  }

  // Ensure a live favela session. Returns { did, handle } on success. Throws if not paired or if the
  // server rejects the proof (expired/revoked delegation, bad sub-key, PLC unreachable). Cookies are
  // HttpOnly so we ask the server "who am I?" via /api/auth/me rather than reading document.cookie.
  async signIn () {
    const pairing = this.getPairing()
    if (!pairing || !pairing.did || !pairing.delegation) throw new Error('this browser is not paired to a DID yet')
    const { challenge } = await this._fetch('/api/auth/challenge')
    if (!challenge) throw new Error('challenge missing')
    const { privHex, subKey } = this.getSubKey()
    const nowSec = Math.floor(Date.now() / 1000)
    const sig = signAssertion({ challenge, iat: nowSec, exp: nowSec + 300 }, privHex) // 5-min proof
    const out = await this._fetch('/api/auth/login', {
      method: 'POST',
      body: { did: pairing.did, subKey, challenge, sig, delegation: pairing.delegation, handle: pairing.handle || undefined }
    })
    return { did: out.did, handle: out.handle || pairing.handle || null }
  }

  // hpp-INITIATED pairing (the fix for the backwards flow): paste the ONE hash-URL invite the hpp minted
  // (via `favela-pair`). The browser generates a sub-key + signs a challenge (proving it holds the secret);
  // favela-web joins the invite's blind-pairing tunnel, has the hpp delegate the sub-key, and mints the
  // session — no manual delegation shuttle. Accepts the raw invite hex OR a `…/login#pair=<invite>` URL.
  // Returns { did, handle } on success.
  async pairWithInvite (invite) {
    const inv = String(invite || '').trim().replace(/^.*[#?&]pair=/, '')
    if (!/^[0-9a-f]{20,}$/i.test(inv)) throw new Error('paste the pairing invite the hpp printed (favela-pair)')
    const { challenge } = await this._fetch('/api/auth/challenge')
    if (!challenge) throw new Error('challenge missing')
    const { privHex, subKey } = this.getSubKey()
    const nowSec = Math.floor(Date.now() / 1000)
    const sig = signAssertion({ challenge, iat: nowSec, exp: nowSec + 300 }, privHex) // 5-min proof
    const out = await this._fetch('/api/auth/pair', { method: 'POST', body: { invite: inv, subKey, challenge, sig } })
    // PERSIST the delegation locally (localStorage favela-pairing) so isPaired() stays true across a
    // favela-web restart — the browser then re-signs-in SILENTLY with the stored delegation (signIn),
    // instead of falling back to the paste-invite screen. This is the pgv3-parity persistence fix.
    if (out.delegation) this.pair({ did: out.did, delegation: out.delegation, handle: out.handle })
    return { did: out.did, handle: out.handle || null }
  }

  // Current session identity per the server (reads the HttpOnly cookie), or null.
  async whoami () {
    try { const me = await this._fetch('/api/auth/me'); return me && me.did ? me : null } catch { return null }
  }

  async logout () {
    try { await this._fetch('/api/auth/logout', { method: 'POST' }) } catch {}
  }

  // Forget the pairing (unpair this browser from the DID). The sub-key is kept so re-pairing the SAME
  // sub-key can reuse an existing delegation; call forgetSubKey() to rotate the key entirely.
  unpair () { if (typeof localStorage !== 'undefined') localStorage.removeItem(PAIRING_KEY) }
  forgetSubKey () { if (typeof localStorage !== 'undefined') localStorage.removeItem(SUBKEY_KEY); this._subkey = null }

  async _fetch (path, opts = {}) {
    const init = { headers: { Accept: 'application/json' }, credentials: 'same-origin' }
    if (opts.method) init.method = opts.method
    if (opts.body) {
      init.method = init.method || 'POST'
      init.headers['Content-Type'] = 'application/json'
      init.body = JSON.stringify(opts.body)
    }
    const res = await fetch(this._baseUrl + path, init)
    if (!res.ok) {
      let msg = ''
      try { const j = await res.json(); msg = j.error || '' } catch { try { msg = await res.text() } catch {} }
      throw new Error(msg || `${res.status}`)
    }
    return res.json()
  }
}

export default AtprotoIdentity