// Proof-of-Work Captcha Service // Client must find nonce where SHA256(challenge + nonce) has N leading zeros // Server-first: tries /pow/challenge endpoint, falls back to local generation const POW_SERVER = 'https://pow.kashilo.com' const DIFFICULTY = 4 const SERVER_TIMEOUT_MS = 1500 const LOCAL_HMAC_KEY = 'kashilo-pow-local-v1' async function hmacSign(message) { const enc = new TextEncoder() const key = await crypto.subtle.importKey( 'raw', enc.encode(LOCAL_HMAC_KEY), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'] ) const sig = await crypto.subtle.sign('HMAC', key, enc.encode(message)) return Array.from(new Uint8Array(sig)).map(b => b.toString(16).padStart(2, '0')).join('') } async function hmacVerify(message, signature) { const expected = await hmacSign(message) return expected === signature } async function localGenerateChallenge() { const challenge = crypto.randomUUID() const timestamp = Date.now() const signature = await hmacSign(`${challenge}:${timestamp}:${DIFFICULTY}`) return { challenge, difficulty: DIFFICULTY, timestamp, signature, source: 'local' } } export async function generateChallenge() { try { const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), SERVER_TIMEOUT_MS) const response = await fetch(`${POW_SERVER}/challenge`, { method: 'GET', signal: controller.signal }) clearTimeout(timeout) if (!response.ok) throw new Error(`HTTP ${response.status}`) const data = await response.json() return { ...data, source: 'server' } } catch { return localGenerateChallenge() } } export async function verifySolution(solution) { if (solution.source === 'server') { const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), SERVER_TIMEOUT_MS) try { const response = await fetch(`${POW_SERVER}/verify`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(solution), signal: controller.signal }) clearTimeout(timeout) if (!response.ok) throw new Error(`HTTP ${response.status}`) const data = await response.json() return { ok: data.ok === true, token: data.captcha_token || null } } catch { clearTimeout(timeout) return { ok: false, token: null } } } const maxAge = 5 * 60 * 1000 if (!solution.timestamp || Date.now() - solution.timestamp > maxAge) { return { ok: false, token: null } } const sigValid = await hmacVerify( `${solution.challenge}:${solution.timestamp}:${solution.difficulty || DIFFICULTY}`, solution.signature ) if (!sigValid) return { ok: false, token: null } const hash = await sha256(`${solution.challenge}${solution.nonce}`) const prefix = '0'.repeat(solution.difficulty || DIFFICULTY) return { ok: hash.startsWith(prefix), token: null } } export async function solveChallenge(challenge, difficulty, onProgress) { let nonce = 0 const prefix = '0'.repeat(difficulty) const startTime = Date.now() while (true) { const hash = await sha256(`${challenge}${nonce}`) if (hash.startsWith(prefix)) { return { nonce, hash, duration: Date.now() - startTime } } nonce++ if (onProgress && nonce % 1000 === 0) { onProgress({ attempts: nonce, elapsed: Date.now() - startTime }) } if (nonce % 100 === 0) { await new Promise(r => setTimeout(r, 0)) } } } async function sha256(message) { const msgBuffer = new TextEncoder().encode(message) const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer) const hashArray = Array.from(new Uint8Array(hashBuffer)) return hashArray.map(b => b.toString(16).padStart(2, '0')).join('') }