// atproto-identity/lib/web-auth.js — DID web sign-in verify (the atproto-native "pgv3 method").
//
// The reusable VERIFY half of DID web sign-in. A browser holds a secp256k1 SUB-KEY; the DID's
// #atproto key DELEGATES it (a signed assertion, anchored to the DID's PLC-published key). Login: the
// sub-key signs the fresh server challenge as an ES256K assertion. This is the atproto analog of
// pgv3's root-anchored multi-identity — the DID is the ROOT anchor, sub-keys are the delegated DEVICES
// (revoke a device by expiring/removing its delegation; the DID is untouched). Everything is
// secp256k1/ES256K (signAssertion/verifyAssertion) — no keet, no anproto.
//
// This module owns ONLY the crypto verify. The challenge store (single-use/TTL) and the session live
// in the consumer (e.g. favela wraps this + hyper-identity's AtprotoProvider). resolveVerifyKey(did) →
// the DID's #atproto compressed pubkey is INJECTED (a PLC-directory resolver in prod, a mock in tests)
// so this stays network-free + unit-testable. Fails closed on any bad/absent/expired input.
import { verifyAssertion } from './auth.js'
const hexToBytes = (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 toBytes = (k) => (typeof k === 'string' ? hexToBytes(k) : k)
const toHex = (k) => (typeof k === 'string' ? k : Array.from(k).map((b) => b.toString(16).padStart(2, '0')).join(''))
// The sub-key signed THIS challenge (proves live key control + anti-replay). sig = an ES256K assertion
// signAssertion({ challenge, … }, subPrivKey). subKey is the compressed pubkey (hex or bytes).
export function verifyChallengeSig ({ subKey, challenge, sig } = {}) {
if (!subKey || !challenge || !sig) return false
let p
try { p = verifyAssertion(sig, toBytes(subKey)) } catch { return false } // bad sig / expired → reject
return !!p && p.challenge === challenge
}
// The DID's #atproto key delegated this sub-key. delegation = an ES256K assertion
// signAssertion({ iss: did, sub: <subKey hex>, exp }, atprotoPrivKey), minted once by the keyholder
// (hpp). resolveVerifyKey(did) → the DID's #atproto compressed pubkey (PLC-published). Fails closed.
export function makeVerifyDelegation ({ resolveVerifyKey } = {}) {
if (typeof resolveVerifyKey !== 'function') throw new Error('makeVerifyDelegation: resolveVerifyKey(did) required')
return async function verifyDelegation ({ did, subKey, delegation } = {}) {
if (!did || !subKey || !delegation) return false
const subHex = toHex(subKey)
let vk
try { vk = await resolveVerifyKey(did) } catch { return false }
if (!vk) return false
let p
try { p = verifyAssertion(delegation, vk) } catch { return false }
return !!p && p.iss === did && p.sub === subHex
}
}
// Combined login-proof verify — the injected `verify` for hyper-identity's AtprotoProvider.
// proof = { did, subKey(hex), challenge(hex), sig, delegation, handle?, didKey? }
// Checks the sub-key signed the challenge AND the DID's #atproto key delegated the sub-key (PLC-
// anchored, re-verified every login — no TOFU). Returns the binding { did, subKey, handle, didKey } or
// false. The challenge's single-use/freshness is the CALLER's job (consume it before/around this).
export function makeAtprotoWebVerify ({ resolveVerifyKey } = {}) {
const verifyDelegation = makeVerifyDelegation({ resolveVerifyKey })
return async function verify (proof) {
if (!proof) return false
const { did, subKey, challenge, sig, delegation } = proof
if (!verifyChallengeSig({ subKey, challenge, sig })) return false
if (!(await verifyDelegation({ did, subKey, delegation }).catch(() => false))) return false
return { did, subKey: toHex(subKey), handle: proof.handle || null, didKey: proof.didKey || null }
}
}