diff --git a/apps/browser/src/background/runtime.background.ts b/apps/browser/src/background/runtime.background.ts index 2abd4662d1a6..2b1814372a04 100644 --- a/apps/browser/src/background/runtime.background.ts +++ b/apps/browser/src/background/runtime.background.ts @@ -12,6 +12,7 @@ import { BrowserApi } from "../platform/browser/browser-api"; import { BrowserPopoutWindowService } from "../platform/popup/abstractions/browser-popout-window.service"; import { BrowserEnvironmentService } from "../platform/services/browser-environment.service"; import BrowserPlatformUtilsService from "../platform/services/browser-platform-utils.service"; +import { AbortManager } from "../vault/background/abort-manager"; import MainBackground from "./main.background"; import LockedVaultPendingNotificationsItem from "./models/lockedVaultPendingNotificationsItem"; @@ -21,7 +22,7 @@ export default class RuntimeBackground { private pageDetailsToAutoFill: any[] = []; private onInstalledReason: string = null; private lockedVaultPendingNotifications: LockedVaultPendingNotificationsItem[] = []; - private abortControllers = new Map(); + private abortManager = new AbortManager(); constructor( private main: MainBackground, @@ -253,18 +254,18 @@ export default class RuntimeBackground { this.platformUtilsService.copyToClipboard(msg.identifier, { window: window }); break; case "fido2AbortRequest": - this.abortControllers.get(msg.abortedRequestId)?.abort(); + this.abortManager.abort(msg.abortedRequestId); break; case "checkFido2FeatureEnabled": return await this.main.fido2ClientService.isFido2FeatureEnabled(); case "fido2RegisterCredentialRequest": - return await this.main.fido2ClientService - .createCredential(msg.data, this.createAbortController(msg.requestId)) - .finally(() => this.abortControllers.delete(msg.requestId)); + return await this.abortManager.runWithAbortController(msg.requestId, (abortController) => + this.main.fido2ClientService.createCredential(msg.data, abortController) + ); case "fido2GetCredentialRequest": - return await this.main.fido2ClientService - .assertCredential(msg.data, this.createAbortController(msg.requestId)) - .finally(() => this.abortControllers.delete(msg.requestId)); + return await this.abortManager.runWithAbortController(msg.requestId, (abortController) => + this.main.fido2ClientService.assertCredential(msg.data, abortController) + ); } } @@ -301,10 +302,4 @@ export default class RuntimeBackground { } }, 100); } - - private createAbortController(id: string): AbortController { - const abortController = new AbortController(); - this.abortControllers.set(id, abortController); - return abortController; - } } diff --git a/apps/browser/src/vault/background/abort-manager.ts b/apps/browser/src/vault/background/abort-manager.ts new file mode 100644 index 000000000000..8e61ca7a7b47 --- /dev/null +++ b/apps/browser/src/vault/background/abort-manager.ts @@ -0,0 +1,21 @@ +type Runner = (abortController: AbortController) => Promise; + +/** + * Manages abort controllers for long running tasks and allow separate + * execution contexts to abort each other by using ids. + */ +export class AbortManager { + private abortControllers = new Map(); + + runWithAbortController(id: string, runner: Runner): Promise { + const abortController = new AbortController(); + this.abortControllers.set(id, abortController); + return runner(abortController).finally(() => { + this.abortControllers.delete(id); + }); + } + + abort(id: string) { + this.abortControllers.get(id)?.abort(); + } +} diff --git a/apps/browser/src/vault/fido2/browser-fido2-user-interface.service.ts b/apps/browser/src/vault/fido2/browser-fido2-user-interface.service.ts index 549ca324afe0..272c3d44d454 100644 --- a/apps/browser/src/vault/fido2/browser-fido2-user-interface.service.ts +++ b/apps/browser/src/vault/fido2/browser-fido2-user-interface.service.ts @@ -43,7 +43,7 @@ export type BrowserFido2Message = { sessionId: string } & ( } /** * This message is used to announce the creation of a new session. - * It iss used by popouts to know when to close. + * It is used by popouts to know when to close. **/ | { type: "NewSessionCreatedRequest"; @@ -89,6 +89,10 @@ export type BrowserFido2Message = { sessionId: string } & ( } ); +/** + * Browser implementation of the {@link Fido2UserInterfaceService}. + * The user interface is implemented as a popout and the service uses the browser's messaging API to communicate with it. + */ export class BrowserFido2UserInterfaceService implements Fido2UserInterfaceServiceAbstraction { constructor(private popupUtilsService: PopupUtilsService) {} @@ -188,12 +192,6 @@ export class BrowserFido2UserInterfaceSession implements Fido2UserInterfaceSessi }); } - fallbackRequested = false; - - get aborted() { - return this.abortController.signal.aborted; - } - async pickCredential({ cipherIds, userVerification, diff --git a/apps/browser/src/vault/fido2/content/content-script.ts b/apps/browser/src/vault/fido2/content/content-script.ts index eb266aff4d07..bf147c4b58fa 100644 --- a/apps/browser/src/vault/fido2/content/content-script.ts +++ b/apps/browser/src/vault/fido2/content/content-script.ts @@ -20,10 +20,11 @@ function initializeFido2ContentScript(isFido2FeatureEnabled: boolean) { const messenger = Messenger.forDOMCommunication(window); messenger.handler = async (message, abortController) => { + const requestId = Date.now().toString(); const abortHandler = () => chrome.runtime.sendMessage({ command: "fido2AbortRequest", - abortedRequestId: message.metadata.requestId, + abortedRequestId: requestId, }); abortController.signal.addEventListener("abort", abortHandler); @@ -33,7 +34,7 @@ function initializeFido2ContentScript(isFido2FeatureEnabled: boolean) { { command: "fido2RegisterCredentialRequest", data: message.data, - requestId: message.metadata.requestId, + requestId: requestId, }, (response) => { if (response.error !== undefined) { @@ -55,7 +56,7 @@ function initializeFido2ContentScript(isFido2FeatureEnabled: boolean) { { command: "fido2GetCredentialRequest", data: message.data, - requestId: message.metadata.requestId, + requestId: requestId, }, (response) => { if (response.error !== undefined) { diff --git a/apps/browser/src/vault/fido2/content/messaging/messenger.spec.ts b/apps/browser/src/vault/fido2/content/messaging/messenger.spec.ts index 6d3ffe50effb..505682d997d8 100644 --- a/apps/browser/src/vault/fido2/content/messaging/messenger.spec.ts +++ b/apps/browser/src/vault/fido2/content/messaging/messenger.spec.ts @@ -1,5 +1,3 @@ -import { Subject } from "rxjs"; - import { Utils } from "@bitwarden/common/platform/misc/utils"; import { Message } from "./message"; @@ -12,6 +10,9 @@ describe("Messenger", () => { let handlerB: TestMessageHandler; beforeEach(() => { + // jest does not support MessageChannel + window.MessageChannel = MockMessageChannel as any; + const channelPair = new TestChannelPair(); messengerA = new Messenger(channelPair.channelA); messengerB = new Messenger(channelPair.channelB); @@ -81,19 +82,16 @@ class TestChannelPair { readonly channelB: Channel; constructor() { - const subjectA = new Subject(); - const subjectB = new Subject(); + const broadcastChannel = new MockMessageChannel(); this.channelA = { - messages$: subjectA, - postMessage: (message) => { - subjectB.next(message); - }, + addEventListener: (listener) => (broadcastChannel.port1.onmessage = listener), + postMessage: (message, port) => broadcastChannel.port1.postMessage(message, port), }; this.channelB = { - messages$: subjectB, - postMessage: (message) => subjectA.next(message), + addEventListener: (listener) => (broadcastChannel.port2.onmessage = listener), + postMessage: (message, port) => broadcastChannel.port2.postMessage(message, port), }; } } @@ -129,3 +127,28 @@ class TestMessageHandler { return received; } } + +class MockMessageChannel { + port1 = new MockMessagePort(); + port2 = new MockMessagePort(); + + constructor() { + this.port1.remotePort = this.port2; + this.port2.remotePort = this.port1; + } +} + +class MockMessagePort { + onmessage: ((ev: MessageEvent) => any) | null; + remotePort: MockMessagePort; + + postMessage(message: T, port?: MessagePort) { + this.remotePort.onmessage( + new MessageEvent("message", { data: message, ports: port ? [port] : [] }) + ); + } + + close() { + // Do nothing + } +} diff --git a/apps/browser/src/vault/fido2/content/messaging/messenger.ts b/apps/browser/src/vault/fido2/content/messaging/messenger.ts index 103a0c23191f..aeb835e2d5f7 100644 --- a/apps/browser/src/vault/fido2/content/messaging/messenger.ts +++ b/apps/browser/src/vault/fido2/content/messaging/messenger.ts @@ -1,127 +1,130 @@ -import { concatMap, filter, firstValueFrom, Observable } from "rxjs"; - import { Message, MessageType } from "./message"; const SENDER = "bitwarden-webauthn"; -type PostMessageFunction = (message: MessageWithMetadata) => void; +type PostMessageFunction = (message: MessageWithMetadata, remotePort: MessagePort) => void; export type Channel = { - messages$: Observable; + addEventListener: (listener: (message: MessageEvent) => void) => void; postMessage: PostMessageFunction; }; -export type Metadata = { SENDER: typeof SENDER; requestId: string }; -export type MessageWithMetadata = Message & { metadata: Metadata }; +export type Metadata = { SENDER: typeof SENDER }; +export type MessageWithMetadata = Message & Metadata; type Handler = ( message: MessageWithMetadata, abortController?: AbortController ) => Promise; -// TODO: This class probably duplicates functionality but I'm not especially familiar with -// the inner workings of the browser extension yet. -// If you see this in a code review please comment on it! - +/** + * A class that handles communication between the page and content script. It converts + * the browser's broadcasting API into a request/response API with support for seamlessly + * handling aborts and exceptions across separate execution contexts. + */ export class Messenger { + /** + * Creates a messenger that uses the browser's `window.postMessage` API to initiate + * requests in the content script. Every request will then create it's own + * `MessageChannel` through which all subsequent communication will be sent through. + * + * @param window the window object to use for communication + * @returns a `Messenger` instance + */ static forDOMCommunication(window: Window) { const windowOrigin = window.location.origin; return new Messenger({ - postMessage: (message) => window.postMessage(message, windowOrigin), - messages$: new Observable((subscriber) => { - const eventListener = (event: MessageEvent) => { + postMessage: (message, port) => window.postMessage(message, windowOrigin, [port]), + addEventListener: (listener) => + window.addEventListener("message", (event: MessageEvent) => { if (event.origin !== windowOrigin) { return; } - subscriber.next(event.data); - }; - - window.addEventListener("message", eventListener); - - return () => window.removeEventListener("message", eventListener); - }), + listener(event as MessageEvent); + }), }); } + /** + * The handler that will be called when a message is recieved. The handler should return + * a promise that resolves to the response message. If the handler throws an error, the + * error will be sent back to the sender. + */ handler?: Handler; - private abortControllers = new Map(); - - constructor(private channel: Channel) { - this.channel.messages$ - .pipe( - filter((message) => message?.metadata?.SENDER === SENDER), - concatMap(async (message) => { - if (this.handler === undefined) { - return; - } - const abortController = new AbortController(); - this.abortControllers.set(message.metadata.requestId, abortController); - - try { - const handlerResponse = await this.handler(message, abortController); - - if (handlerResponse === undefined) { - return; - } - - const metadata: Metadata = { SENDER, requestId: message.metadata.requestId }; - this.channel.postMessage({ ...handlerResponse, metadata }); - } catch (error) { - const metadata: Metadata = { SENDER, requestId: message.metadata.requestId }; - this.channel.postMessage({ - type: MessageType.ErrorResponse, - metadata, - error: JSON.stringify(error, Object.getOwnPropertyNames(error)), - }); - } finally { - this.abortControllers.delete(message.metadata.requestId); - } - }) - ) - .subscribe(); + constructor(private broadcastChannel: Channel) { + this.broadcastChannel.addEventListener(async (event) => { + if (this.handler === undefined) { + return; + } - this.channel.messages$.subscribe((message) => { - if (message.type !== MessageType.AbortRequest) { + const message = event.data; + const port = event.ports?.[0]; + if (message?.SENDER !== SENDER || message == null || port == null) { return; } - this.abortControllers.get(message.abortedRequestId)?.abort(); + const abortController = new AbortController(); + port.onmessage = (event: MessageEvent) => { + if (event.data.type === MessageType.AbortRequest) { + abortController.abort(); + } + }; + + try { + const handlerResponse = await this.handler(message, abortController); + port.postMessage({ ...handlerResponse, SENDER }); + } catch (error) { + port.postMessage({ + SENDER, + type: MessageType.ErrorResponse, + error: JSON.stringify(error, Object.getOwnPropertyNames(error)), + }); + } finally { + port.close(); + } }); } + /** + * Sends a request to the content script and returns the response. + * AbortController signals will be forwarded to the content script. + * + * @param request data to send to the content script + * @param abortController the abort controller that might be used to abort the request + * @returns the response from the content script + */ async request(request: Message, abortController?: AbortController): Promise { - const requestId = Date.now().toString(); - const metadata: Metadata = { SENDER, requestId }; - - const promise = firstValueFrom( - this.channel.messages$.pipe( - filter( - (m) => m != undefined && m.metadata?.requestId === requestId && m.type !== request.type - ) - ) - ); - - const abortListener = () => - this.channel.postMessage({ - metadata: { SENDER, requestId: `${requestId}-abort` }, - type: MessageType.AbortRequest, - abortedRequestId: requestId, + const requestChannel = new MessageChannel(); + const { port1: localPort, port2: remotePort } = requestChannel; + + try { + const promise = new Promise((resolve) => { + localPort.onmessage = (event: MessageEvent) => resolve(event.data); }); - abortController?.signal.addEventListener("abort", abortListener); - this.channel.postMessage({ ...request, metadata }); + const abortListener = () => + localPort.postMessage({ + metadata: { SENDER }, + type: MessageType.AbortRequest, + }); + abortController?.signal.addEventListener("abort", abortListener); - const response = await promise; - abortController?.signal.removeEventListener("abort", abortListener); + this.broadcastChannel.postMessage({ ...request, SENDER }, remotePort); + const response = await promise; - if (response.type === MessageType.ErrorResponse) { - const error = new Error(); - Object.assign(error, JSON.parse(response.error)); - throw error; - } + abortController?.signal.removeEventListener("abort", abortListener); - return response; + if (response.type === MessageType.ErrorResponse) { + const error = new Error(); + Object.assign(error, JSON.parse(response.error)); + throw error; + } + + return response; + } finally { + localPort.close(); + } } } diff --git a/apps/browser/src/vault/fido2/content/page-script.ts b/apps/browser/src/vault/fido2/content/page-script.ts index 1194e0a4e4c3..103b959c59b4 100644 --- a/apps/browser/src/vault/fido2/content/page-script.ts +++ b/apps/browser/src/vault/fido2/content/page-script.ts @@ -1,6 +1,6 @@ import { FallbackRequestedError } from "@bitwarden/common/vault/abstractions/fido2/fido2-client.service.abstraction"; -import { WebauthnUtils } from "../../../browser/webauthn-utils"; +import { WebauthnUtils } from "../webauthn-utils"; import { MessageType } from "./messaging/message"; import { Messenger } from "./messaging/messenger"; diff --git a/apps/browser/src/browser/webauthn-utils.ts b/apps/browser/src/vault/fido2/webauthn-utils.ts similarity index 100% rename from apps/browser/src/browser/webauthn-utils.ts rename to apps/browser/src/vault/fido2/webauthn-utils.ts diff --git a/libs/common/src/vault/abstractions/fido2/fido2-authenticator.service.abstraction.ts b/libs/common/src/vault/abstractions/fido2/fido2-authenticator.service.abstraction.ts index 521c332344ca..04ec7123afde 100644 --- a/libs/common/src/vault/abstractions/fido2/fido2-authenticator.service.abstraction.ts +++ b/libs/common/src/vault/abstractions/fido2/fido2-authenticator.service.abstraction.ts @@ -1,8 +1,17 @@ +/** + * This class represents an abstraction of the WebAuthn Authenticator model as described by W3C: + * https://www.w3.org/TR/webauthn-3/#sctn-authenticator-model + * + * The authenticator provides key management and cryptographic signatures. + */ export abstract class Fido2AuthenticatorService { /** - * Create and save a new credential + * Create and save a new credential as described in: + * https://www.w3.org/TR/webauthn-3/#sctn-op-make-cred * - * @return {Uint8Array} Attestation object + * @param params Parameters for creating a new credential + * @param abortController An AbortController that can be used to abort the operation. + * @returns A promise that resolves with the new credential and an attestation signature. **/ makeCredential: ( params: Fido2AuthenticatorMakeCredentialsParams, @@ -10,7 +19,12 @@ export abstract class Fido2AuthenticatorService { ) => Promise; /** - * Generate an assertion using an existing credential + * Generate an assertion using an existing credential as describe in: + * https://www.w3.org/TR/webauthn-3/#sctn-op-get-assertion + * + * @param params Parameters for generating an assertion + * @param abortController An AbortController that can be used to abort the operation. + * @returns A promise that resolves with the asserted credential and an assertion signature. */ getAssertion: ( params: Fido2AuthenticatorGetAssertionParams, @@ -46,7 +60,6 @@ export interface PublicKeyCredentialDescriptor { /** * Parameters for {@link Fido2AuthenticatorService.makeCredential} * - * @note * This interface represents the input parameters described in * https://www.w3.org/TR/webauthn-3/#sctn-op-make-cred */ @@ -97,6 +110,12 @@ export interface Fido2AuthenticatorMakeCredentialResult { publicKeyAlgorithm: number; } +/** + * Parameters for {@link Fido2AuthenticatorService.getAssertion} + + * This interface represents the input parameters described in + * https://www.w3.org/TR/webauthn-3/#sctn-op-get-assertion + */ export interface Fido2AuthenticatorGetAssertionParams { /** The caller’s RP ID, as determined by the user agent and the client. */ rpId: string; diff --git a/libs/common/src/vault/abstractions/fido2/fido2-client.service.abstraction.ts b/libs/common/src/vault/abstractions/fido2/fido2-client.service.abstraction.ts index f888b5d68929..a87168e3ebcf 100644 --- a/libs/common/src/vault/abstractions/fido2/fido2-client.service.abstraction.ts +++ b/libs/common/src/vault/abstractions/fido2/fido2-client.service.abstraction.ts @@ -2,53 +2,118 @@ export const UserRequestedFallbackAbortReason = "UserRequestedFallback"; export type UserVerification = "discouraged" | "preferred" | "required"; +/** + * This class represents an abstraction of the WebAuthn Client as described by W3C: + * https://www.w3.org/TR/webauthn-3/#webauthn-client + * + * The WebAuthn Client is an intermediary entity typically implemented in the user agent + * (in whole, or in part). Conceptually, it underlies the Web Authentication API and embodies + * the implementation of the Web Authentication API's operations. + * + * It is responsible for both marshalling the inputs for the underlying authenticator operations, + * and for returning the results of the latter operations to the Web Authentication API's callers. + */ export abstract class Fido2ClientService { + /** + * Allows WebAuthn Relying Party scripts to request the creation of a new public key credential source. + * For more information please see: https://www.w3.org/TR/webauthn-3/#sctn-createCredential + * + * @param params The parameters for the credential creation operation. + * @param abortController An AbortController that can be used to abort the operation. + * @returns A promise that resolves with the new credential. + */ createCredential: ( params: CreateCredentialParams, abortController?: AbortController ) => Promise; + + /** + * Allows WebAuthn Relying Party scripts to discover and use an existing public key credential, with the user’s consent. + * Relying Party script can optionally specify some criteria to indicate what credential sources are acceptable to it. + * For more information please see: https://www.w3.org/TR/webauthn-3/#sctn-getAssertion + * + * @param params The parameters for the credential assertion operation. + * @param abortController An AbortController that can be used to abort the operation. + * @returns A promise that resolves with the asserted credential. + */ assertCredential: ( params: AssertCredentialParams, abortController?: AbortController ) => Promise; + isFido2FeatureEnabled: () => Promise; } +/** + * Parameters for creating a new credential. + */ export interface CreateCredentialParams { + /** The Relaying Parties origin, see: https://html.spec.whatwg.org/multipage/browsers.html#concept-origin */ origin: string; + /** + * A value which is true if and only if the caller’s environment settings object is same-origin with its ancestors. + * It is false if caller is cross-origin. + * */ sameOriginWithAncestors: boolean; + /** The Relying Party's preference for attestation conveyance */ attestation?: "direct" | "enterprise" | "indirect" | "none"; + /** The Relying Party's requirements of the authenticator used in the creation of the credential. */ authenticatorSelection?: { // authenticatorAttachment?: AuthenticatorAttachment; // not used requireResidentKey?: boolean; residentKey?: "discouraged" | "preferred" | "required"; userVerification?: UserVerification; }; + /** Challenge intended to be used for generating the newly created credential's attestation object. */ challenge: string; // b64 encoded + /** + * This member is intended for use by Relying Parties that wish to limit the creation of multiple credentials for + * the same account on a single authenticator. The client is requested to return an error if the new credential would + * be created on an authenticator that also contains one of the credentials enumerated in this parameter. + * */ excludeCredentials?: { id: string; // b64 encoded transports?: ("ble" | "hybrid" | "internal" | "nfc" | "usb")[]; type: "public-key"; }[]; + /** + * This member contains additional parameters requesting additional processing by the client and authenticator. + * Not currently supported. + **/ extensions?: { appid?: string; appidExclude?: string; credProps?: boolean; uvm?: boolean; }; + /** + * This member contains information about the desired properties of the credential to be created. + * The sequence is ordered from most preferred to least preferred. + * The client makes a best-effort to create the most preferred credential that it can. + */ pubKeyCredParams: PublicKeyCredentialParam[]; + /** Data about the Relying Party responsible for the request. */ rp: { id?: string; name: string; }; + /** Data about the user account for which the Relying Party is requesting attestation. */ user: { id: string; // b64 encoded displayName: string; }; + /** Forwarded to user interface */ fallbackSupported: boolean; + /** + * This member specifies a time, in milliseconds, that the caller is willing to wait for the call to complete. + * This is treated as a hint, and MAY be overridden by the client. + **/ timeout?: number; } +/** + * The result of creating a new credential. + */ export interface CreateCredentialResult { credentialId: string; clientDataJSON: string; @@ -58,6 +123,9 @@ export interface CreateCredentialResult { transports: string[]; } +/** + * Parameters for asserting a credential. + */ export interface AssertCredentialParams { allowedCredentialIds: string[]; rpId: string; @@ -69,6 +137,9 @@ export interface AssertCredentialParams { fallbackSupported: boolean; } +/** + * The result of asserting a credential. + */ export interface AssertCredentialResult { credentialId: string; clientDataJSON: string; @@ -77,11 +148,22 @@ export interface AssertCredentialResult { userHandle: string; } +/** + * A description of a key type and algorithm. + * + * @example { + * alg: -7, // ES256 + * type: "public-key" + * } + */ export interface PublicKeyCredentialParam { alg: number; type: "public-key"; } +/** + * Error thrown when the user requests a fallback to the browser's built-in WebAuthn implementation. + */ export class FallbackRequestedError extends Error { readonly fallbackRequested = true; constructor() { diff --git a/libs/common/src/vault/abstractions/fido2/fido2-user-interface.service.abstraction.ts b/libs/common/src/vault/abstractions/fido2/fido2-user-interface.service.abstraction.ts index 8c2ef2bb28fe..f1e18b4a3a78 100644 --- a/libs/common/src/vault/abstractions/fido2/fido2-user-interface.service.abstraction.ts +++ b/libs/common/src/vault/abstractions/fido2/fido2-user-interface.service.abstraction.ts @@ -1,15 +1,53 @@ +/** + * Parameters used to ask the user to confirm the creation of a new credential. + */ export interface NewCredentialParams { + /** + * The name of the credential. + */ credentialName: string; + + /** + * The name of the user. + */ userName: string; + + /** + * Whether or not the user must be verified before completing the operation. + */ userVerification: boolean; } +/** + * Parameters used to ask the user to pick a credential from a list of existing credentials. + */ export interface PickCredentialParams { + /** + * The IDs of the credentials that the user can pick from. + */ cipherIds: string[]; + + /** + * Whether or not the user must be verified before completing the operation. + */ userVerification: boolean; } +/** + * This service is used to provide a user interface with which the user can control FIDO2 operations. + * It acts as a way to remote control the user interface from the background script. + * + * The service is session based and is intended to be used by the FIDO2 authenticator to open a window, + * and then use this window to ask the user for input and/or display messages to the user. + */ export abstract class Fido2UserInterfaceService { + /** + * Creates a new session. + * Note: This will not necessarily open a window until it is needed to request something from the user. + * + * @param fallbackSupported Whether or not the browser natively supports WebAuthn. + * @param abortController An abort controller that can be used to cancel/close the session. + */ newSession: ( fallbackSupported: boolean, abortController?: AbortController @@ -17,22 +55,48 @@ export abstract class Fido2UserInterfaceService { } export abstract class Fido2UserInterfaceSession { - fallbackRequested = false; - aborted = false; - + /** + * Ask the user to pick a credential from a list of existing credentials. + * + * @param params The parameters to use when asking the user to pick a credential. + * @param abortController An abort controller that can be used to cancel/close the session. + * @returns The ID of the cipher that contains the credentials the user picked. + */ pickCredential: ( - params: PickCredentialParams, - abortController?: AbortController + params: PickCredentialParams ) => Promise<{ cipherId: string; userVerified: boolean }>; + + /** + * Ask the user to confirm the creation of a new credential. + * + * @param params The parameters to use when asking the user to confirm the creation of a new credential. + * @param abortController An abort controller that can be used to cancel/close the session. + * @returns The ID of the cipher where the new credential should be saved. + */ confirmNewCredential: ( - params: NewCredentialParams, - abortController?: AbortController + params: NewCredentialParams ) => Promise<{ cipherId: string; userVerified: boolean }>; + + /** + * Make sure that the vault is unlocked. + * This will open a window and ask the user to login or unlock the vault if necessary. + */ ensureUnlockedVault: () => Promise; - informExcludedCredential: ( - existingCipherIds: string[], - abortController?: AbortController - ) => Promise; + + /** + * Inform the user that the operation was cancelled because their vault contains excluded credentials. + * + * @param existingCipherIds The IDs of the excluded credentials. + */ + informExcludedCredential: (existingCipherIds: string[]) => Promise; + + /** + * Inform the user that the operation was cancelled because their vault does not contain any useable credentials. + */ informCredentialNotFound: (abortController?: AbortController) => Promise; + + /** + * Close the session, including any windows that may be open. + */ close: () => void; } diff --git a/libs/common/src/vault/services/fido2/fido2-authenticator.service.spec.ts b/libs/common/src/vault/services/fido2/fido2-authenticator.service.spec.ts index 0e030630d02a..3b91760bf301 100644 --- a/libs/common/src/vault/services/fido2/fido2-authenticator.service.spec.ts +++ b/libs/common/src/vault/services/fido2/fido2-authenticator.service.spec.ts @@ -207,16 +207,13 @@ describe("FidoAuthenticatorService", () => { userVerified: userVerification, }); - await authenticator.makeCredential(params, new AbortController()); + await authenticator.makeCredential(params); - expect(userInterfaceSession.confirmNewCredential).toHaveBeenCalledWith( - { - credentialName: params.rpEntity.name, - userName: params.userEntity.displayName, - userVerification, - } as NewCredentialParams, - expect.anything() - ); + expect(userInterfaceSession.confirmNewCredential).toHaveBeenCalledWith({ + credentialName: params.rpEntity.name, + userName: params.userEntity.displayName, + userVerification, + } as NewCredentialParams); }); } diff --git a/libs/common/src/vault/services/fido2/fido2-authenticator.service.ts b/libs/common/src/vault/services/fido2/fido2-authenticator.service.ts index e322bf7e752f..42fb619a6156 100644 --- a/libs/common/src/vault/services/fido2/fido2-authenticator.service.ts +++ b/libs/common/src/vault/services/fido2/fido2-authenticator.service.ts @@ -31,8 +31,10 @@ export const AAGUID = new Uint8Array([ const KeyUsages: KeyUsage[] = ["sign"]; /** - * Bitwarden implementation of the WebAuthn Authenticator Model described by W3C + * Bitwarden implementation of the WebAuthn Authenticator Model as described by W3C * https://www.w3.org/TR/webauthn-3/#sctn-authenticator-model + * + * It is highly recommended that the W3C specification is used a reference when reading this code. */ export class Fido2AuthenticatorService implements Fido2AuthenticatorServiceAbstraction { constructor( @@ -41,6 +43,7 @@ export class Fido2AuthenticatorService implements Fido2AuthenticatorServiceAbstr private syncService: SyncService, private logService?: LogService ) {} + async makeCredential( params: Fido2AuthenticatorMakeCredentialsParams, abortController?: AbortController @@ -92,7 +95,7 @@ export class Fido2AuthenticatorService implements Fido2AuthenticatorServiceAbstr this.logService?.info( `[Fido2Authenticator] Aborting due to excluded credential found in vault.` ); - await userInterfaceSession.informExcludedCredential(existingCipherIds, abortController); + await userInterfaceSession.informExcludedCredential(existingCipherIds); throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.NotAllowed); } @@ -101,14 +104,11 @@ export class Fido2AuthenticatorService implements Fido2AuthenticatorServiceAbstr let keyPair: CryptoKeyPair; let userVerified = false; let credentialId: string; - const response = await userInterfaceSession.confirmNewCredential( - { - credentialName: params.rpEntity.name, - userName: params.userEntity.displayName, - userVerification: params.requireUserVerification, - }, - abortController - ); + const response = await userInterfaceSession.confirmNewCredential({ + credentialName: params.rpEntity.name, + userName: params.userEntity.displayName, + userVerification: params.requireUserVerification, + }); const cipherId = response.cipherId; userVerified = response.userVerified; diff --git a/libs/common/src/vault/services/fido2/fido2-client.service.ts b/libs/common/src/vault/services/fido2/fido2-client.service.ts index b9432dae14c4..0057e0c0c087 100644 --- a/libs/common/src/vault/services/fido2/fido2-client.service.ts +++ b/libs/common/src/vault/services/fido2/fido2-client.service.ts @@ -27,6 +27,12 @@ import { import { isValidRpId } from "./domain-utils"; import { Fido2Utils } from "./fido2-utils"; +/** + * Bitwarden implementation of the Web Authentication API as described by W3C + * https://www.w3.org/TR/webauthn-3/#sctn-api + * + * It is highly recommended that the W3C specification is used a reference when reading this code. + */ export class Fido2ClientService implements Fido2ClientServiceAbstraction { constructor( private authenticator: Fido2AuthenticatorService, @@ -81,10 +87,12 @@ export class Fido2ClientService implements Fido2ClientServiceAbstraction { let credTypesAndPubKeyAlgs: PublicKeyCredentialParam[]; if (params.pubKeyCredParams?.length > 0) { + // Filter out all unsupported algorithms credTypesAndPubKeyAlgs = params.pubKeyCredParams.filter( (kp) => kp.alg === -7 && kp.type === "public-key" ); } else { + // Assign default algorithms credTypesAndPubKeyAlgs = [ { alg: -7, type: "public-key" }, { alg: -257, type: "public-key" }, @@ -109,6 +117,13 @@ export class Fido2ClientService implements Fido2ClientServiceAbstraction { const clientDataJSON = JSON.stringify(collectedClientData); const clientDataJSONBytes = Utils.fromByteStringToArray(clientDataJSON); const clientDataHash = await crypto.subtle.digest({ name: "SHA-256" }, clientDataJSONBytes); + const makeCredentialParams = mapToMakeCredentialParams({ + params, + credTypesAndPubKeyAlgs, + clientDataHash, + }); + + // Set timeout before invoking authenticator if (abortController.signal.aborted) { this.logService?.info(`[Fido2Client] Aborted with AbortController`); throw new DOMException(undefined, "AbortError"); @@ -118,34 +133,7 @@ export class Fido2ClientService implements Fido2ClientServiceAbstraction { params.authenticatorSelection?.userVerification, params.timeout ); - const excludeCredentialDescriptorList: PublicKeyCredentialDescriptor[] = - params.excludeCredentials?.map((credential) => ({ - id: Fido2Utils.stringToBuffer(credential.id), - transports: credential.transports, - type: credential.type, - })) ?? []; - - const makeCredentialParams: Fido2AuthenticatorMakeCredentialsParams = { - requireResidentKey: - params.authenticatorSelection?.residentKey === "required" || - params.authenticatorSelection?.residentKey === "preferred" || - (params.authenticatorSelection?.residentKey === undefined && - params.authenticatorSelection?.requireResidentKey === true), - requireUserVerification: params.authenticatorSelection?.userVerification === "required", - enterpriseAttestationPossible: params.attestation === "enterprise", - excludeCredentialDescriptorList, - credTypesAndPubKeyAlgs, - hash: clientDataHash, - rpEntity: { - id: rpId, - name: params.rp.name, - }, - userEntity: { - id: Fido2Utils.stringToBuffer(params.user.id), - displayName: params.user.displayName, - }, - fallbackSupported: params.fallbackSupported, - }; + let makeCredentialResult; try { makeCredentialResult = await this.authenticator.makeCredential( @@ -238,6 +226,7 @@ export class Fido2ClientService implements Fido2ClientServiceAbstraction { const clientDataJSON = JSON.stringify(collectedClientData); const clientDataJSONBytes = Utils.fromByteStringToArray(clientDataJSON); const clientDataHash = await crypto.subtle.digest({ name: "SHA-256" }, clientDataJSONBytes); + const getAssertionParams = mapToGetAssertionParams({ params, clientDataHash }); if (abortController.signal.aborted) { this.logService?.info(`[Fido2Client] Aborted with AbortController`); @@ -246,21 +235,6 @@ export class Fido2ClientService implements Fido2ClientServiceAbstraction { const timeout = setAbortTimeout(abortController, params.userVerification, params.timeout); - const allowCredentialDescriptorList: PublicKeyCredentialDescriptor[] = - params.allowedCredentialIds.map((id) => ({ - id: Fido2Utils.stringToBuffer(id), - type: "public-key", - })); - - const getAssertionParams: Fido2AuthenticatorGetAssertionParams = { - rpId, - requireUserVerification: params.userVerification === "required", - hash: clientDataHash, - allowCredentialDescriptorList, - extensions: {}, - fallbackSupported: params.fallbackSupported, - }; - let getAssertionResult; try { getAssertionResult = await this.authenticator.getAssertion( @@ -343,3 +317,73 @@ function setAbortTimeout( return window.setTimeout(() => abortController.abort(), clampedTimeout); } + +/** + * Convert data gathered by the WebAuthn Client to a format that can be used by the authenticator. + */ +function mapToMakeCredentialParams({ + params, + credTypesAndPubKeyAlgs, + clientDataHash, +}: { + params: CreateCredentialParams; + credTypesAndPubKeyAlgs: PublicKeyCredentialParam[]; + clientDataHash: ArrayBuffer; +}): Fido2AuthenticatorMakeCredentialsParams { + const excludeCredentialDescriptorList: PublicKeyCredentialDescriptor[] = + params.excludeCredentials?.map((credential) => ({ + id: Fido2Utils.stringToBuffer(credential.id), + transports: credential.transports, + type: credential.type, + })) ?? []; + + const requireResidentKey = + params.authenticatorSelection?.residentKey === "required" || + params.authenticatorSelection?.residentKey === "preferred" || + (params.authenticatorSelection?.residentKey === undefined && + params.authenticatorSelection?.requireResidentKey === true); + + return { + requireResidentKey, + requireUserVerification: params.authenticatorSelection?.userVerification === "required", + enterpriseAttestationPossible: params.attestation === "enterprise", + excludeCredentialDescriptorList, + credTypesAndPubKeyAlgs, + hash: clientDataHash, + rpEntity: { + id: params.rp.id, + name: params.rp.name, + }, + userEntity: { + id: Fido2Utils.stringToBuffer(params.user.id), + displayName: params.user.displayName, + }, + fallbackSupported: params.fallbackSupported, + }; +} + +/** + * Convert data gathered by the WebAuthn Client to a format that can be used by the authenticator. + */ +function mapToGetAssertionParams({ + params, + clientDataHash, +}: { + params: AssertCredentialParams; + clientDataHash: ArrayBuffer; +}): Fido2AuthenticatorGetAssertionParams { + const allowCredentialDescriptorList: PublicKeyCredentialDescriptor[] = + params.allowedCredentialIds.map((id) => ({ + id: Fido2Utils.stringToBuffer(id), + type: "public-key", + })); + + return { + rpId: params.rpId, + requireUserVerification: params.userVerification === "required", + hash: clientDataHash, + allowCredentialDescriptorList, + extensions: {}, + fallbackSupported: params.fallbackSupported, + }; +} diff --git a/libs/common/src/vault/services/fido2/fido2-utils.ts b/libs/common/src/vault/services/fido2/fido2-utils.ts index 8d4c59c68875..a2de13755071 100644 --- a/libs/common/src/vault/services/fido2/fido2-utils.ts +++ b/libs/common/src/vault/services/fido2/fido2-utils.ts @@ -20,7 +20,7 @@ export class Fido2Utils { } /** Utility function to identify type of bufferSource. Necessary because of differences between runtimes */ - static isArrayBuffer(bufferSource: BufferSource): bufferSource is ArrayBuffer { + private static isArrayBuffer(bufferSource: BufferSource): bufferSource is ArrayBuffer { return bufferSource instanceof ArrayBuffer || bufferSource.buffer === undefined; } } diff --git a/libs/common/src/vault/services/fido2/noop-fido2-user-interface.service.ts b/libs/common/src/vault/services/fido2/noop-fido2-user-interface.service.ts index 4ed14831e228..440bd5190027 100644 --- a/libs/common/src/vault/services/fido2/noop-fido2-user-interface.service.ts +++ b/libs/common/src/vault/services/fido2/noop-fido2-user-interface.service.ts @@ -3,11 +3,12 @@ import { Fido2UserInterfaceSession, } from "../../abstractions/fido2/fido2-user-interface.service.abstraction"; +/** + * Noop implementation of the {@link Fido2UserInterfaceService}. + * This implementation does not provide any user interface. + */ export class Fido2UserInterfaceService implements Fido2UserInterfaceServiceAbstraction { - newSession( - fallbackSupported: boolean, - abortController?: AbortController - ): Promise { + newSession(): Promise { throw new Error("Not implemented exception"); } }