274 lines
9.6 KiB
TypeScript
274 lines
9.6 KiB
TypeScript
'use strict';
|
|
|
|
import type { Db } from '../types/db';
|
|
|
|
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(): string {
|
|
return process.env.WEBAUTHN_RP_ID || 'localhost';
|
|
}
|
|
|
|
function getOrigin(): string {
|
|
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: Db, userId: number, username: string) {
|
|
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: any) => ({ 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: Db,
|
|
userId: number,
|
|
challengeId: string,
|
|
response: any,
|
|
credentialName: any,
|
|
) {
|
|
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: any) {
|
|
console.error('[webauthn] registration error:', err.message);
|
|
return { verified: false, error: err.message };
|
|
}
|
|
}
|
|
|
|
// ── Authentication ────────────────────────────────────────────────────────────
|
|
|
|
async function createAuthenticationChallenge(db: Db, userId: number) {
|
|
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: any) => ({
|
|
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: Db, userId: number, challengeId: string, response: any) {
|
|
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: any) {
|
|
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: Db, userId: number, webauthnChallengeId: any): string {
|
|
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: Db,
|
|
loginChallengeId: any,
|
|
): { userId: number; authChallengeId: string } | null {
|
|
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: Db, userId: number): any[] {
|
|
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: Db, credentialId: any, userId: number) {
|
|
return db
|
|
.prepare('DELETE FROM webauthn_credentials WHERE credential_id = ? AND user_id = ?')
|
|
.run(credentialId, userId);
|
|
}
|
|
|
|
// ── Cleanup ───────────────────────────────────────────────────────────────────
|
|
|
|
function pruneExpiredChallenges(db: Db): void {
|
|
db.prepare("DELETE FROM webauthn_challenges WHERE expires_at <= datetime('now')").run();
|
|
}
|
|
|
|
module.exports = {
|
|
createRegistrationChallenge,
|
|
verifyRegistration,
|
|
createAuthenticationChallenge,
|
|
verifyAuthentication,
|
|
createLoginChallenge,
|
|
consumeLoginChallenge,
|
|
getCredentials,
|
|
deleteCredential,
|
|
pruneExpiredChallenges,
|
|
};
|