// favela browser sign-in client — the cross-crypto-world interop proof. The BROWSER client
// (web/static/atproto-client.js) uses the vendored standalone @noble/secp256k1 + pure-JS sha256; the
// SERVER (atproto-identity) uses @noble/curves + @noble/hashes. This proves they agree BYTE-FOR-BYTE:
// a key derived + an assertion signed by the browser lib verify against the server lib, and a full
// login proof built with the browser's challenge-sig flows through FavelaAuth (with a real hpp-minted
// delegation). If this passes, the browser client is wired correctly against the live /api/auth/* path.
// Run: node test/browser-auth-client.test.mjs
import { signAssertion as browserSign, subKeyOf, bytesToHex, hexToBytes } from '../web/static/atproto-client.js'
import { generateSubKey } from '../web/static/atproto-client.js'
import { deriveAtprotoKey, keyFromPrivate } from 'atproto-identity/lib/atproto-identity.js'
import { signAssertion as serverSign, verifyAssertion } from 'atproto-identity/lib/auth.js'
import { makeAtprotoWebVerify } from 'atproto-identity/lib/web-auth.js'
import { FavelaAuth } from '../lib/auth.js'
let pass = 0
const ok = (c, m) => { if (!c) { console.error('FAIL:', m); process.exit(1) } console.log(' ok -', m); pass++ }
const eq = (a, b, m) => ok(a === b, m + ` (got ${JSON.stringify(a)})`)
const threw = async (fn, re, m) => { try { await fn() } catch (e) { return ok(re.test(e.message), m + ` (threw: ${e.message})`) } ok(false, m + ' (did NOT throw)') }
const nowSec = () => Math.floor(Date.now() / 1000)
const DID = 'did:plc:favelabrowser222222222222'
// A fixed sub-key private (the same fixture shape the hpp delegate test uses). The browser derives its
// public key with @noble/secp256k1; the server with @noble/curves. They MUST match.
const SUB_PRIV = '11'.repeat(32)
// ── 1. KEY interop: browser subKeyOf(priv) === server keyFromPrivate(priv).publicKey ──
{
const browserSub = subKeyOf(SUB_PRIV)
const serverSub = bytesToHex(keyFromPrivate(hexToBytes(SUB_PRIV)).publicKey)
eq(browserSub, serverSub, 'browser and server derive the SAME compressed secp256k1 pubkey from one private key')
ok(/^[0-9a-f]{66}$/.test(browserSub), 'sub-key is a 33-byte compressed pubkey hex')
}
// ── 2. SIGNATURE interop: a browser-signed ES256K assertion verifies with the server's verifier ──
{
const subHex = subKeyOf(SUB_PRIV)
const token = browserSign({ challenge: 'a'.repeat(64), iat: nowSec(), exp: nowSec() + 300 }, SUB_PRIV)
eq(token.split('.').length, 3, 'browser assertion is a head.body.sig JWT')
const payload = verifyAssertion(token, hexToBytes(subHex)) // server @noble/curves verify — throws on bad sig
eq(payload.challenge, 'a'.repeat(64), 'server verify accepts the browser sig and reads the payload')
}
// ── 3. FULL LOOP through FavelaAuth: hpp delegates → browser signs the challenge → favela logs in ──
{
const atproto = deriveAtprotoKey('legal winner thank year wave sausage worth useful legal winner thank yellow') // the DID's #atproto key (hpp holds it)
const subHex = subKeyOf(SUB_PRIV)
const delegation = serverSign({ iss: DID, sub: subHex, iat: nowSec(), exp: nowSec() + 3600 }, atproto.privateKey) // hpp mints once
const resolveVerifyKey = async (d) => (d === DID ? atproto.publicKey : null) // PLC mock
const auth = new FavelaAuth({ resolveVerifyKey })
const challenge = auth.mintChallenge()
const sig = browserSign({ challenge, iat: nowSec(), exp: nowSec() + 300 }, SUB_PRIV) // the BROWSER's real challenge sig
const { token, did } = await auth.login({ did: DID, subKey: subHex, challenge, sig, delegation, handle: 'browser.test' })
eq(did, DID, 'FavelaAuth logs in from a browser-produced proof')
ok(token && token.length === 64, 'a session token is minted')
// forgery: a DIFFERENT browser key (not the delegated sub-key) is rejected
const roguePriv = '22'.repeat(32)
const rogueVerify = makeAtprotoWebVerify({ resolveVerifyKey })
const c2 = 'c'.repeat(64)
const rogueSig = browserSign({ challenge: c2, exp: nowSec() + 300 }, roguePriv)
ok(!(await rogueVerify({ did: DID, subKey: subKeyOf(roguePriv), challenge: c2, sig: rogueSig, delegation })), 'an undelegated browser sub-key is rejected')
// tamper: a challenge-sig for a DIFFERENT challenge is rejected
const verify = makeAtprotoWebVerify({ resolveVerifyKey })
const wrongSig = browserSign({ challenge: 'f'.repeat(64), exp: nowSec() + 300 }, SUB_PRIV)
ok(!(await verify({ did: DID, subKey: subHex, challenge: 'e'.repeat(64), sig: wrongSig, delegation })), 'a challenge-sig for a different challenge is rejected')
}
// ── 4. generateSubKey() (the browser random path, exercised via Node's global webcrypto) roundtrips ──
{
const { privHex, subKey } = generateSubKey()
eq(subKeyOf(privHex), subKey, 'generateSubKey derives a consistent {privHex, subKey}')
const token = browserSign({ challenge: 'b'.repeat(64), exp: nowSec() + 300 }, privHex)
const payload = verifyAssertion(token, hexToBytes(subKey))
eq(payload.challenge, 'b'.repeat(64), 'a freshly generated sub-key signs assertions the server verifies')
}
console.log(`\nBROWSER-AUTH-CLIENT OK: ${pass} assertions`)