import { FastifyInstance } from "fastify"; import { z } from "zod"; import { randomBytes } from "node:crypto"; import bcrypt from "bcrypt"; import prisma from "../lib/prisma.js"; import { blindIndex, hashToken } from "../services/encryption.js"; import { blindIndexKey } from "../config.js"; import { verifyChallenge } from "../services/altcha.js"; import { generateRecoveryPhrase } from "../lib/wordlist.js"; const EXPIRY_DAYS = 90; const recoverBody = z.object({ phrase: z.string().regex(/^[a-z]+-[a-z]+-[a-z]+-[a-z]+-[a-z]+-[a-z]+$/), altcha: z.string(), }); async function getFailedAttempts(ip: string): Promise { const cutoff = new Date(Date.now() - 15 * 60 * 1000); const count = await prisma.blockedToken.count({ where: { tokenHash: { startsWith: `recovery:${ip}:` }, createdAt: { gt: cutoff } }, }); return count; } async function recordFailedAttempt(ip: string): Promise { await prisma.blockedToken.create({ data: { tokenHash: `recovery:${ip}:${Date.now()}`, expiresAt: new Date(Date.now() + 15 * 60 * 1000), }, }); } export default async function recoveryRoutes(app: FastifyInstance) { // check if user has a recovery code app.get( "/me/recovery-code", { preHandler: [app.requireUser], config: { rateLimit: { max: 30, timeWindow: "1 minute" } } }, async (req, reply) => { const code = await prisma.recoveryCode.findUnique({ where: { userId: req.user!.id }, select: { expiresAt: true }, }); if (code && code.expiresAt > new Date()) { reply.send({ hasCode: true, expiresAt: code.expiresAt }); } else { reply.send({ hasCode: false }); } } ); // generate a new recovery code app.post( "/me/recovery-code", { preHandler: [app.requireUser], config: { rateLimit: { max: 3, timeWindow: "1 hour" } } }, async (req, reply) => { if (req.user!.authMethod === "PASSKEY") { reply.status(400).send({ error: "Passkey users don't need recovery codes" }); return; } const phrase = generateRecoveryPhrase(); const codeHash = await bcrypt.hash(phrase, 12); const phraseIdx = blindIndex(phrase, blindIndexKey); const expiresAt = new Date(Date.now() + EXPIRY_DAYS * 24 * 60 * 60 * 1000); await prisma.recoveryCode.upsert({ where: { userId: req.user!.id }, create: { codeHash, phraseIdx, userId: req.user!.id, expiresAt }, update: { codeHash, phraseIdx, expiresAt }, }); reply.send({ phrase, expiresAt }); } ); // recover identity using a code (unauthenticated) app.post( "/auth/recover", { config: { rateLimit: { max: 3, timeWindow: "15 minutes" } } }, async (req, reply) => { const body = recoverBody.parse(req.body); const valid = await verifyChallenge(body.altcha); if (!valid) { reply.status(400).send({ error: "Invalid challenge response" }); return; } // exponential backoff per IP (persistent) const attempts = await getFailedAttempts(req.ip); if (attempts >= 3) { const delay = Math.min(1000 * Math.pow(2, attempts - 3), 30000); await new Promise((r) => setTimeout(r, delay)); } const phrase = body.phrase.toLowerCase().trim(); const idx = blindIndex(phrase, blindIndexKey); const record = await prisma.recoveryCode.findUnique({ where: { phraseIdx: idx }, include: { user: { select: { id: true } } }, }); if (!record || record.expiresAt < new Date()) { await recordFailedAttempt(req.ip); reply.status(401).send({ error: "Invalid or expired recovery code" }); return; } const matches = await bcrypt.compare(phrase, record.codeHash); if (!matches) { await recordFailedAttempt(req.ip); reply.status(401).send({ error: "Invalid or expired recovery code" }); return; } // success - issue new session token and delete used code const token = randomBytes(32).toString("hex"); const hash = hashToken(token); await prisma.$transaction([ prisma.user.update({ where: { id: record.userId }, data: { tokenHash: hash }, }), prisma.recoveryCode.delete({ where: { id: record.id } }), ]); reply .setCookie("echoboard_token", token, { path: "/", httpOnly: true, sameSite: "strict", secure: process.env.NODE_ENV === "production", maxAge: 60 * 60 * 24 * 90, }) .send({ recovered: true }); } ); }