add passkey tests

This commit is contained in:
Girish Ramakrishnan
2026-02-17 18:05:14 +01:00
parent 113aba0897
commit b8ae46b6df
3 changed files with 606 additions and 0 deletions
+338
View File
@@ -0,0 +1,338 @@
/* global it:false */
import BoxError from '../boxerror.js';
import common from './common.js';
import expect from 'expect.js';
import passkeys from '../passkeys.js';
import safe from 'safetydance';
import speakeasy from 'speakeasy';
import users from '../users.js';
import webauthnHelper from './webauthn-helper.js';
/* global describe:false */
/* global before:false */
/* global after:false */
describe('Passkeys', function () {
const { domainSetup, cleanup, admin, user, auditSource, dashboardFqdn } = common;
const origin = `https://${dashboardFqdn}`;
async function cleanupUsers() {
for (const u of await users.list()) {
await users.del(u, auditSource);
}
}
async function createOwner() {
await cleanupUsers();
const id = await users.add(admin.email, admin, auditSource);
admin.id = id;
}
before(domainSetup);
after(cleanup);
describe('CRUD', function () {
before(createOwner);
it('list returns empty', async function () {
const result = await passkeys.list(admin.id);
expect(result).to.be.an(Array);
expect(result.length).to.be(0);
});
it('can add a passkey', async function () {
const { id } = await passkeys.add(admin.id, 'credId123', 'pubKeyBase64', 0, ['internal'], 'Test Key');
expect(id).to.be.a('string');
expect(id).to.match(/^pk-/);
});
it('list returns one passkey', async function () {
const result = await passkeys.list(admin.id);
expect(result.length).to.be(1);
expect(result[0].userId).to.be(admin.id);
expect(result[0].credentialId).to.be('credId123');
expect(result[0].publicKey).to.be('pubKeyBase64');
expect(result[0].counter).to.be(0);
expect(result[0].transports).to.eql(['internal']);
expect(result[0].name).to.be('Test Key');
});
it('can get by id', async function () {
const list = await passkeys.list(admin.id);
const result = await passkeys.get(list[0].id);
expect(result).to.not.be(null);
expect(result.credentialId).to.be('credId123');
});
it('can get by credentialId', async function () {
const result = await passkeys.getByCredentialId('credId123');
expect(result).to.not.be(null);
expect(result.userId).to.be(admin.id);
});
it('get returns null for unknown id', async function () {
const result = await passkeys.get('pk-nonexistent');
expect(result).to.be(null);
});
it('getByCredentialId returns null for unknown', async function () {
const result = await passkeys.getByCredentialId('unknownCredId');
expect(result).to.be(null);
});
it('rejects duplicate credentialId', async function () {
const [error] = await safe(passkeys.add(admin.id, 'credId123', 'otherPubKey', 0, [], 'Dup'));
expect(error).to.not.be(null);
expect(error.reason).to.be(BoxError.ALREADY_EXISTS);
});
it('can update counter', async function () {
const list = await passkeys.list(admin.id);
await passkeys.updateCounter(list[0].id, 42);
const updated = await passkeys.get(list[0].id);
expect(updated.counter).to.be(42);
expect(updated.lastUsedTime).to.not.be(null);
});
it('can delete a passkey', async function () {
const list = await passkeys.list(admin.id);
await passkeys.del(list[0].id, admin.id);
const afterDel = await passkeys.list(admin.id);
expect(afterDel.length).to.be(0);
});
it('delete throws for unknown passkey', async function () {
const [error] = await safe(passkeys.del('pk-nonexistent', admin.id));
expect(error).to.not.be(null);
expect(error.reason).to.be(BoxError.NOT_FOUND);
});
it('delAll clears all passkeys', async function () {
await passkeys.add(admin.id, 'cred1', 'pk1', 0, [], 'Key1');
await passkeys.delAll();
const result = await passkeys.list(admin.id);
expect(result.length).to.be(0);
});
it('removePrivateFields strips sensitive data', async function () {
const { id } = await passkeys.add(admin.id, 'credStrip', 'pkStrip', 0, ['usb'], 'Strip Test');
const pk = await passkeys.get(id);
const filtered = passkeys.removePrivateFields(pk);
expect(filtered.id).to.be(id);
expect(filtered.name).to.be('Strip Test');
expect(filtered.creationTime).to.not.be(undefined);
expect(filtered.credentialId).to.be(undefined);
expect(filtered.publicKey).to.be(undefined);
expect(filtered.counter).to.be(undefined);
expect(filtered.userId).to.be(undefined);
await passkeys.del(id, admin.id);
});
});
describe('registration flow', function () {
before(createOwner);
let authenticator;
it('can generate registration options', async function () {
const adminUser = await users.get(admin.id);
const options = await passkeys.generateRegistrationOptions(adminUser);
expect(options).to.be.an(Object);
expect(options.challenge).to.be.a('string');
expect(options.rp).to.be.an(Object);
expect(options.rp.name).to.be('Cloudron');
expect(options.rp.id).to.be(dashboardFqdn);
expect(options.user).to.be.an(Object);
});
it('can register with virtual authenticator', async function () {
authenticator = webauthnHelper.createVirtualAuthenticator();
const adminUser = await users.get(admin.id);
const options = await passkeys.generateRegistrationOptions(adminUser);
const response = await webauthnHelper.createRegistrationResponse(authenticator, options, origin);
const result = await passkeys.verifyRegistration(adminUser, response, 'My Test Key');
expect(result).to.be.an(Object);
expect(result.id).to.be.a('string');
expect(result.id).to.match(/^pk-/);
const list = await passkeys.list(admin.id);
expect(list.length).to.be(1);
expect(list[0].name).to.be('My Test Key');
});
it('rejects duplicate registration', async function () {
const adminUser = await users.get(admin.id);
const [error] = await safe(passkeys.generateRegistrationOptions(adminUser));
expect(error).to.not.be(null);
expect(error.reason).to.be(BoxError.ALREADY_EXISTS);
});
it('cleanup passkey for further tests', async function () {
await passkeys.delAll();
});
it('rejects registration with expired challenge', async function () {
const adminUser = await users.get(admin.id);
authenticator = webauthnHelper.createVirtualAuthenticator();
// generate options to get a challenge, but use a different challenge in the response
const options = await passkeys.generateRegistrationOptions(adminUser);
options.challenge = 'tampered-challenge-value';
const response = await webauthnHelper.createRegistrationResponse(authenticator, options, origin);
const [error] = await safe(passkeys.verifyRegistration(adminUser, response, 'Bad Key'));
expect(error).to.not.be(null);
});
it('rejects registration when TOTP is enabled', async function () {
const adminUser = await users.get(admin.id);
// enable TOTP first
const twofa = await users.setTwoFactorAuthenticationSecret(adminUser, auditSource);
adminUser.twoFactorAuthenticationSecret = twofa.secret;
const totpToken = speakeasy.totp({ secret: twofa.secret, encoding: 'base32' });
await users.enableTwoFactorAuthentication(adminUser, totpToken, auditSource);
adminUser.twoFactorAuthenticationEnabled = true;
const [error] = await safe(passkeys.generateRegistrationOptions(adminUser));
expect(error).to.not.be(null);
expect(error.reason).to.be(BoxError.ALREADY_EXISTS);
// disable TOTP for further tests
await users.disableTwoFactorAuthentication(adminUser, auditSource);
adminUser.twoFactorAuthenticationEnabled = false;
});
});
describe('authentication flow', function () {
before(createOwner);
let authenticator;
it('register a passkey first', async function () {
authenticator = webauthnHelper.createVirtualAuthenticator();
const adminUser = await users.get(admin.id);
const options = await passkeys.generateRegistrationOptions(adminUser);
const response = await webauthnHelper.createRegistrationResponse(authenticator, options, origin);
await passkeys.verifyRegistration(adminUser, response, 'Auth Test Key');
});
it('can generate authentication options', async function () {
const adminUser = await users.get(admin.id);
const options = await passkeys.generateAuthenticationOptions(adminUser);
expect(options).to.be.an(Object);
expect(options.challenge).to.be.a('string');
expect(options.allowCredentials).to.be.an(Array);
expect(options.allowCredentials.length).to.be(1);
});
it('can authenticate with virtual authenticator', async function () {
const adminUser = await users.get(admin.id);
const options = await passkeys.generateAuthenticationOptions(adminUser);
const response = await webauthnHelper.createAuthenticationResponse(authenticator, options, origin);
const result = await passkeys.verifyAuthentication(adminUser, response);
expect(result).to.be.an(Object);
expect(result.verified).to.be(true);
expect(result.passkeyId).to.be.a('string');
});
it('counter was updated after authentication', async function () {
const list = await passkeys.list(admin.id);
expect(list[0].counter).to.be(1);
expect(list[0].lastUsedTime).to.not.be(null);
});
it('can authenticate again (counter increments)', async function () {
const adminUser = await users.get(admin.id);
const options = await passkeys.generateAuthenticationOptions(adminUser);
const response = await webauthnHelper.createAuthenticationResponse(authenticator, options, origin);
const result = await passkeys.verifyAuthentication(adminUser, response);
expect(result.verified).to.be(true);
const list = await passkeys.list(admin.id);
expect(list[0].counter).to.be(2);
});
it('rejects authentication with wrong credential', async function () {
const adminUser = await users.get(admin.id);
const options = await passkeys.generateAuthenticationOptions(adminUser);
// create a different authenticator and try to auth with it
const fakeAuth = webauthnHelper.createVirtualAuthenticator();
const response = await webauthnHelper.createAuthenticationResponse(fakeAuth, options, origin);
const [error] = await safe(passkeys.verifyAuthentication(adminUser, response));
expect(error).to.not.be(null);
expect(error.reason).to.be(BoxError.NOT_FOUND);
});
it('rejects authentication with tampered challenge', async function () {
const adminUser = await users.get(admin.id);
const options = await passkeys.generateAuthenticationOptions(adminUser);
options.challenge = 'tampered-challenge';
const response = await webauthnHelper.createAuthenticationResponse(authenticator, options, origin);
const [error] = await safe(passkeys.verifyAuthentication(adminUser, response));
expect(error).to.not.be(null);
});
it('fails to generate auth options when no passkey registered', async function () {
await passkeys.delAll();
const adminUser = await users.get(admin.id);
const [error] = await safe(passkeys.generateAuthenticationOptions(adminUser));
expect(error).to.not.be(null);
expect(error.reason).to.be(BoxError.NOT_FOUND);
});
});
describe('TOTP mutual exclusion', function () {
before(createOwner);
it('cannot enable TOTP when passkey exists', async function () {
// register a passkey
const authenticator = webauthnHelper.createVirtualAuthenticator();
const adminUser = await users.get(admin.id);
const options = await passkeys.generateRegistrationOptions(adminUser);
const response = await webauthnHelper.createRegistrationResponse(authenticator, options, origin);
await passkeys.verifyRegistration(adminUser, response, 'Exclusion Test');
// try to enable TOTP
const twofa = await users.setTwoFactorAuthenticationSecret(adminUser, auditSource);
adminUser.twoFactorAuthenticationSecret = twofa.secret;
const totpToken = speakeasy.totp({ secret: twofa.secret, encoding: 'base32' });
const [error] = await safe(users.enableTwoFactorAuthentication(adminUser, totpToken, auditSource));
expect(error).to.not.be(null);
expect(error.reason).to.be(BoxError.ALREADY_EXISTS);
await passkeys.delAll();
});
it('cannot register passkey when TOTP is enabled', async function () {
const adminUser = await users.get(admin.id);
// enable TOTP
const twofa = await users.setTwoFactorAuthenticationSecret(adminUser, auditSource);
adminUser.twoFactorAuthenticationSecret = twofa.secret;
const totpToken = speakeasy.totp({ secret: twofa.secret, encoding: 'base32' });
await users.enableTwoFactorAuthentication(adminUser, totpToken, auditSource);
adminUser.twoFactorAuthenticationEnabled = true;
const [error] = await safe(passkeys.generateRegistrationOptions(adminUser));
expect(error).to.not.be(null);
expect(error.reason).to.be(BoxError.ALREADY_EXISTS);
// cleanup
await users.disableTwoFactorAuthentication(adminUser, auditSource);
adminUser.twoFactorAuthenticationEnabled = false;
});
});
});
+154
View File
@@ -0,0 +1,154 @@
import crypto from 'node:crypto';
import { isoCBOR, isoBase64URL, isoUint8Array, cose } from '@simplewebauthn/server/helpers';
// Virtual authenticator for testing WebAuthn registration and authentication flows
// without a browser or physical authenticator device.
function createVirtualAuthenticator() {
const keyPair = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' });
const credentialId = crypto.randomBytes(32);
const counter = { value: 0 };
return { keyPair, credentialId, counter };
}
function buildCOSEPublicKey(keyPair) {
const publicKeyJWK = keyPair.publicKey.export({ format: 'jwk' });
const x = Buffer.from(publicKeyJWK.x, 'base64');
const y = Buffer.from(publicKeyJWK.y, 'base64');
const coseKey = new Map();
coseKey.set(cose.COSEKEYS.kty, cose.COSEKTY.EC2); // 1: 2 (EC2)
coseKey.set(cose.COSEKEYS.alg, cose.COSEALG.ES256); // 3: -7 (ES256)
coseKey.set(cose.COSEKEYS.crv, cose.COSECRV.P256); // -1: 1 (P-256)
coseKey.set(cose.COSEKEYS.x, new Uint8Array(x)); // -2: x
coseKey.set(cose.COSEKEYS.y, new Uint8Array(y)); // -3: y
return isoCBOR.encode(coseKey);
}
async function rpIdHash(rpId) {
const hash = crypto.createHash('sha256').update(rpId).digest();
return new Uint8Array(hash);
}
function buildAuthData(rpIdHashBytes, flags, counter, attestedCredentialData) {
const flagsByte = new Uint8Array([flags]);
const counterBytes = new Uint8Array(4);
new DataView(counterBytes.buffer).setUint32(0, counter, false); // big-endian
const parts = [rpIdHashBytes, flagsByte, counterBytes];
if (attestedCredentialData) parts.push(attestedCredentialData);
return isoUint8Array.concat(parts);
}
function buildAttestedCredentialData(credentialId, cosePublicKey) {
const aaguid = new Uint8Array(16); // all zeros
const credIdLen = new Uint8Array(2);
new DataView(credIdLen.buffer).setUint16(0, credentialId.length, false);
return isoUint8Array.concat([
aaguid,
credIdLen,
new Uint8Array(credentialId),
new Uint8Array(cosePublicKey)
]);
}
async function createRegistrationResponse(authenticator, options, origin) {
const { keyPair, credentialId, counter } = authenticator;
const challenge = options.challenge;
const rpId = new URL(origin).hostname;
// clientDataJSON
const clientDataJSON = JSON.stringify({
type: 'webauthn.create',
challenge,
origin,
crossOrigin: false
});
const clientDataJSONEncoded = isoBase64URL.fromUTF8String(clientDataJSON);
// authData with attested credential data
const cosePublicKey = buildCOSEPublicKey(keyPair);
const attestedCredData = buildAttestedCredentialData(credentialId, cosePublicKey);
const rpIdHashBytes = await rpIdHash(rpId);
// flags: UP (0x01) | UV (0x04) | AT (0x40) = 0x45
const authData = buildAuthData(rpIdHashBytes, 0x45, counter.value, attestedCredData);
// attestationObject with "none" format
const attestationObject = new Map();
attestationObject.set('fmt', 'none');
attestationObject.set('attStmt', new Map());
attestationObject.set('authData', authData);
const attestationObjectEncoded = isoBase64URL.fromBuffer(isoCBOR.encode(attestationObject));
const credentialIdBase64url = isoBase64URL.fromBuffer(credentialId);
return {
id: credentialIdBase64url,
rawId: credentialIdBase64url,
response: {
attestationObject: attestationObjectEncoded,
clientDataJSON: clientDataJSONEncoded,
transports: ['internal'],
},
type: 'public-key',
clientExtensionResults: {},
authenticatorAttachment: 'platform',
};
}
async function createAuthenticationResponse(authenticator, options, origin) {
const { keyPair, credentialId, counter } = authenticator;
const challenge = options.challenge;
const rpId = new URL(origin).hostname;
// increment counter
counter.value += 1;
// clientDataJSON
const clientDataJSON = JSON.stringify({
type: 'webauthn.get',
challenge,
origin,
crossOrigin: false
});
const clientDataJSONBytes = new TextEncoder().encode(clientDataJSON);
const clientDataJSONEncoded = isoBase64URL.fromBuffer(clientDataJSONBytes);
// authenticatorData (no attested credential data for authentication)
const rpIdHashBytes = await rpIdHash(rpId);
// flags: UP (0x01) | UV (0x04) = 0x05
const authData = buildAuthData(rpIdHashBytes, 0x05, counter.value);
// signature = sign(authData || SHA-256(clientDataJSON))
const clientDataHash = crypto.createHash('sha256').update(clientDataJSONBytes).digest();
const signedData = isoUint8Array.concat([authData, new Uint8Array(clientDataHash)]);
const signature = crypto.sign('sha256', signedData, keyPair.privateKey);
const credentialIdBase64url = isoBase64URL.fromBuffer(credentialId);
const authDataEncoded = isoBase64URL.fromBuffer(authData);
const signatureEncoded = isoBase64URL.fromBuffer(signature);
return {
id: credentialIdBase64url,
rawId: credentialIdBase64url,
response: {
authenticatorData: authDataEncoded,
clientDataJSON: clientDataJSONEncoded,
signature: signatureEncoded,
},
type: 'public-key',
clientExtensionResults: {},
authenticatorAttachment: 'platform',
};
}
export default {
createVirtualAuthenticator,
createRegistrationResponse,
createAuthenticationResponse
};