add passkey tests
This commit is contained in:
@@ -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;
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
};
|
||||
Reference in New Issue
Block a user