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