diff --git a/src/routes/test/profile-test.js b/src/routes/test/profile-test.js index 845f50508..1850bba2c 100644 --- a/src/routes/test/profile-test.js +++ b/src/routes/test/profile-test.js @@ -3,9 +3,11 @@ import common from './common.js'; import expect from 'expect.js'; import fs from 'node:fs'; +import passkeys from '../../passkeys.js'; import speakeasy from 'speakeasy'; import superagent from '@cloudron/superagent'; import tokens from '../../tokens.js'; +import webauthnHelper from '../../test/webauthn-helper.js'; const customAvatarSize = fs.readFileSync('./logo.png').length; @@ -305,6 +307,118 @@ describe('Profile API', function () { }); }); + describe('passkey', function () { + const origin = `https://${common.dashboardFqdn}`; + let authenticator; + + after(async function () { + // ensure passkey is cleaned up for subsequent tests + await passkeys.delAll(); + }); + + it('no passkey by default', async function () { + const response = await superagent.get(`${serverUrl}/api/v1/profile/passkey`) + .query({ access_token: user.token }); + + expect(response.status).to.equal(200); + expect(response.body.passkey).to.be(null); + }); + + it('fails to get registration options without token', async function () { + const response = await superagent.post(`${serverUrl}/api/v1/profile/passkey/register/options`) + .send({}) + .ok(() => true); + + expect(response.status).to.equal(401); + }); + + it('can get registration options', async function () { + const response = await superagent.post(`${serverUrl}/api/v1/profile/passkey/register/options`) + .query({ access_token: user.token }) + .send({}); + + expect(response.status).to.equal(200); + expect(response.body.challenge).to.be.a('string'); + expect(response.body.rp).to.be.an(Object); + }); + + it('can register passkey', async function () { + authenticator = webauthnHelper.createVirtualAuthenticator(); + + const optionsResponse = await superagent.post(`${serverUrl}/api/v1/profile/passkey/register/options`) + .query({ access_token: user.token }) + .send({}); + + const credential = await webauthnHelper.createRegistrationResponse(authenticator, optionsResponse.body, origin); + + const response = await superagent.post(`${serverUrl}/api/v1/profile/passkey/register`) + .query({ access_token: user.token }) + .send({ credential, name: 'Test Passkey' }); + + expect(response.status).to.equal(201); + expect(response.body.id).to.be.a('string'); + }); + + it('passkey is visible', async function () { + const response = await superagent.get(`${serverUrl}/api/v1/profile/passkey`) + .query({ access_token: user.token }); + + expect(response.status).to.equal(200); + expect(response.body.passkey).to.be.an(Object); + expect(response.body.passkey.name).to.be('Test Passkey'); + expect(response.body.passkey.credentialId).to.be(undefined); // private field stripped + }); + + it('rejects duplicate registration', async function () { + const optionsResponse = await superagent.post(`${serverUrl}/api/v1/profile/passkey/register/options`) + .query({ access_token: user.token }) + .send({}) + .ok(() => true); + + expect(optionsResponse.status).to.equal(409); + }); + + it('fails to register without credential', async function () { + // use owner who has no passkey + const optionsResponse = await superagent.post(`${serverUrl}/api/v1/profile/passkey/register/options`) + .query({ access_token: owner.token }) + .send({}); + expect(optionsResponse.status).to.equal(200); + + const response = await superagent.post(`${serverUrl}/api/v1/profile/passkey/register`) + .query({ access_token: owner.token }) + .send({}) + .ok(() => true); + + expect(response.status).to.equal(400); + }); + + it('can disable passkey', async function () { + const response = await superagent.post(`${serverUrl}/api/v1/profile/passkey/disable`) + .query({ access_token: user.token }) + .send({ password: user.password }); + + expect(response.status).to.equal(204); + }); + + it('passkey is gone after disable', async function () { + const response = await superagent.get(`${serverUrl}/api/v1/profile/passkey`) + .query({ access_token: user.token }); + + expect(response.status).to.equal(200); + expect(response.body.passkey).to.be(null); + }); + + it('disable fails when no passkey registered', async function () { + const response = await superagent.post(`${serverUrl}/api/v1/profile/passkey/disable`) + .query({ access_token: user.token }) + .send({ password: user.password }) + .ok(() => true); + + expect(response.status).to.equal(404); + }); + }); + describe('avatar', function () { let customAvatarSize = 0; diff --git a/src/test/passkeys-test.js b/src/test/passkeys-test.js new file mode 100644 index 000000000..25f23b8ea --- /dev/null +++ b/src/test/passkeys-test.js @@ -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; + }); + }); +}); diff --git a/src/test/webauthn-helper.js b/src/test/webauthn-helper.js new file mode 100644 index 000000000..5c114a525 --- /dev/null +++ b/src/test/webauthn-helper.js @@ -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 +};