BillTracker/services/webauthnService.js

221 lines
9.1 KiB
JavaScript
Raw Normal View History

'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,
};