'use strict'; const crypto = require('crypto'); const { generateRegistrationOptions, verifyRegistrationResponse, generateAuthenticationOptions, verifyAuthenticationResponse, } = require('@simplewebauthn/server'); const APP_NAME = 'Bill Tracker'; const CHALLENGE_TTL_MS = 15 * 60 * 1000; function getRpId() { return process.env.WEBAUTHN_RP_ID || 'localhost'; } function getOrigin() { const o = process.env.WEBAUTHN_ORIGIN; if (o) return o; const port = process.env.PORT || 3000; return `http://localhost:${port}`; } // ── Registration ────────────────────────────────────────────────────────────── async function createRegistrationChallenge(db, userId, username) { let { webauthn_user_id } = db.prepare('SELECT webauthn_user_id FROM users WHERE id = ?').get(userId) || {}; if (!webauthn_user_id) { webauthn_user_id = crypto.randomBytes(32).toString('base64url'); db.prepare("UPDATE users SET webauthn_user_id = ?, updated_at = datetime('now') WHERE id = ?") .run(webauthn_user_id, userId); } // Exclude already-registered credentials so the authenticator won't re-register them const existing = db.prepare('SELECT credential_id FROM webauthn_credentials WHERE user_id = ?').all(userId); const options = await generateRegistrationOptions({ rpName: APP_NAME, rpID: getRpId(), userID: Buffer.from(webauthn_user_id, 'base64url'), userName: username, userDisplayName: username, attestationType: 'none', excludeCredentials: existing.map(c => ({ id: c.credential_id })), authenticatorSelection: { residentKey: 'preferred', userVerification: 'preferred', }, supportedAlgorithmIDs: [-7, -257], }); const challengeId = crypto.randomUUID(); const expiresAt = new Date(Date.now() + CHALLENGE_TTL_MS).toISOString().slice(0, 19).replace('T', ' '); db.prepare("DELETE FROM webauthn_challenges WHERE user_id = ? AND challenge_type = 'registration'").run(userId); db.prepare('INSERT INTO webauthn_challenges (id, user_id, challenge_type, challenge, expires_at) VALUES (?, ?, ?, ?, ?)') .run(challengeId, userId, 'registration', options.challenge, expiresAt); return { options, challengeId }; } async function verifyRegistration(db, userId, challengeId, response, credentialName) { const row = db.prepare( "SELECT challenge FROM webauthn_challenges WHERE id = ? AND user_id = ? AND challenge_type = 'registration' AND expires_at > datetime('now')" ).get(challengeId, userId); if (!row) return { verified: false, error: 'Challenge expired or invalid' }; try { const verification = await verifyRegistrationResponse({ response, expectedChallenge: row.challenge, expectedOrigin: getOrigin(), expectedRPID: getRpId(), }); if (!verification.verified) return { verified: false, error: 'Registration verification failed' }; const { credential, aaguid, credentialDeviceType, credentialBackedUp } = verification.registrationInfo; db.prepare(` INSERT INTO webauthn_credentials (user_id, credential_id, public_key, sign_count, transports, backup_eligible, backup_state, credential_name, aaguid) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( userId, credential.id, Buffer.from(credential.publicKey).toString('base64url'), credential.counter, credential.transports ? JSON.stringify(credential.transports) : null, credentialDeviceType === 'multiDevice' ? 1 : 0, credentialBackedUp ? 1 : 0, credentialName || 'Security Key', aaguid || null, ); db.prepare('DELETE FROM webauthn_challenges WHERE id = ?').run(challengeId); return { verified: true, credentialId: credential.id }; } catch (err) { console.error('[webauthn] registration error:', err.message); return { verified: false, error: err.message }; } } // ── Authentication ──────────────────────────────────────────────────────────── async function createAuthenticationChallenge(db, userId) { const credentials = db.prepare('SELECT credential_id, transports FROM webauthn_credentials WHERE user_id = ?').all(userId); if (!credentials.length) throw new Error('No registered WebAuthn credentials'); const options = await generateAuthenticationOptions({ rpID: getRpId(), allowCredentials: credentials.map(c => ({ id: c.credential_id, transports: c.transports ? JSON.parse(c.transports) : undefined, })), userVerification: 'preferred', }); const challengeId = crypto.randomUUID(); const expiresAt = new Date(Date.now() + CHALLENGE_TTL_MS).toISOString().slice(0, 19).replace('T', ' '); db.prepare("DELETE FROM webauthn_challenges WHERE user_id = ? AND challenge_type = 'authentication'").run(userId); db.prepare('INSERT INTO webauthn_challenges (id, user_id, challenge_type, challenge, expires_at) VALUES (?, ?, ?, ?, ?)') .run(challengeId, userId, 'authentication', options.challenge, expiresAt); return { options, challengeId }; } async function verifyAuthentication(db, userId, challengeId, response) { const row = db.prepare( "SELECT challenge FROM webauthn_challenges WHERE id = ? AND user_id = ? AND challenge_type = 'authentication' AND expires_at > datetime('now')" ).get(challengeId, userId); if (!row) return { verified: false, error: 'Challenge expired or invalid' }; const cred = db.prepare('SELECT credential_id, public_key, sign_count FROM webauthn_credentials WHERE user_id = ? AND credential_id = ?') .get(userId, response.id); if (!cred) return { verified: false, error: 'Credential not registered to this user' }; try { const verification = await verifyAuthenticationResponse({ response, expectedChallenge: row.challenge, expectedOrigin: getOrigin(), expectedRPID: getRpId(), credential: { id: cred.credential_id, publicKey: Buffer.from(cred.public_key, 'base64url'), counter: cred.sign_count, }, }); if (!verification.verified) return { verified: false, error: 'Authentication failed' }; // Update sign count to detect cloned authenticators db.prepare("UPDATE webauthn_credentials SET sign_count = ?, updated_at = datetime('now') WHERE user_id = ? AND credential_id = ?") .run(verification.authenticationInfo.newCounter, userId, cred.credential_id); db.prepare('DELETE FROM webauthn_challenges WHERE id = ?').run(challengeId); return { verified: true }; } catch (err) { console.error('[webauthn] authentication error:', err.message); return { verified: false, error: err.message }; } } // ── Login challenge (mirrors totpService.createChallenge / consumeChallenge) ── // Issued after password passes; consumed when the WebAuthn assertion is verified. function createLoginChallenge(db, userId, webauthnChallengeId) { const id = crypto.randomUUID(); const expiresAt = new Date(Date.now() + CHALLENGE_TTL_MS).toISOString().slice(0, 19).replace('T', ' '); db.prepare("DELETE FROM webauthn_challenges WHERE user_id = ? AND challenge_type = 'login'").run(userId); // Piggy-back the authentication challengeId in the challenge column for retrieval db.prepare('INSERT INTO webauthn_challenges (id, user_id, challenge_type, challenge, expires_at) VALUES (?, ?, ?, ?, ?)') .run(id, userId, 'login', webauthnChallengeId, expiresAt); return id; } function consumeLoginChallenge(db, loginChallengeId) { const row = db.prepare( "SELECT user_id, challenge FROM webauthn_challenges WHERE id = ? AND challenge_type = 'login' AND expires_at > datetime('now')" ).get(loginChallengeId); if (!row) return null; db.prepare('DELETE FROM webauthn_challenges WHERE id = ?').run(loginChallengeId); return { userId: row.user_id, authChallengeId: row.challenge }; } // ── Credential management ───────────────────────────────────────────────────── function getCredentials(db, userId) { return db.prepare( 'SELECT id, credential_id, credential_name, aaguid, backup_eligible, backup_state, created_at FROM webauthn_credentials WHERE user_id = ? ORDER BY created_at DESC' ).all(userId); } function deleteCredential(db, credentialId, userId) { return db.prepare('DELETE FROM webauthn_credentials WHERE credential_id = ? AND user_id = ?').run(credentialId, userId); } // ── Cleanup ─────────────────────────────────────────────────────────────────── function pruneExpiredChallenges(db) { db.prepare("DELETE FROM webauthn_challenges WHERE expires_at <= datetime('now')").run(); } module.exports = { createRegistrationChallenge, verifyRegistration, createAuthenticationChallenge, verifyAuthentication, createLoginChallenge, consumeLoginChallenge, getCredentials, deleteCredential, pruneExpiredChallenges, };