This commit is contained in:
Girish Ramakrishnan
2026-02-17 19:30:33 +01:00
parent 3ef990b0bf
commit 319360f8d0
5 changed files with 68 additions and 101 deletions
+33 -58
View File
@@ -6,16 +6,15 @@ import database from './database.js';
import debugModule from 'debug';
import safe from 'safetydance';
import {
generateRegistrationOptions as generateWebAuthnRegistrationOptions,
generateRegistrationOptions,
verifyRegistrationResponse,
generateAuthenticationOptions as generateWebAuthnAuthenticationOptions,
generateAuthenticationOptions,
verifyAuthenticationResponse
} from '@simplewebauthn/server';
import _ from './underscore.js';
const debug = debugModule('box:passkeys');
const PASSKEY_FIELDS = [ 'id', 'userId', 'credentialId', 'publicKey', 'counter', 'transports', 'name', 'creationTime', 'lastUsedTime' ].join(',');
// In-memory challenge store with expiration (challenges are short-lived)
@@ -32,17 +31,17 @@ function postProcess(passkey) {
return passkey;
}
async function list(userId) {
async function listByUserId(userId) {
assert.strictEqual(typeof userId, 'string');
const results = await database.query('SELECT ' + PASSKEY_FIELDS + ' FROM passkeys WHERE userId = ? ORDER BY creationTime', [ userId ]);
const results = await database.query(`SELECT ${PASSKEY_FIELDS} FROM passkeys WHERE userId = ? ORDER BY creationTime`, [ userId ]);
return results.map(postProcess);
}
async function get(id) {
assert.strictEqual(typeof id, 'string');
const result = await database.query('SELECT ' + PASSKEY_FIELDS + ' FROM passkeys WHERE id = ?', [ id ]);
const result = await database.query(`SELECT ${PASSKEY_FIELDS} FROM passkeys WHERE id = ?`, [ id ]);
if (result.length === 0) return null;
return postProcess(result[0]);
}
@@ -50,7 +49,7 @@ async function get(id) {
async function getByCredentialId(credentialId) {
assert.strictEqual(typeof credentialId, 'string');
const result = await database.query('SELECT ' + PASSKEY_FIELDS + ' FROM passkeys WHERE credentialId = ?', [ credentialId ]);
const result = await database.query(`SELECT ${PASSKEY_FIELDS} FROM passkeys WHERE credentialId = ?`, [ credentialId ]);
if (result.length === 0) return null;
return postProcess(result[0]);
}
@@ -76,14 +75,14 @@ async function add(userId, credentialId, publicKey, counter, transports, name) {
return { id };
}
async function del(id, userId) {
async function del(id) {
assert.strictEqual(typeof id, 'string');
assert.strictEqual(typeof userId, 'string');
const result = await database.query('DELETE FROM passkeys WHERE id = ? AND userId = ?', [ id, userId ]);
const result = await database.query('DELETE FROM passkeys WHERE id = ?', [ id ]);
if (result.affectedRows !== 1) throw new BoxError(BoxError.NOT_FOUND, 'Passkey not found');
}
// this counter prevents replay attacks
async function updateCounter(id, counter) {
assert.strictEqual(typeof id, 'string');
assert.strictEqual(typeof counter, 'number');
@@ -96,22 +95,20 @@ async function delAll() {
}
function storeChallenge(userId, challenge) {
const key = `${userId}`;
gChallenges.set(key, {
gChallenges.set(userId, {
challenge,
expiresAt: Date.now() + CHALLENGE_EXPIRY_MS
});
// Clean up expired challenges periodically
// clean up expired challenges
for (const [k, v] of gChallenges) {
if (v.expiresAt < Date.now()) gChallenges.delete(k);
}
}
function getAndDeleteChallenge(userId) {
const key = `${userId}`;
const entry = gChallenges.get(key);
gChallenges.delete(key);
const entry = gChallenges.get(userId);
gChallenges.delete(userId);
if (!entry) return null;
if (entry.expiresAt < Date.now()) return null;
@@ -129,23 +126,17 @@ async function getRpInfo() {
};
}
async function generateRegistrationOptions(user) {
async function getRegistrationOptions(user) {
assert.strictEqual(typeof user, 'object');
// Only one passkey per user is allowed
const existingPasskeys = await list(user.id);
if (existingPasskeys.length > 0) {
throw new BoxError(BoxError.ALREADY_EXISTS, 'User already has a passkey registered');
}
const existingPasskeys = await listByUserId(user.id);
if (existingPasskeys.length > 0) throw new BoxError(BoxError.ALREADY_EXISTS, 'User already has a passkey registered');
// Cannot register passkey if TOTP is enabled (user must choose one or the other)
if (user.twoFactorAuthenticationEnabled) {
throw new BoxError(BoxError.ALREADY_EXISTS, 'Cannot register passkey when TOTP is enabled');
}
if (user.twoFactorAuthenticationEnabled) throw new BoxError(BoxError.ALREADY_EXISTS, 'Cannot register passkey when TOTP is enabled');
const { rpId, rpName } = await getRpInfo();
const options = await generateWebAuthnRegistrationOptions({
const options = await generateRegistrationOptions({
rpName,
rpID: rpId,
userName: user.username || user.email,
@@ -160,7 +151,7 @@ async function generateRegistrationOptions(user) {
storeChallenge(user.id, options.challenge);
debug(`generateRegistrationOptions: generated for user ${user.id}`);
debug(`getRegistrationOptions: generated for user ${user.id}`);
return options;
}
@@ -170,16 +161,10 @@ async function verifyRegistration(user, response, name) {
assert.strictEqual(typeof response, 'object');
assert.strictEqual(typeof name, 'string');
// Defense in depth: check again before adding
const existingPasskeys = await list(user.id);
if (existingPasskeys.length > 0) {
throw new BoxError(BoxError.ALREADY_EXISTS, 'User already has a passkey registered');
}
// Cannot register passkey if TOTP is enabled
if (user.twoFactorAuthenticationEnabled) {
throw new BoxError(BoxError.ALREADY_EXISTS, 'Cannot register passkey when TOTP is enabled');
}
// check should ideally be in a transaction
const existingPasskeys = await listByUserId(user.id);
if (existingPasskeys.length > 0) throw new BoxError(BoxError.ALREADY_EXISTS, 'User already has a passkey registered');
if (user.twoFactorAuthenticationEnabled) throw new BoxError(BoxError.ALREADY_EXISTS, 'Cannot register passkey when TOTP is enabled');
const expectedChallenge = getAndDeleteChallenge(user.id);
if (!expectedChallenge) throw new BoxError(BoxError.BAD_FIELD, 'Challenge expired or not found');
@@ -198,9 +183,7 @@ async function verifyRegistration(user, response, name) {
throw new BoxError(BoxError.INVALID_CREDENTIALS, 'Passkey verification failed');
}
if (!verification.verified || !verification.registrationInfo) {
throw new BoxError(BoxError.INVALID_CREDENTIALS, 'Passkey verification failed');
}
if (!verification.verified || !verification.registrationInfo) throw new BoxError(BoxError.INVALID_CREDENTIALS, 'Passkey verification failed');
const { credential } = verification.registrationInfo;
@@ -222,17 +205,15 @@ async function verifyRegistration(user, response, name) {
return result;
}
async function generateAuthenticationOptions(user) {
async function getAuthenticationOptions(user) {
assert.strictEqual(typeof user, 'object');
const { rpId } = await getRpInfo();
const existingPasskeys = await list(user.id);
if (existingPasskeys.length === 0) {
throw new BoxError(BoxError.NOT_FOUND, 'No passkeys registered');
}
const existingPasskeys = await listByUserId(user.id);
if (existingPasskeys.length === 0) throw new BoxError(BoxError.NOT_FOUND, 'No passkeys registered');
const options = await generateWebAuthnAuthenticationOptions({
const options = await generateAuthenticationOptions({
rpID: rpId,
allowCredentials: existingPasskeys.map(pk => ({
id: pk.credentialId,
@@ -243,7 +224,7 @@ async function generateAuthenticationOptions(user) {
storeChallenge(user.id, options.challenge);
debug(`generateAuthenticationOptions: generated for user ${user.id}`);
debug(`getAuthenticationOptions: generated for user ${user.id}`);
return options;
}
@@ -271,7 +252,6 @@ async function verifyAuthentication(user, response) {
throw new BoxError(BoxError.INVALID_CREDENTIALS, 'Passkey does not belong to this user');
}
// Convert stored base64 public key back to Uint8Array
const publicKey = new Uint8Array(Buffer.from(passkey.publicKey, 'base64'));
const [error, verification] = await safe(verifyAuthenticationResponse({
@@ -292,11 +272,8 @@ async function verifyAuthentication(user, response) {
throw new BoxError(BoxError.INVALID_CREDENTIALS, 'Passkey verification failed');
}
if (!verification.verified) {
throw new BoxError(BoxError.INVALID_CREDENTIALS, 'Passkey verification failed');
}
if (!verification.verified) throw new BoxError(BoxError.INVALID_CREDENTIALS, 'Passkey verification failed');
// Update the counter to prevent replay attacks
await updateCounter(passkey.id, verification.authenticationInfo.newCounter);
debug(`verifyAuthentication: passkey verified for user ${user.id}`);
@@ -305,19 +282,17 @@ async function verifyAuthentication(user, response) {
}
export default {
list,
listByUserId,
get,
getByCredentialId,
add,
del,
updateCounter,
// this is only for dashboard origin changes
delAll,
generateRegistrationOptions,
getRegistrationOptions,
verifyRegistration,
generateAuthenticationOptions,
getAuthenticationOptions,
verifyAuthentication,
removePrivateFields