// The phone's self-authentication against its own in-process favela-web.
//
// The CRYPTO is real: the sub-key is a real secp256k1 keypair, the challenge signature and the
// delegation are real ES256K assertions, and they are verified with atproto-identity's REAL verifier —
// the same makeAtprotoWebVerify favela-web wires into FavelaAuth. Only the transport (fetch) and the
// hpp node are stood in for, because those need a handset and a DHT respectively.
//
// A mock verifier would prove nothing: the whole risk here is producing a proof shape that favela-web
// rejects, so the verifier must be the real one.
//
// Run: bare test/session.test.mjs
// ⛔ FIRST — Bare omits TextEncoder/TextDecoder, which @noble/hashes touches AT MODULE LOAD. Without
// this the import graph below throws before a single assertion runs (hyper-social #40 §4: TextEncoder
// is guaranteed by neither Bare nor Hermes). lib/node.js imports the same shim first, for the same reason.
import 'favela-web/lib/bare-globals.js'
import fsm from 'bare-fs'
import os from 'bare-os'
import pathm from 'bare-path'
import { keyFromPrivate } from 'atproto-identity/lib/atproto-identity.js'
import { signAssertion } from 'atproto-identity/lib/auth.js'
import { makeAtprotoWebVerify, verifyChallengeSig } from 'atproto-identity/lib/web-auth.js'
import { loadOrCreateSubKey, signChallenge, tokenFromSetCookie, establishSession, mintSubKey, sessionNodeOf } from '../lib/session.js'
const assert = (c, m) => { if (!c) { console.error('FAIL:', m); process.exit(1) } console.log(' ok -', m) }
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 }
const tmp = fsm.mkdtempSync(pathm.join(os.tmpdir(), 'favela-session-'))
// ── 1. sub-key persistence ──────────────────────────────────────────────────────────────────────────
console.log('sub-key')
const keyFile = pathm.join(tmp, 'sub', 'ui-subkey.json')
const k1 = loadOrCreateSubKey(keyFile, fsm, pathm.dirname)
assert(k1.created === true, 'mints on first run')
assert(/^[0-9a-f]{64}$/.test(k1.privHex), 'private key is 32 bytes hex')
assert(/^[0-9a-f]{66}$/.test(k1.pubHex), 'public key is 33-byte COMPRESSED hex (what delegate() requires)')
assert(toHex(keyFromPrivate(fromHex(k1.privHex)).publicKey) === k1.pubHex, 'pub really derives from priv')
const k2 = loadOrCreateSubKey(keyFile, fsm, pathm.dirname)
assert(k2.created === false && k2.privHex === k1.privHex, 'PERSISTS — a regenerated key would orphan the delegation')
// A malformed keyfile must NOT be silently overwritten: the old key may still hold a live delegation
// in the device registry, and minting over it leaves a dead entry nobody can account for.
fsm.writeFileSync(keyFile, '{"priv":"nope"}')
let threw = null
try { loadOrCreateSubKey(keyFile, fsm, pathm.dirname) } catch (e) { threw = e }
assert(threw && /malformed/.test(threw.message), 'refuses to overwrite a malformed keyfile')
fsm.writeFileSync(keyFile, JSON.stringify({ priv: k1.privHex, pub: k1.pubHex }))
// ── 2. the challenge signature verifies with the REAL verifier ──────────────────────────────────────
console.log('challenge signature')
const challenge = 'a'.repeat(64)
const sig = signChallenge(challenge, k1.privHex)
assert(verifyChallengeSig({ subKey: k1.pubHex, challenge, sig }) === true, 'real verifyChallengeSig accepts it')
assert(verifyChallengeSig({ subKey: k1.pubHex, challenge: 'b'.repeat(64), sig }) === false, 'rejects a DIFFERENT challenge (anti-replay)')
// ── 3. cookie parsing ───────────────────────────────────────────────────────────────────────────────
console.log('set-cookie')
assert(tokenFromSetCookie('favela_session=abc123; Path=/; HttpOnly; SameSite=Lax') === 'abc123', 'extracts the token')
assert(tokenFromSetCookie('other=1; favela_session=xyz; Path=/') === 'xyz', 'finds it among other cookies')
assert(tokenFromSetCookie(null) === null, 'null when absent')
// ── 4. the whole proof, verified as favela-web verifies it ──────────────────────────────────────────
// A fake hpp node that mints a delegation exactly as SwarmDevice.delegate does, and a fake favela-web
// that runs the REAL makeAtprotoWebVerify over what establishSession sends.
console.log('end-to-end proof')
const didKp = mintSubKey() // stands in for the DID's #atproto key
const didPriv = didKp.privateKey
const didPub = didKp.publicKey
const DID = 'did:plc:testtesttesttesttesttest'
const fakeNode = {
did: DID,
delegate ({ subKey }) {
const now = Math.floor(Date.now() / 1000)
return { did: DID, subKey, delegation: signAssertion({ iss: DID, sub: subKey, iat: now, exp: now + 3600 }, didPriv), exp: now + 3600 }
}
}
// resolveVerifyKey is what favela-web wires to the PLC directory; here it returns the DID's key.
const webVerify = makeAtprotoWebVerify({ resolveVerifyKey: async () => didPub })
let seenProof = null
const fakeFetch = async (url, init) => {
if (url.endsWith('/api/auth/challenge')) {
return { ok: true, status: 200, json: async () => ({ challenge: 'c'.repeat(64) }) }
}
if (url.endsWith('/api/auth/login')) {
seenProof = JSON.parse(init.body)
const ok = await webVerify(seenProof) // ← the REAL verifier decides
if (!ok) return { ok: false, status: 401, json: async () => ({ error: 'Invalid atproto proof' }), headers: { get: () => null } }
return {
ok: true,
status: 200,
json: async () => ({ did: DID, handle: 'phone.test' }),
headers: { get: (h) => (h.toLowerCase() === 'set-cookie' ? 'favela_session=tok-real; Path=/; HttpOnly' : null) }
}
}
throw new Error('unexpected url ' + url)
}
const sess = await establishSession({
favelaUrl: 'http://127.0.0.1:4041', node: fakeNode, privHex: k1.privHex, pubHex: k1.pubHex, fetchImpl: fakeFetch
})
assert(sess.token === 'tok-real', 'returns the session token')
assert(sess.did === DID, 'returns the DID')
assert(seenProof.subKey === k1.pubHex, 'proof carries the compressed sub-key')
assert(!!seenProof.delegation && !!seenProof.sig, 'proof carries both delegation and challenge sig')
// ── 5. it must FAIL when the delegation is by the wrong key ─────────────────────────────────────────
// The control. Without this the test only shows the happy path agrees with itself.
const wrongPriv = mintSubKey().privateKey
const badNode = {
did: DID,
delegate ({ subKey }) {
const now = Math.floor(Date.now() / 1000)
return { did: DID, subKey, delegation: signAssertion({ iss: DID, sub: subKey, iat: now, exp: now + 3600 }, wrongPriv), exp: now + 3600 }
}
}
let rejected = null
try {
await establishSession({ favelaUrl: 'http://127.0.0.1:4041', node: badNode, privHex: k1.privHex, pubHex: k1.pubHex, fetchImpl: fakeFetch })
} catch (e) { rejected = e }
assert(rejected && /login rejected/.test(rejected.message), 'a delegation NOT signed by the DID key is refused')
// ── 6. argument guards ──────────────────────────────────────────────────────────────────────────────
console.log('guards')
for (const [args, why] of [
[{ node: fakeNode, privHex: k1.privHex, pubHex: k1.pubHex, fetchImpl: fakeFetch }, 'favelaUrl'],
[{ favelaUrl: 'x', privHex: k1.privHex, pubHex: k1.pubHex, fetchImpl: fakeFetch }, 'node with delegate()'],
[{ favelaUrl: 'x', node: fakeNode, pubHex: k1.pubHex, fetchImpl: fakeFetch }, 'sub-keypair'],
[{ favelaUrl: 'x', node: fakeNode, privHex: k1.privHex, pubHex: k1.pubHex }, 'fetchImpl']
]) {
let e = null
try { await establishSession(args) } catch (err) { e = err }
assert(e !== null, 'refuses without ' + why)
}
// ── 7. the SHAPE hpp actually hands us ──────────────────────────────────────────────────────────────
// THE BUG THIS EXISTS FOR (found on a handset, 2026-08-11): every fixture above is a hand-written object
// with a delegate() on it, so the suite only ever proved establishSession against a node shaped the way
// the test author imagined. hpp does not hand us that. `bootSwarmDevice` returns a WRAPPER —
// { device, sb, did, self, blobs, control, … } (hyper-proto-peer/lib/device-node.js:595) — and delegate()
// lives on the SwarmDevice INSIDE it (swarm-device.js:699). The wrapper carries `.did`, so it passes a
// casual "looks like a node" reading and then trips the delegate() guard at runtime, on the phone, with
// the UI silently downgraded to anonymous. A mock that cannot reproduce the caller's shape cannot catch
// a caller-shape bug, so this asserts the REAL shape.
console.log('hpp wrapper shape')
const wrapper = { did: DID, device: fakeNode, sb: {}, blobs: {}, control: null } // as device-node.js:595 returns
assert(typeof wrapper.delegate !== 'function', 'the wrapper itself has NO delegate — this is the bug')
assert(sessionNodeOf(wrapper) === fakeNode, 'unwraps the SwarmDevice out of the hpp wrapper')
assert(sessionNodeOf(fakeNode) === fakeNode, 'passes a bare SwarmDevice through unchanged')
assert(sessionNodeOf(null) === null, 'null in, null out')
assert(sessionNodeOf({ did: DID, sb: {} }) === null, 'an entry with no delegate anywhere is refused, not guessed at')
// and end-to-end: the wrapper, unwrapped, signs in for real
const wrapped = await establishSession({
favelaUrl: 'http://127.0.0.1:4041', node: sessionNodeOf(wrapper), privHex: k1.privHex, pubHex: k1.pubHex, fetchImpl: fakeFetch
})
assert(wrapped.token && wrapped.did === DID, 'a session is established from the shape hpp really returns')
console.log('\nall session tests passed')