// 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