OverviewFilesCommitsIssuesDocsReleasesPeers

favela-web / test / browser-auth-client.test.mjs

80 lines · javascript ref:
// 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`)