From 6bf680cacc651a3163bef2114497bc35611083d6 Mon Sep 17 00:00:00 2001 From: Andreas Coroiu Date: Thu, 23 Mar 2023 10:47:58 +0100 Subject: [PATCH] [EC-598] feat: add support for non-discoverable credentials --- ...ido2-user-interface.service.abstraction.ts | 4 + .../fido2-authenticator.service.spec.ts | 83 +++++++++++++++++-- .../services/fido2-authenticator.service.ts | 72 ++++++++++------ 3 files changed, 126 insertions(+), 33 deletions(-) diff --git a/libs/common/src/webauthn/abstractions/fido2-user-interface.service.abstraction.ts b/libs/common/src/webauthn/abstractions/fido2-user-interface.service.abstraction.ts index 68d372d2fa..2e1507349a 100644 --- a/libs/common/src/webauthn/abstractions/fido2-user-interface.service.abstraction.ts +++ b/libs/common/src/webauthn/abstractions/fido2-user-interface.service.abstraction.ts @@ -10,6 +10,10 @@ export abstract class Fido2UserInterfaceService { params: NewCredentialParams, abortController?: AbortController ) => Promise; + confirmNewNonDiscoverableCredential: ( + params: NewCredentialParams, + abortController?: AbortController + ) => Promise; informExcludedCredential: ( existingCipherIds: string[], newCredential: NewCredentialParams, diff --git a/libs/common/src/webauthn/services/fido2-authenticator.service.spec.ts b/libs/common/src/webauthn/services/fido2-authenticator.service.spec.ts index 2503b198e8..84933b894c 100644 --- a/libs/common/src/webauthn/services/fido2-authenticator.service.spec.ts +++ b/libs/common/src/webauthn/services/fido2-authenticator.service.spec.ts @@ -6,6 +6,7 @@ import { Utils } from "../../misc/utils"; import { CipherService } from "../../vault/abstractions/cipher.service"; import { CipherType } from "../../vault/enums/cipher-type"; import { Cipher } from "../../vault/models/domain/cipher"; +import { Login } from "../../vault/models/domain/login"; import { CipherView } from "../../vault/models/view/cipher.view"; import { Fido2AutenticatorErrorCode, @@ -153,10 +154,15 @@ describe("FidoAuthenticatorService", () => { }); describe("creation of discoverable credential", () => { + let params: Fido2AuthenticatorMakeCredentialsParams; + + beforeEach(async () => { + params = await createCredentialParams({ options: { rk: true } }); + }); + /** Spec: show the items contained within the user and rp parameter structures to the user. */ it("should request confirmation from user", async () => { userInterface.confirmNewCredential.mockResolvedValue(true); - const params = await createCredentialParams(); await authenticator.makeCredential(params); @@ -170,7 +176,6 @@ describe("FidoAuthenticatorService", () => { const encryptedCipher = Symbol(); userInterface.confirmNewCredential.mockResolvedValue(true); cipherService.encrypt.mockResolvedValue(encryptedCipher as unknown as Cipher); - const params = await createCredentialParams({ options: { rk: true } }); await authenticator.makeCredential(params); @@ -196,6 +201,72 @@ describe("FidoAuthenticatorService", () => { /** Spec: If the user declines permission, return the CTAP2_ERR_OPERATION_DENIED error. */ it("should throw error if user denies creation request", async () => { userInterface.confirmNewCredential.mockResolvedValue(false); + + const result = async () => await authenticator.makeCredential(params); + + await expect(result).rejects.toThrowError( + Fido2AutenticatorErrorCode[Fido2AutenticatorErrorCode.CTAP2_ERR_OPERATION_DENIED] + ); + }); + }); + + describe("creation of non-discoverable credential", () => { + let existingCipherView: CipherView; + let params: Fido2AuthenticatorMakeCredentialsParams; + + beforeEach(async () => { + const existingCipher = createCipher({ type: CipherType.Login }); + existingCipher.login = new Login(); + existingCipher.fido2Key = undefined; + existingCipherView = await existingCipher.decrypt(); + params = await createCredentialParams(); + cipherService.get.mockImplementation(async (id) => + id === existingCipher.id ? existingCipher : undefined + ); + cipherService.getAllDecrypted.mockResolvedValue([existingCipherView]); + }); + + /** Spec: show the items contained within the user and rp parameter structures to the user. */ + it("should request confirmation from user", async () => { + userInterface.confirmNewNonDiscoverableCredential.mockResolvedValue(existingCipherView.id); + + await authenticator.makeCredential(params); + + expect(userInterface.confirmNewNonDiscoverableCredential).toHaveBeenCalledWith({ + credentialName: params.rp.name, + userName: params.user.name, + } as NewCredentialParams); + }); + + it("should save credential to vault if request confirmed by user", async () => { + const encryptedCipher = Symbol(); + userInterface.confirmNewNonDiscoverableCredential.mockResolvedValue(existingCipherView.id); + cipherService.encrypt.mockResolvedValue(encryptedCipher as unknown as Cipher); + + await authenticator.makeCredential(params); + + const saved = cipherService.encrypt.mock.lastCall?.[0]; + expect(saved).toEqual( + expect.objectContaining({ + type: CipherType.Login, + name: existingCipherView.name, + + fido2Key: expect.objectContaining({ + keyType: "ECDSA", + keyCurve: "P-256", + rpId: params.rp.id, + rpName: params.rp.name, + userHandle: Fido2Utils.bufferToString(params.user.id), + userName: params.user.name, + }), + }) + ); + expect(cipherService.updateWithServer).toHaveBeenCalledWith(encryptedCipher); + }); + + /** Spec: If the user declines permission, return the CTAP2_ERR_OPERATION_DENIED error. */ + it("should throw error if user denies creation request", async () => { + userInterface.confirmNewNonDiscoverableCredential.mockResolvedValue(undefined); const params = await createCredentialParams(); const result = async () => await authenticator.makeCredential(params); @@ -262,11 +333,11 @@ async function createInvalidParams() { }; } -function createCipher(id = Utils.newGuid()): Cipher { +function createCipher(data: Partial = {}): Cipher { const cipher = new Cipher(); - cipher.id = id; - cipher.type = CipherType.Fido2Key; - cipher.fido2Key = new Fido2Key(); + cipher.id = data.id ?? Utils.newGuid(); + cipher.type = data.type ?? CipherType.Fido2Key; + cipher.fido2Key = data.fido2Key ?? new Fido2Key(); return cipher; } diff --git a/libs/common/src/webauthn/services/fido2-authenticator.service.ts b/libs/common/src/webauthn/services/fido2-authenticator.service.ts index 6c9897f472..c53666fbd0 100644 --- a/libs/common/src/webauthn/services/fido2-authenticator.service.ts +++ b/libs/common/src/webauthn/services/fido2-authenticator.service.ts @@ -57,20 +57,42 @@ export class Fido2AuthenticatorService implements Fido2AuthenticatorServiceAbstr throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.CTAP2_ERR_CREDENTIAL_EXCLUDED); } - const userVerification = await this.userInterface.confirmNewCredential({ - credentialName: params.rp.name, - userName: params.user.name, - }); + if (params.options?.rk) { + const userVerification = await this.userInterface.confirmNewCredential({ + credentialName: params.rp.name, + userName: params.user.name, + }); - if (!userVerification) { - throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.CTAP2_ERR_OPERATION_DENIED); + if (!userVerification) { + throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.CTAP2_ERR_OPERATION_DENIED); + } + + const keyPair = await this.createKeyPair(); + + const cipher = new CipherView(); + cipher.type = CipherType.Fido2Key; + cipher.name = params.rp.name; + cipher.fido2Key = await this.createKeyView(params, keyPair.privateKey); + const encrypted = await this.cipherService.encrypt(cipher); + await this.cipherService.createWithServer(encrypted); + } else { + const cipherId = await this.userInterface.confirmNewNonDiscoverableCredential({ + credentialName: params.rp.name, + userName: params.user.name, + }); + + if (cipherId === undefined) { + throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.CTAP2_ERR_OPERATION_DENIED); + } + + const keyPair = await this.createKeyPair(); + + const encrypted = await this.cipherService.get(cipherId); + const cipher = await encrypted.decrypt(); + cipher.fido2Key = await this.createKeyView(params, keyPair.privateKey); + const reencrypted = await this.cipherService.encrypt(cipher); + await this.cipherService.updateWithServer(reencrypted); } - - const keyPair = await this.createKeyPair(); - const vaultItem = await this.createVaultItem(params, keyPair.privateKey); - - const encrypted = await this.cipherService.encrypt(vaultItem); - await this.cipherService.createWithServer(encrypted); } private async vaultContainsId(ids: string[]): Promise { @@ -94,25 +116,21 @@ export class Fido2AuthenticatorService implements Fido2AuthenticatorServiceAbstr ); } - private async createVaultItem( + private async createKeyView( params: Fido2AuthenticatorMakeCredentialsParams, keyValue: CryptoKey - ): Promise { + ): Promise { const pcks8Key = await crypto.subtle.exportKey("pkcs8", keyValue); - const view = new CipherView(); - view.type = CipherType.Fido2Key; - view.name = params.rp.name; + const fido2Key = new Fido2KeyView(); + fido2Key.keyType = "ECDSA"; + fido2Key.keyCurve = "P-256"; + fido2Key.keyValue = Fido2Utils.bufferToString(pcks8Key); + fido2Key.rpId = params.rp.id; + fido2Key.rpName = params.rp.name; + fido2Key.userHandle = Fido2Utils.bufferToString(params.user.id); + fido2Key.userName = params.user.name; - view.fido2Key = new Fido2KeyView(); - view.fido2Key.keyType = "ECDSA"; - view.fido2Key.keyCurve = "P-256"; - view.fido2Key.keyValue = Fido2Utils.bufferToString(pcks8Key); - view.fido2Key.rpId = params.rp.id; - view.fido2Key.rpName = params.rp.name; - view.fido2Key.userHandle = Fido2Utils.bufferToString(params.user.id); - view.fido2Key.userName = params.user.name; - - return view; + return fido2Key; } }