import { Hono } from "jsr:@hono/hono@4"; import { sqlWrapper } from "./db.ts"; import { getCookie, setCookie } from "jsr:@hono/hono@4/cookie"; import { rateLimitWrapper } from "./ratelimit.ts"; import { auditWrapper } from "./audit.ts"; import { generateRegistrationOptions, verifyRegistrationResponse, } from "jsr:@simplewebauthn/server@13"; import { encodeBase64Url } from "jsr:@std/encoding@1/base64url"; const rpName = "Auth-Yes Identity Provider"; const rpID = Deno.env.get("RP_ID") || (import.meta.main ? undefined : "localhost"); const origin = Deno.env.get("ORIGIN") || (import.meta.main ? undefined : "http://localhost"); export const recoveryApp = new Hono(); // Helper for constant time string comparison function constantTimeCompare(a: string, b: string): boolean { if (a.length !== b.length) return false; let result = 0; for (let i = 0; i < a.length; i++) { result |= a.charCodeAt(i) ^ b.charCodeAt(i); } return result === 0; } recoveryApp.post("/challenge", async (c) => { const { code, pin } = await c.req.json(); if (!code || !pin) { return c.json({ error: "Missing recovery code or pin" }, 400); } // Find the recovery link const link = await sqlWrapper .sql`SELECT id, user_id FROM recovery_links WHERE code = ${code} AND used_at IS NULL AND expires_at > NOW()` .then((res: any) => res[0]); if (!link) { return c.json( { error: "Invalid, expired, or already used recovery code" }, 400, ); } // Rate Limiting: 5 attempts per 15 minutes per user/code combo const rateLimitKey = `rl:recovery:${link.user_id}:${code}`; const allowed = await rateLimitWrapper.checkRateLimit(rateLimitKey, 5, 900); // 15 mins = 900s if (!allowed) { return c.json({ error: "Too many recovery attempts. Please try again later.", }, 429); } // Verify PIN against Server Share record const shareRecord = await sqlWrapper .sql`SELECT id, server_share, pin_hash, attempts_count FROM recovery_shares WHERE user_id = ${link.user_id}` .then((res: any) => res[0]); if (!shareRecord) { return c.json({ error: "No recovery configuration found for this account.", }, 400); } // Compare PIN hashes const pinBuffer = new TextEncoder().encode(pin); const hashBuffer = await crypto.subtle.digest("SHA-256", pinBuffer); const pinHash = Array.from(new Uint8Array(hashBuffer)).map((b) => b.toString(16).padStart(2, "0") ).join(""); if (!constantTimeCompare(pinHash, shareRecord.pin_hash)) { // Increment attempts count (simple tracking, RL handles blocking) await sqlWrapper .sql`UPDATE recovery_shares SET attempts_count = attempts_count + 1 WHERE id = ${shareRecord.id}`; return c.json({ error: "Invalid Recovery PIN" }, 401); } // Success, release challenge and share const options = await generateRegistrationOptions({ rpName, rpID: rpID as string, userID: new TextEncoder().encode(link.user_id), userName: link.user_id, attestationType: "none", authenticatorSelection: { userVerification: "preferred", residentKey: "required", }, supportedAlgorithmIDs: [-8, -7, -257], // Ed25519, ES256, RS256 extensions: { prf: { eval: { first: new Uint8Array(32) } } } as any, }); setCookie(c, "expected_recovery_challenge", options.challenge, { httpOnly: true, secure: true, sameSite: "Lax", maxAge: 300, }); setCookie(c, "recovery_user_id", link.user_id, { httpOnly: true, secure: true, sameSite: "Lax", maxAge: 300, }); return c.json({ options, serverShareHex: shareRecord.server_share }); }); recoveryApp.post("/verify", async (c) => { const { code, response, signature } = await c.req.json(); const expectedChallenge = getCookie(c, "expected_recovery_challenge"); const recoveryUserId = getCookie(c, "recovery_user_id"); if (!expectedChallenge || !recoveryUserId || !signature) { return c.json( { error: "Missing or expired recovery session/signature" }, 400, ); } const link = await sqlWrapper .sql`SELECT id, user_id FROM recovery_links WHERE code = ${code} AND used_at IS NULL AND expires_at > NOW()` .then((res: any) => res[0]); if (!link || link.user_id !== recoveryUserId) { return c.json({ error: "Invalid or expired recovery code" }, 400); } try { const verification = await verifyRegistrationResponse({ response, expectedChallenge, expectedOrigin: origin as string, expectedRPID: rpID as string, requireUserVerification: false, }); if (verification.verified && verification.registrationInfo) { const { credential, credentialDeviceType, credentialBackedUp } = verification.registrationInfo; const pubKeyBase64 = encodeBase64Url(credential.publicKey); const aaguid = (credential as any).aaguid || (verification.registrationInfo as any)?.aaguid || null; // Revoke old passkeys await sqlWrapper .sql`DELETE FROM passkeys WHERE user_id = ${link.user_id}`; // Bind new passkey await sqlWrapper.sql` INSERT INTO passkeys (user_id, credential_id, public_key, counter, aaguid) VALUES (${link.user_id}, ${credential.id}, ${pubKeyBase64}, ${credential.counter}, ${aaguid}) `; // Mark link as used await sqlWrapper .sql`UPDATE recovery_links SET used_at = NOW() WHERE id = ${link.id}`; // Reset recovery configuration await sqlWrapper .sql`DELETE FROM recovery_shares WHERE user_id = ${link.user_id}`; auditWrapper.auditLog( link.user_id, "account_recovered", null, { aaguid, credentialDeviceType, credentialBackedUp, }, c.req.header("x-forwarded-for") || "", ); setCookie(c, "expected_recovery_challenge", "", { maxAge: 0 }); setCookie(c, "recovery_user_id", "", { maxAge: 0 }); return c.json({ success: true }); } else { return c.json( { error: "Passkey registration failed during recovery" }, 400, ); } } catch (error: any) { return c.json({ error: error.message }, 400); } });