auth-yes/server/recovery.ts

194 lines
6.1 KiB
TypeScript

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);
}
});