Add passkey support
This commit is contained in:
+316
@@ -0,0 +1,316 @@
|
||||
'use strict';
|
||||
|
||||
exports = module.exports = {
|
||||
list,
|
||||
get,
|
||||
getByCredentialId,
|
||||
add,
|
||||
del,
|
||||
updateCounter,
|
||||
|
||||
generateRegistrationOptions,
|
||||
verifyRegistration,
|
||||
generateAuthenticationOptions,
|
||||
verifyAuthentication,
|
||||
|
||||
removePrivateFields
|
||||
};
|
||||
|
||||
const assert = require('node:assert'),
|
||||
BoxError = require('./boxerror.js'),
|
||||
crypto = require('node:crypto'),
|
||||
dashboard = require('./dashboard.js'),
|
||||
database = require('./database.js'),
|
||||
debug = require('debug')('box:passkeys'),
|
||||
safe = require('safetydance'),
|
||||
{
|
||||
generateRegistrationOptions: generateWebAuthnRegistrationOptions,
|
||||
verifyRegistrationResponse,
|
||||
generateAuthenticationOptions: generateWebAuthnAuthenticationOptions,
|
||||
verifyAuthenticationResponse
|
||||
} = require('@simplewebauthn/server'),
|
||||
_ = require('./underscore.js');
|
||||
|
||||
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 list(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, userId) {
|
||||
assert.strictEqual(typeof id, 'string');
|
||||
assert.strictEqual(typeof userId, 'string');
|
||||
|
||||
const result = await database.query('DELETE FROM passkeys WHERE id = ? AND userId = ?', [ id, userId ]);
|
||||
if (result.affectedRows !== 1) throw new BoxError(BoxError.NOT_FOUND, 'Passkey not found');
|
||||
}
|
||||
|
||||
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 ]);
|
||||
}
|
||||
|
||||
function storeChallenge(userId, challenge) {
|
||||
const key = `${userId}`;
|
||||
gChallenges.set(key, {
|
||||
challenge,
|
||||
expiresAt: Date.now() + CHALLENGE_EXPIRY_MS
|
||||
});
|
||||
|
||||
// Clean up expired challenges periodically
|
||||
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);
|
||||
|
||||
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 generateRegistrationOptions(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');
|
||||
}
|
||||
|
||||
// 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');
|
||||
}
|
||||
|
||||
const { rpId, rpName } = await getRpInfo();
|
||||
|
||||
const options = await generateWebAuthnRegistrationOptions({
|
||||
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);
|
||||
|
||||
debug(`generateRegistrationOptions: 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');
|
||||
|
||||
// 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');
|
||||
}
|
||||
|
||||
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) {
|
||||
debug(`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'
|
||||
);
|
||||
|
||||
debug(`verifyRegistration: passkey registered for user ${user.id}`);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function generateAuthenticationOptions(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 options = await generateWebAuthnAuthenticationOptions({
|
||||
rpID: rpId,
|
||||
allowCredentials: existingPasskeys.map(pk => ({
|
||||
id: pk.credentialId,
|
||||
transports: pk.transports
|
||||
})),
|
||||
userVerification: 'preferred'
|
||||
});
|
||||
|
||||
storeChallenge(user.id, options.challenge);
|
||||
|
||||
debug(`generateAuthenticationOptions: 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) {
|
||||
debug(`verifyAuthentication: passkey not found for credential ${credentialIdBase64url}`);
|
||||
throw new BoxError(BoxError.NOT_FOUND, 'Passkey not found');
|
||||
}
|
||||
|
||||
if (passkey.userId !== user.id) {
|
||||
debug(`verifyAuthentication: passkey belongs to different user`);
|
||||
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({
|
||||
response,
|
||||
expectedChallenge,
|
||||
expectedOrigin: origin,
|
||||
expectedRPID: rpId,
|
||||
credential: {
|
||||
id: passkey.credentialId,
|
||||
publicKey,
|
||||
counter: passkey.counter,
|
||||
transports: passkey.transports
|
||||
}
|
||||
}));
|
||||
|
||||
if (error) {
|
||||
debug(`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');
|
||||
}
|
||||
|
||||
// Update the counter to prevent replay attacks
|
||||
await updateCounter(passkey.id, verification.authenticationInfo.newCounter);
|
||||
|
||||
debug(`verifyAuthentication: passkey verified for user ${user.id}`);
|
||||
|
||||
return { verified: true, passkeyId: passkey.id };
|
||||
}
|
||||
Reference in New Issue
Block a user