300 lines
9.9 KiB
JavaScript
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
|
|
};
|