Files
cloudron-box/src/passkeys.js
Girish Ramakrishnan f12b4faf34 lint
2026-03-12 23:23:23 +05:30

300 lines
9.9 KiB
JavaScript

import assert from 'node:assert';
import BoxError from './boxerror.js';
import crypto from 'node:crypto';
import dashboard from './dashboard.js';
import database from './database.js';
import logger from './logger.js';
import safe from 'safetydance';
import {
generateRegistrationOptions,
verifyRegistrationResponse,
generateAuthenticationOptions,
verifyAuthenticationResponse
} from '@simplewebauthn/server';
import _ from './underscore.js';
const { log } = logger('passkeys');
const PASSKEY_FIELDS = [ 'id', 'userId', 'credentialId', 'publicKey', 'counter', 'transports', 'name', 'creationTime', 'lastUsedTime' ].join(',');
// In-memory challenge store with expiration (challenges are short-lived)
const gChallenges = new Map();
const CHALLENGE_EXPIRY_MS = 5 * 60 * 1000; // 5 minutes
function removePrivateFields(passkey) {
return _.pick(passkey, ['id', 'name', 'creationTime', 'lastUsedTime']);
}
function postProcess(passkey) {
if (!passkey) return null;
passkey.transports = passkey.transports ? JSON.parse(passkey.transports) : [];
return passkey;
}
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 ]);
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 ]);
if (result.length === 0) return null;
return postProcess(result[0]);
}
async function getByCredentialId(credentialId) {
assert.strictEqual(typeof credentialId, 'string');
const result = await database.query(`SELECT ${PASSKEY_FIELDS} FROM passkeys WHERE credentialId = ?`, [ credentialId ]);
if (result.length === 0) return null;
return postProcess(result[0]);
}
async function add(userId, credentialId, publicKey, counter, transports, name) {
assert.strictEqual(typeof userId, 'string');
assert.strictEqual(typeof credentialId, 'string');
assert.strictEqual(typeof publicKey, 'string');
assert.strictEqual(typeof counter, 'number');
assert(Array.isArray(transports));
assert.strictEqual(typeof name, 'string');
const id = 'pk-' + crypto.randomUUID();
const query = 'INSERT INTO passkeys (id, userId, credentialId, publicKey, counter, transports, name) VALUES (?, ?, ?, ?, ?, ?, ?)';
const args = [ id, userId, credentialId, publicKey, counter, JSON.stringify(transports), name ];
const [error] = await safe(database.query(query, args));
if (error && error.sqlCode === 'ER_DUP_ENTRY') throw new BoxError(BoxError.ALREADY_EXISTS, 'Passkey already registered');
if (error && error.sqlCode === 'ER_NO_REFERENCED_ROW_2') throw new BoxError(BoxError.NOT_FOUND, 'User not found');
if (error) throw error;
return { id };
}
async function del(id) {
assert.strictEqual(typeof id, 'string');
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');
await database.query('UPDATE passkeys SET counter = ?, lastUsedTime = NOW() WHERE id = ?', [ counter, id ]);
}
async function delAll() {
await database.query('DELETE FROM passkeys');
}
function storeChallenge(userId, challenge) {
gChallenges.set(userId, {
challenge,
expiresAt: Date.now() + CHALLENGE_EXPIRY_MS
});
// clean up expired challenges
for (const [k, v] of gChallenges) {
if (v.expiresAt < Date.now()) gChallenges.delete(k);
}
}
function getAndDeleteChallenge(userId) {
const entry = gChallenges.get(userId);
gChallenges.delete(userId);
if (!entry) return null;
if (entry.expiresAt < Date.now()) return null;
return entry.challenge;
}
async function getRpInfo() {
const { fqdn } = await dashboard.getLocation();
return {
rpId: fqdn,
rpName: 'Cloudron',
origin: `https://${fqdn}`
};
}
async function getRegistrationOptions(user) {
assert.strictEqual(typeof user, 'object');
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 { rpId, rpName } = await getRpInfo();
const options = await generateRegistrationOptions({
rpName,
rpID: rpId,
userName: user.username || user.email,
userDisplayName: user.displayName || user.username || user.email,
userID: new TextEncoder().encode(user.id),
attestationType: 'none',
authenticatorSelection: {
residentKey: 'preferred',
userVerification: 'preferred'
}
});
storeChallenge(user.id, options.challenge);
log(`getRegistrationOptions: generated for user ${user.id}`);
return options;
}
async function verifyRegistration(user, response, name) {
assert.strictEqual(typeof user, 'object');
assert.strictEqual(typeof response, 'object');
assert.strictEqual(typeof name, 'string');
// 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');
const { rpId, origin } = await getRpInfo();
const [error, verification] = await safe(verifyRegistrationResponse({
response,
expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpId
}));
if (error) {
log(`verifyRegistration: verification failed for user ${user.id}:`, error);
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;
// credential.id is already base64url encoded by simplewebauthn
// credential.publicKey is a Uint8Array, store as base64
const publicKeyBase64 = Buffer.from(credential.publicKey).toString('base64');
const result = await add(
user.id,
credential.id, // already base64url
publicKeyBase64,
credential.counter,
credential.transports || [],
name || 'Passkey'
);
log(`verifyRegistration: passkey registered for user ${user.id}`);
return result;
}
async function getAuthenticationOptions(user) {
assert.strictEqual(typeof user, 'object');
const { rpId } = await getRpInfo();
const existingPasskeys = await listByUserId(user.id);
if (existingPasskeys.length === 0) throw new BoxError(BoxError.NOT_FOUND, 'No passkeys registered');
const options = await generateAuthenticationOptions({
rpID: rpId,
allowCredentials: existingPasskeys.map(pk => ({
id: pk.credentialId,
transports: pk.transports
})),
userVerification: 'preferred'
});
storeChallenge(user.id, options.challenge);
log(`getAuthenticationOptions: generated for user ${user.id}`);
return options;
}
async function verifyAuthentication(user, response) {
assert.strictEqual(typeof user, 'object');
assert.strictEqual(typeof response, 'object');
const expectedChallenge = getAndDeleteChallenge(user.id);
if (!expectedChallenge) throw new BoxError(BoxError.BAD_FIELD, 'Challenge expired or not found');
const { rpId, origin } = await getRpInfo();
// Find the passkey being used - response.id is base64url encoded (matches our stored format)
const credentialIdBase64url = response.id;
const passkey = await getByCredentialId(credentialIdBase64url);
if (!passkey) {
log(`verifyAuthentication: passkey not found for credential ${credentialIdBase64url}`);
throw new BoxError(BoxError.NOT_FOUND, 'Passkey not found');
}
if (passkey.userId !== user.id) {
log(`verifyAuthentication: passkey belongs to different user`);
throw new BoxError(BoxError.INVALID_CREDENTIALS, 'Passkey does not belong to this user');
}
const publicKey = new Uint8Array(Buffer.from(passkey.publicKey, 'base64'));
const [error, verification] = await safe(verifyAuthenticationResponse({
response,
expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpId,
credential: {
id: passkey.credentialId,
publicKey,
counter: passkey.counter,
transports: passkey.transports
}
}));
if (error) {
log(`verifyAuthentication: verification failed for user ${user.id}:`, error);
throw new BoxError(BoxError.INVALID_CREDENTIALS, 'Passkey verification failed');
}
if (!verification.verified) throw new BoxError(BoxError.INVALID_CREDENTIALS, 'Passkey verification failed');
await updateCounter(passkey.id, verification.authenticationInfo.newCounter);
log(`verifyAuthentication: passkey verified for user ${user.id}`);
return { verified: true, passkeyId: passkey.id };
}
export default {
listByUserId,
get,
getByCredentialId,
add,
del,
// this is only for dashboard origin changes
delAll,
getRegistrationOptions,
verifyRegistration,
getAuthenticationOptions,
verifyAuthentication,
removePrivateFields
};