From 11d340bc97e7432f23aea7c181a977500d43cb3c Mon Sep 17 00:00:00 2001 From: Andreas Coroiu <andreas.coroiu@gmail.com> Date: Wed, 5 Apr 2023 11:38:32 +0200 Subject: [PATCH] [EC-598] feat: add initial implementation of UI sessions --- .../browser-fido2-user-interface.service.ts | 73 +++++++++++++++++++ ...ido2-user-interface.service.abstraction.ts | 23 ++++++ .../fido2-authenticator.service.spec.ts | 66 ++++++++++------- .../services/fido2-authenticator.service.ts | 15 ++-- .../webauthn/services/fido2-client.service.ts | 2 + 5 files changed, 146 insertions(+), 33 deletions(-) diff --git a/apps/browser/src/services/fido2/browser-fido2-user-interface.service.ts b/apps/browser/src/services/fido2/browser-fido2-user-interface.service.ts index c378cf1ada..b570c4980a 100644 --- a/apps/browser/src/services/fido2/browser-fido2-user-interface.service.ts +++ b/apps/browser/src/services/fido2/browser-fido2-user-interface.service.ts @@ -3,6 +3,7 @@ import { filter, first, lastValueFrom, Observable, Subject, takeUntil } from "rx import { Utils } from "@bitwarden/common/misc/utils"; import { Fido2UserInterfaceService as Fido2UserInterfaceServiceAbstraction, + Fido2UserInterfaceSession, NewCredentialParams, } from "@bitwarden/common/webauthn/abstractions/fido2-user-interface.service.abstraction"; @@ -87,6 +88,10 @@ export class BrowserFido2UserInterfaceService implements Fido2UserInterfaceServi constructor(private popupUtilsService: PopupUtilsService) {} + async newSession(abortController?: AbortController): Promise<Fido2UserInterfaceSession> { + return await BrowserFido2UserInterfaceSession.create(this, abortController); + } + async confirmCredential( cipherId: string, abortController = new AbortController() @@ -265,3 +270,71 @@ export class BrowserFido2UserInterfaceService implements Fido2UserInterfaceServi return setTimeout(() => abortController.abort()); } } + +export class BrowserFido2UserInterfaceSession implements Fido2UserInterfaceSession { + static async create( + parentService: BrowserFido2UserInterfaceService, + abortController?: AbortController + ): Promise<BrowserFido2UserInterfaceSession> { + return new BrowserFido2UserInterfaceSession(parentService, abortController); + } + + readonly abortListener: () => void; + + private constructor( + private readonly parentService: BrowserFido2UserInterfaceService, + readonly abortController = new AbortController(), + readonly sessionId = Utils.newGuid() + ) { + this.abortListener = () => this.abort(); + abortController.signal.addEventListener("abort", this.abortListener); + } + + fallbackRequested = false; + + get aborted() { + return this.abortController.signal.aborted; + } + + confirmCredential(cipherId: string, abortController?: AbortController): Promise<boolean> { + return this.parentService.confirmCredential(cipherId, this.abortController); + } + + pickCredential(cipherIds: string[], abortController?: AbortController): Promise<string> { + return this.parentService.pickCredential(cipherIds, this.abortController); + } + + confirmNewCredential( + params: NewCredentialParams, + abortController?: AbortController + ): Promise<boolean> { + return this.parentService.confirmNewCredential(params, this.abortController); + } + + confirmNewNonDiscoverableCredential( + params: NewCredentialParams, + abortController?: AbortController + ): Promise<string> { + return this.parentService.confirmNewNonDiscoverableCredential(params, this.abortController); + } + + informExcludedCredential( + existingCipherIds: string[], + newCredential: NewCredentialParams, + abortController?: AbortController + ): Promise<void> { + return this.parentService.informExcludedCredential( + existingCipherIds, + newCredential, + this.abortController + ); + } + + private abort() { + this.close(); + } + + private close() { + this.abortController.signal.removeEventListener("abort", this.abortListener); + } +} 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 2e1507349a..8313ec5102 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 @@ -4,6 +4,29 @@ export interface NewCredentialParams { } export abstract class Fido2UserInterfaceService { + newSession: (abortController?: AbortController) => Promise<Fido2UserInterfaceSession>; + + // confirmCredential: (cipherId: string, abortController?: AbortController) => Promise<boolean>; + // pickCredential: (cipherIds: string[], abortController?: AbortController) => Promise<string>; + // confirmNewCredential: ( + // params: NewCredentialParams, + // abortController?: AbortController + // ) => Promise<boolean>; + // confirmNewNonDiscoverableCredential: ( + // params: NewCredentialParams, + // abortController?: AbortController + // ) => Promise<string | undefined>; + // informExcludedCredential: ( + // existingCipherIds: string[], + // newCredential: NewCredentialParams, + // abortController?: AbortController + // ) => Promise<void>; +} + +export abstract class Fido2UserInterfaceSession { + fallbackRequested = false; + aborted = false; + confirmCredential: (cipherId: string, abortController?: AbortController) => Promise<boolean>; pickCredential: (cipherIds: string[], abortController?: AbortController) => Promise<string>; confirmNewCredential: ( 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 4264cccf34..70635817f9 100644 --- a/libs/common/src/webauthn/services/fido2-authenticator.service.spec.ts +++ b/libs/common/src/webauthn/services/fido2-authenticator.service.spec.ts @@ -16,6 +16,7 @@ import { } from "../abstractions/fido2-authenticator.service.abstraction"; import { Fido2UserInterfaceService, + Fido2UserInterfaceSession, NewCredentialParams, } from "../abstractions/fido2-user-interface.service.abstraction"; import { Fido2Utils } from "../abstractions/fido2-utils"; @@ -28,11 +29,14 @@ const RpId = "bitwarden.com"; describe("FidoAuthenticatorService", () => { let cipherService!: MockProxy<CipherService>; let userInterface!: MockProxy<Fido2UserInterfaceService>; + let userInterfaceSession!: MockProxy<Fido2UserInterfaceSession>; let authenticator!: Fido2AuthenticatorService; beforeEach(async () => { cipherService = mock<CipherService>(); userInterface = mock<Fido2UserInterfaceService>(); + userInterfaceSession = mock<Fido2UserInterfaceSession>(); + userInterface.newSession.mockResolvedValue(userInterfaceSession); authenticator = new Fido2AuthenticatorService(cipherService, userInterface); }); @@ -77,7 +81,7 @@ describe("FidoAuthenticatorService", () => { }); it("should not request confirmation from user", async () => { - userInterface.confirmNewCredential.mockResolvedValue(true); + userInterfaceSession.confirmNewCredential.mockResolvedValue(true); const invalidParams = await createInvalidParams(); for (const p of Object.values(invalidParams)) { @@ -86,7 +90,7 @@ describe("FidoAuthenticatorService", () => { // eslint-disable-next-line no-empty } catch {} } - expect(userInterface.confirmNewCredential).not.toHaveBeenCalled(); + expect(userInterfaceSession.confirmNewCredential).not.toHaveBeenCalled(); }); }); @@ -120,19 +124,19 @@ describe("FidoAuthenticatorService", () => { * Deviation: Consent is not asked and the user is simply informed of the situation. **/ it("should inform user", async () => { - userInterface.informExcludedCredential.mockResolvedValue(); + userInterfaceSession.informExcludedCredential.mockResolvedValue(); try { await authenticator.makeCredential(params); // eslint-disable-next-line no-empty } catch {} - expect(userInterface.informExcludedCredential).toHaveBeenCalled(); + expect(userInterfaceSession.informExcludedCredential).toHaveBeenCalled(); }); /** Spec: return an error code equivalent to "NotAllowedError" and terminate the operation. */ it("should throw error", async () => { - userInterface.informExcludedCredential.mockResolvedValue(); + userInterfaceSession.informExcludedCredential.mockResolvedValue(); const result = async () => await authenticator.makeCredential(params); @@ -140,7 +144,7 @@ describe("FidoAuthenticatorService", () => { }); it("should not inform user of duplication when input data does not pass checks", async () => { - userInterface.informExcludedCredential.mockResolvedValue(); + userInterfaceSession.informExcludedCredential.mockResolvedValue(); const invalidParams = await createInvalidParams(); for (const p of Object.values(invalidParams)) { @@ -149,7 +153,7 @@ describe("FidoAuthenticatorService", () => { // eslint-disable-next-line no-empty } catch {} } - expect(userInterface.informExcludedCredential).not.toHaveBeenCalled(); + expect(userInterfaceSession.informExcludedCredential).not.toHaveBeenCalled(); }); it.todo( @@ -181,19 +185,19 @@ describe("FidoAuthenticatorService", () => { * Deviation: Consent is not asked and the user is simply informed of the situation. **/ it("should inform user", async () => { - userInterface.informExcludedCredential.mockResolvedValue(); + userInterfaceSession.informExcludedCredential.mockResolvedValue(); try { await authenticator.makeCredential(params); // eslint-disable-next-line no-empty } catch {} - expect(userInterface.informExcludedCredential).toHaveBeenCalled(); + expect(userInterfaceSession.informExcludedCredential).toHaveBeenCalled(); }); /** Spec: return an error code equivalent to "NotAllowedError" and terminate the operation. */ it("should throw error", async () => { - userInterface.informExcludedCredential.mockResolvedValue(); + userInterfaceSession.informExcludedCredential.mockResolvedValue(); const result = async () => await authenticator.makeCredential(params); @@ -201,7 +205,7 @@ describe("FidoAuthenticatorService", () => { }); it("should not inform user of duplication when input data does not pass checks", async () => { - userInterface.informExcludedCredential.mockResolvedValue(); + userInterfaceSession.informExcludedCredential.mockResolvedValue(); const invalidParams = await createInvalidParams(); for (const p of Object.values(invalidParams)) { @@ -210,7 +214,7 @@ describe("FidoAuthenticatorService", () => { // eslint-disable-next-line no-empty } catch {} } - expect(userInterface.informExcludedCredential).not.toHaveBeenCalled(); + expect(userInterfaceSession.informExcludedCredential).not.toHaveBeenCalled(); }); it.todo( @@ -231,7 +235,7 @@ describe("FidoAuthenticatorService", () => { * Deviation: Only `rpEntity.name` and `userEntity.name` is shown. * */ it("should request confirmation from user", async () => { - userInterface.confirmNewCredential.mockResolvedValue(true); + userInterfaceSession.confirmNewCredential.mockResolvedValue(true); cipherService.encrypt.mockResolvedValue({} as unknown as Cipher); cipherService.createWithServer.mockImplementation(async (cipher) => { cipher.id = Utils.newGuid(); @@ -240,7 +244,7 @@ describe("FidoAuthenticatorService", () => { await authenticator.makeCredential(params, new AbortController()); - expect(userInterface.confirmNewCredential).toHaveBeenCalledWith( + expect(userInterfaceSession.confirmNewCredential).toHaveBeenCalledWith( { credentialName: params.rpEntity.name, userName: params.userEntity.displayName, @@ -251,7 +255,7 @@ describe("FidoAuthenticatorService", () => { it("should save credential to vault if request confirmed by user", async () => { const encryptedCipher = {}; - userInterface.confirmNewCredential.mockResolvedValue(true); + userInterfaceSession.confirmNewCredential.mockResolvedValue(true); cipherService.encrypt.mockResolvedValue(encryptedCipher as unknown as Cipher); cipherService.createWithServer.mockImplementation(async (cipher) => { cipher.id = Utils.newGuid(); @@ -284,7 +288,7 @@ describe("FidoAuthenticatorService", () => { /** Spec: If the user does not consent or if user verification fails, return an error code equivalent to "NotAllowedError" and terminate the operation. */ it("should throw error if user denies creation request", async () => { - userInterface.confirmNewCredential.mockResolvedValue(false); + userInterfaceSession.confirmNewCredential.mockResolvedValue(false); const result = async () => await authenticator.makeCredential(params); @@ -294,7 +298,7 @@ describe("FidoAuthenticatorService", () => { /** Spec: If any error occurred while creating the new credential object, return an error code equivalent to "UnknownError" and terminate the operation. */ it("should throw unkown error if creation fails", async () => { const encryptedCipher = {}; - userInterface.confirmNewCredential.mockResolvedValue(true); + userInterfaceSession.confirmNewCredential.mockResolvedValue(true); cipherService.encrypt.mockResolvedValue(encryptedCipher as unknown as Cipher); cipherService.createWithServer.mockRejectedValue(new Error("Internal error")); @@ -322,11 +326,13 @@ describe("FidoAuthenticatorService", () => { * Deviation: Only `rpEntity.name` and `userEntity.name` is shown. * */ it("should request confirmation from user", async () => { - userInterface.confirmNewNonDiscoverableCredential.mockResolvedValue(existingCipher.id); + userInterfaceSession.confirmNewNonDiscoverableCredential.mockResolvedValue( + existingCipher.id + ); await authenticator.makeCredential(params, new AbortController()); - expect(userInterface.confirmNewNonDiscoverableCredential).toHaveBeenCalledWith( + expect(userInterfaceSession.confirmNewNonDiscoverableCredential).toHaveBeenCalledWith( { credentialName: params.rpEntity.name, userName: params.userEntity.displayName, @@ -337,7 +343,9 @@ describe("FidoAuthenticatorService", () => { it("should save credential to vault if request confirmed by user", async () => { const encryptedCipher = Symbol(); - userInterface.confirmNewNonDiscoverableCredential.mockResolvedValue(existingCipher.id); + userInterfaceSession.confirmNewNonDiscoverableCredential.mockResolvedValue( + existingCipher.id + ); cipherService.encrypt.mockResolvedValue(encryptedCipher as unknown as Cipher); await authenticator.makeCredential(params); @@ -368,7 +376,7 @@ describe("FidoAuthenticatorService", () => { /** Spec: If the user does not consent or if user verification fails, return an error code equivalent to "NotAllowedError" and terminate the operation. */ it("should throw error if user denies creation request", async () => { - userInterface.confirmNewNonDiscoverableCredential.mockResolvedValue(undefined); + userInterfaceSession.confirmNewNonDiscoverableCredential.mockResolvedValue(undefined); const params = await createParams(); const result = async () => await authenticator.makeCredential(params); @@ -379,7 +387,9 @@ describe("FidoAuthenticatorService", () => { /** Spec: If any error occurred while creating the new credential object, return an error code equivalent to "UnknownError" and terminate the operation. */ it("should throw unkown error if creation fails", async () => { const encryptedCipher = Symbol(); - userInterface.confirmNewNonDiscoverableCredential.mockResolvedValue(existingCipher.id); + userInterfaceSession.confirmNewNonDiscoverableCredential.mockResolvedValue( + existingCipher.id + ); cipherService.encrypt.mockResolvedValue(encryptedCipher as unknown as Cipher); cipherService.updateWithServer.mockRejectedValue(new Error("Internal error")); @@ -408,8 +418,8 @@ describe("FidoAuthenticatorService", () => { beforeEach(async () => { const cipher = createCipherView({ id: cipherId, type: CipherType.Login }); params = await createParams({ requireResidentKey }); - userInterface.confirmNewNonDiscoverableCredential.mockResolvedValue(cipherId); - userInterface.confirmNewCredential.mockResolvedValue(true); + userInterfaceSession.confirmNewNonDiscoverableCredential.mockResolvedValue(cipherId); + userInterfaceSession.confirmNewCredential.mockResolvedValue(true); cipherService.get.mockImplementation(async (cipherId) => cipherId === cipher.id ? ({ decrypt: () => cipher } as any) : undefined ); @@ -625,16 +635,16 @@ describe("FidoAuthenticatorService", () => { /** Spec: Prompt the user to select a public key credential source selectedCredential from credentialOptions. */ it("should request confirmation from the user", async () => { - userInterface.pickCredential.mockResolvedValue(ciphers[0].id); + userInterfaceSession.pickCredential.mockResolvedValue(ciphers[0].id); await authenticator.getAssertion(params); - expect(userInterface.pickCredential).toHaveBeenCalledWith(ciphers.map((c) => c.id)); + expect(userInterfaceSession.pickCredential).toHaveBeenCalledWith(ciphers.map((c) => c.id)); }); /** Spec: If the user does not consent, return an error code equivalent to "NotAllowedError" and terminate the operation. */ it("should throw error", async () => { - userInterface.pickCredential.mockResolvedValue(undefined); + userInterfaceSession.pickCredential.mockResolvedValue(undefined); const result = async () => await authenticator.getAssertion(params); @@ -690,7 +700,7 @@ describe("FidoAuthenticatorService", () => { }); } cipherService.getAllDecrypted.mockResolvedValue(ciphers); - userInterface.pickCredential.mockResolvedValue(ciphers[0].id); + userInterfaceSession.pickCredential.mockResolvedValue(ciphers[0].id); }); /** Spec: Increment the credential associated signature counter */ diff --git a/libs/common/src/webauthn/services/fido2-authenticator.service.ts b/libs/common/src/webauthn/services/fido2-authenticator.service.ts index 745b9570cb..daea9a440e 100644 --- a/libs/common/src/webauthn/services/fido2-authenticator.service.ts +++ b/libs/common/src/webauthn/services/fido2-authenticator.service.ts @@ -41,6 +41,8 @@ export class Fido2AuthenticatorService implements Fido2AuthenticatorServiceAbstr params: Fido2AuthenticatorMakeCredentialsParams, abortController?: AbortController ): Promise<Fido2AuthenticatorMakeCredentialResult> { + const userInterfaceSession = await this.userInterface.newSession(abortController); + if (params.credTypesAndPubKeyAlgs.every((p) => p.alg !== Fido2AlgorithmIdentifier.ES256)) { throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.NotSupported); } @@ -62,7 +64,7 @@ export class Fido2AuthenticatorService implements Fido2AuthenticatorServiceAbstr const isExcluded = await this.vaultContainsCredentials(params.excludeCredentialDescriptorList); if (isExcluded) { - await this.userInterface.informExcludedCredential( + await userInterfaceSession.informExcludedCredential( [Utils.guidToStandardFormat(params.excludeCredentialDescriptorList[0].id)], { credentialName: params.rpEntity.name, @@ -78,7 +80,7 @@ export class Fido2AuthenticatorService implements Fido2AuthenticatorServiceAbstr let fido2Key: Fido2KeyView; let keyPair: CryptoKeyPair; if (params.requireResidentKey) { - const userVerification = await this.userInterface.confirmNewCredential( + const userVerification = await userInterfaceSession.confirmNewCredential( { credentialName: params.rpEntity.name, userName: params.userEntity.displayName, @@ -104,7 +106,7 @@ export class Fido2AuthenticatorService implements Fido2AuthenticatorServiceAbstr throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.Unknown); } } else { - const cipherId = await this.userInterface.confirmNewNonDiscoverableCredential( + const cipherId = await userInterfaceSession.confirmNewNonDiscoverableCredential( { credentialName: params.rpEntity.name, userName: params.userEntity.displayName, @@ -157,8 +159,11 @@ export class Fido2AuthenticatorService implements Fido2AuthenticatorServiceAbstr } async getAssertion( - params: Fido2AuthenticatorGetAssertionParams + params: Fido2AuthenticatorGetAssertionParams, + abortController?: AbortController ): Promise<Fido2AuthenticatorGetAssertionResult> { + const userInterfaceSession = await this.userInterface.newSession(abortController); + if ( params.requireUserVerification != undefined && typeof params.requireUserVerification !== "boolean" @@ -186,7 +191,7 @@ export class Fido2AuthenticatorService implements Fido2AuthenticatorServiceAbstr throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.NotAllowed); } - const selectedCipherId = await this.userInterface.pickCredential( + const selectedCipherId = await userInterfaceSession.pickCredential( cipherOptions.map((cipher) => cipher.id) ); const selectedCipher = cipherOptions.find((c) => c.id === selectedCipherId); diff --git a/libs/common/src/webauthn/services/fido2-client.service.ts b/libs/common/src/webauthn/services/fido2-client.service.ts index 2ed13a05f7..49718c82ba 100644 --- a/libs/common/src/webauthn/services/fido2-client.service.ts +++ b/libs/common/src/webauthn/services/fido2-client.service.ts @@ -124,9 +124,11 @@ export class Fido2ClientService implements Fido2ClientServiceAbstraction { } throw new DOMException(undefined, "NotAllowedError"); } + if (abortController.signal.aborted) { throw new DOMException(undefined, "AbortError"); } + clearTimeout(timeout); return { credentialId: Fido2Utils.bufferToString(makeCredentialResult.credentialId),