Repository Public
1import { createHash, randomBytes, randomUUID } from 'node:crypto';2import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';3import '@fastify/cookie';4import {5 generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse,6 type AuthenticationResponseJSON, type RegistrationResponseJSON,7} from '@simplewebauthn/server';8import { z } from 'zod';9import { config, secureCookies, UserError } from './config.ts';10import { audit, sql } from './db.ts';11import { signer } from './signer-client.ts';1213export const COOKIE = secureCookies ? '__Host-spw' : 'spw';14const IDLE_MIN = 30, ABSOLUTE_H = 12;15const h = (s: string) => createHash('sha256').update(s).digest('hex');16const enrollChallenge = (id: string, pk: string) => createHash('sha256').update(`SPW-ENROLL-v1\n${id}\n${pk}`).digest();1718interface Cred { id: string; public_key: string; counter: string; transports: string[]; label: string; created_at: Date; last_used_at: Date | null; device_type: string; backed_up: boolean }19export const listCreds = () => sql<Cred[]>`select * from credentials order by created_at`;2021async function putChallenge(kind: string, challenge: string): Promise<string> {22 const id = randomUUID();23 await sql`delete from challenges where expires_at < now()`;24 await sql`insert into challenges (id, kind, challenge, expires_at) values (${id}, ${kind}, ${challenge}, now() + interval '3 minutes')`;25 return id;26}27async function takeChallenge(id: string, kind: string): Promise<string> {28 const [r] = await sql<{ challenge: string }[]>`delete from challenges where id = ${id} and kind = ${kind} and expires_at > now() returning challenge`;29 if (!r) throw new UserError('challenge_expired', 'challenge expired, try again', 401);30 return r.challenge;31}3233async function startSession(req: FastifyRequest, reply: FastifyReply): Promise<void> {34 const token = randomBytes(32).toString('base64url');35 await sql`insert into sessions (id_hash, expires_at, ua, ip) values (${h(token)}, now() + ${ABSOLUTE_H + ' hours'}::interval, ${String(req.headers['user-agent'] ?? '').slice(0, 200)}, ${req.ip})`;36 reply.setCookie(COOKIE, token, { httpOnly: true, secure: secureCookies, sameSite: 'strict', path: '/', maxAge: ABSOLUTE_H * 3600 });37}3839export async function isAuthed(req: FastifyRequest): Promise<boolean> {40 const token = req.cookies[COOKIE];41 if (!token || token.length > 100) return false;42 const [s] = await sql`update sessions set last_seen = now() where id_hash = ${h(token)} and not revoked and expires_at > now()43 and last_seen > now() - ${IDLE_MIN + ' minutes'}::interval returning id_hash`;44 return !!s;45}4647const RegBody = z.object({ challengeId: z.uuid(), response: z.any(), setupToken: z.string().min(8).max(100).optional(), label: z.string().max(40).optional() });48const AuthBody = z.object({ challengeId: z.uuid(), response: z.any() });49const regOptions = async (exclude: Cred[]) => generateRegistrationOptions({50 rpName: 'SP Wallet', rpID: config.rpId, userName: 'owner', userDisplayName: 'SP Wallet', userID: new TextEncoder().encode('sp-wallet-owner'),51 attestationType: 'none', excludeCredentials: exclude.map((c) => ({ id: c.id, transports: c.transports as never })),52 authenticatorSelection: { residentKey: 'required', userVerification: 'required' },53});54async function verifyReg(body: z.infer<typeof RegBody>, kind: string) {55 const expectedChallenge = await takeChallenge(body.challengeId, kind);56 const v = await verifyRegistrationResponse({ response: body.response as RegistrationResponseJSON, expectedChallenge, expectedOrigin: config.origin, expectedRPID: config.rpId, requireUserVerification: true })57 .catch((e: Error) => { throw new UserError('registration_failed', e.message, 401); });58 if (!v.verified) throw new UserError('registration_failed', 'passkey registration not verified', 401);59 const c = v.registrationInfo.credential;60 return { id: c.id, publicKey: Buffer.from(c.publicKey).toString('base64url'), counter: c.counter, transports: (c.transports ?? []) as string[],61 deviceType: v.registrationInfo.credentialDeviceType, backedUp: v.registrationInfo.credentialBackedUp };62}6364export function authRoutes(app: FastifyInstance): void {65 const strict = { config: { rateLimit: { max: 10, timeWindow: '1 minute' } } };6667 app.get('/api/v1/auth/state', async (req) => {68 const n = (await listCreds()).length;69 return { setupRequired: n === 0, authenticated: await isAuthed(req) };70 });7172 // --- one-time owner setup: closed forever once a passkey exists -------------------------------------------73 app.post('/api/v1/auth/setup/options', strict, async () => {74 if ((await listCreds()).length > 0) throw new UserError('setup_closed', 'setup already completed', 403);75 const options = await regOptions([]);76 return { challengeId: await putChallenge('setup', options.challenge), options };77 });78 app.post('/api/v1/auth/setup/verify', strict, async (req, reply) => {79 const body = RegBody.parse(req.body);80 if ((await listCreds()).length > 0) throw new UserError('setup_closed', 'setup already completed', 403);81 if (!body.setupToken) throw new UserError('setup_token', 'setup token required', 403);82 const c = await verifyReg(body, 'setup');83 // the signer validates the one-time token and pins this passkey as the only approver84 await signer.enroll({ credential: { id: c.id, publicKey: c.publicKey, counter: c.counter, transports: c.transports }, setupToken: body.setupToken });85 await sql`insert into credentials (id, public_key, counter, transports, label, device_type, backed_up) values (${c.id}, ${c.publicKey}, ${c.counter}, ${sql.json(c.transports)}, ${body.label ?? 'Passkey 1'}, ${c.deviceType}, ${c.backedUp})`;86 await audit('owner.setup', 'owner', { credential: c.id.slice(0, 12) });87 await startSession(req, reply);88 return { ok: true };89 });9091 // --- login -------------------------------------------------------------------------------------------------92 app.post('/api/v1/auth/login/options', strict, async () => {93 const options = await generateAuthenticationOptions({ rpID: config.rpId, userVerification: 'required', allowCredentials: [] });94 return { challengeId: await putChallenge('login', options.challenge), options };95 });96 app.post('/api/v1/auth/login/verify', strict, async (req, reply) => {97 const body = AuthBody.parse(req.body);98 const response = body.response as AuthenticationResponseJSON;99 const expectedChallenge = await takeChallenge(body.challengeId, 'login');100 const [cred] = await sql<Cred[]>`select * from credentials where id = ${String(response?.id ?? '')}`;101 const fail = async (why: string) => { await audit('login.failed', 'unknown', { why, ip: req.ip }); throw new UserError('login_failed', 'authentication failed', 401); };102 if (!cred) return fail('unknown credential');103 const v = await verifyAuthenticationResponse({ response, expectedChallenge, expectedOrigin: config.origin, expectedRPID: config.rpId, requireUserVerification: true,104 credential: { id: cred.id, publicKey: new Uint8Array(Buffer.from(cred.public_key, 'base64url')), counter: Number(cred.counter), transports: cred.transports as never } }).catch(() => null);105 if (!v?.verified) return fail('bad assertion');106 await sql`update credentials set counter = ${v.authenticationInfo.newCounter}, last_used_at = now() where id = ${cred.id}`;107 await audit('login.ok', 'owner', { credential: cred.id.slice(0, 12), ip: req.ip });108 await startSession(req, reply);109 return { ok: true };110 });111 app.post('/api/v1/auth/logout', async (req, reply) => {112 const t = req.cookies[COOKIE];113 if (t) await sql`update sessions set revoked = true where id_hash = ${h(t)}`;114 reply.clearCookie(COOKIE, { path: '/' });115 return { ok: true };116 });117}118119/** Authenticated: add a second passkey. The signer only accepts it with an assertion from an existing passkey. */120export function deviceRoutes(app: FastifyInstance): void {121 app.post('/api/v1/security/devices/options', async () => {122 const options = await regOptions(await listCreds());123 return { challengeId: await putChallenge('add-device', options.challenge), options };124 });125 app.post('/api/v1/security/devices/verify', async (req) => {126 const body = RegBody.parse(req.body);127 const c = await verifyReg(body, 'add-device');128 await sql`delete from pending_credentials where expires_at < now()`;129 await sql`insert into pending_credentials (id, public_key, counter, transports, label, device_type, backed_up, expires_at)130 values (${c.id}, ${c.publicKey}, ${c.counter}, ${sql.json(c.transports)}, ${body.label ?? 'Passkey'}, ${c.deviceType}, ${c.backedUp}, now() + interval '5 minutes')131 on conflict (id) do update set expires_at = excluded.expires_at`;132 const creds = await listCreds();133 const options = await generateAuthenticationOptions({ rpID: config.rpId, userVerification: 'required', challenge: new Uint8Array(enrollChallenge(c.id, c.publicKey)),134 allowCredentials: creds.map((x) => ({ id: x.id, transports: x.transports as never })) });135 return { pendingId: c.id, options };136 });137 app.post('/api/v1/security/devices/confirm', async (req) => {138 const body = z.object({ pendingId: z.string().max(1024), assertion: z.any() }).parse(req.body);139 const [p] = await sql`select * from pending_credentials where id = ${body.pendingId} and expires_at > now()`;140 if (!p) throw new UserError('expired', 'pending passkey expired', 400);141 await signer.enroll({ credential: { id: p.id, publicKey: p.public_key, counter: Number(p.counter), transports: p.transports }, assertion: body.assertion });142 await sql`insert into credentials (id, public_key, counter, transports, label, device_type, backed_up) values (${p.id}, ${p.public_key}, ${p.counter}, ${sql.json(p.transports)}, ${p.label}, ${p.device_type}, ${p.backed_up})`;143 await sql`delete from pending_credentials where id = ${p.id}`;144 await audit('device.added', 'owner', { credential: String(p.id).slice(0, 12), label: p.label });145 return { ok: true };146 });147 app.post('/api/v1/security/sessions/revoke-all', async (req, reply) => {148 await sql`update sessions set revoked = true where not revoked`;149 reply.clearCookie(COOKIE, { path: '/' });150 await audit('sessions.revoked', 'owner', { ip: req.ip });151 return { ok: true };152 });153}154