1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-04 18:53:20 +00:00

[PM-3905] Address PR feedback v2 (#6322)

* [PM-3905] chore: move webauthn utils to vault

* [PM-3905] chore: make static function private

* [PM-3905] chore: add documentation to user interface classes

* [PM-3905] chore: clean up unused abort controllers

* [PM-3905] chore: add documentation to fido2 client and authenticatio

* [PM-3905] chore: extract create credential params mapping to separate function

* [PM-3905] chore: extract get assertion params mapping to separate function

* [PM-3905] chore: assign requireResidentKey as separate variable

* [PM-3905] feat: started rewrite of messenger

Basic message sending implemented, now using message channels instead of rxjs

* [PM-3905] feat: complete rewrite of messenger

* [PM-3905] chore: clarify why we're assigning to window

* [PM-3905] feat: clean up tests

* [PM-3905] docs: document messenger class

* [PM-3905] feat: remove `requestId` which is no longer needed

* [PM-3905] feat: simplify message structure

* [PM-3905] chore: typo

* [PM-3905] chore: clean up old file

* [PM-3905] chore: tweak doc comment

* [PM-3905] feat: create separate class for managing aborts

* [PM-3905] chore: move abort manager to vault
This commit is contained in:
Andreas Coroiu
2023-09-21 16:44:03 +02:00
committed by GitHub
parent 562e228745
commit e14caca728
16 changed files with 448 additions and 200 deletions

View File

@@ -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<string, AbortController>();
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;
}
}

View File

@@ -0,0 +1,21 @@
type Runner<T> = (abortController: AbortController) => Promise<T>;
/**
* 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<string, AbortController>();
runWithAbortController<T>(id: string, runner: Runner<T>): Promise<T> {
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();
}
}

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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<MessageWithMetadata>();
const subjectB = new Subject<MessageWithMetadata>();
const broadcastChannel = new MockMessageChannel<MessageWithMetadata>();
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<T> {
port1 = new MockMessagePort<T>();
port2 = new MockMessagePort<T>();
constructor() {
this.port1.remotePort = this.port2;
this.port2.remotePort = this.port1;
}
}
class MockMessagePort<T> {
onmessage: ((ev: MessageEvent<T>) => any) | null;
remotePort: MockMessagePort<T>;
postMessage(message: T, port?: MessagePort) {
this.remotePort.onmessage(
new MessageEvent("message", { data: message, ports: port ? [port] : [] })
);
}
close() {
// Do nothing
}
}

View File

@@ -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<MessageWithMetadata>;
addEventListener: (listener: (message: MessageEvent<MessageWithMetadata>) => 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<Message | undefined>;
// 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<MessageWithMetadata>) => {
postMessage: (message, port) => window.postMessage(message, windowOrigin, [port]),
addEventListener: (listener) =>
window.addEventListener("message", (event: MessageEvent<unknown>) => {
if (event.origin !== windowOrigin) {
return;
}
subscriber.next(event.data);
};
window.addEventListener("message", eventListener);
return () => window.removeEventListener("message", eventListener);
}),
listener(event as MessageEvent<MessageWithMetadata>);
}),
});
}
/**
* 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<string, AbortController>();
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();
this.channel.messages$.subscribe((message) => {
if (message.type !== MessageType.AbortRequest) {
constructor(private broadcastChannel: Channel) {
this.broadcastChannel.addEventListener(async (event) => {
if (this.handler === undefined) {
return;
}
this.abortControllers.get(message.abortedRequestId)?.abort();
const message = event.data;
const port = event.ports?.[0];
if (message?.SENDER !== SENDER || message == null || port == null) {
return;
}
const abortController = new AbortController();
port.onmessage = (event: MessageEvent<MessageWithMetadata>) => {
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<Message> {
const requestId = Date.now().toString();
const metadata: Metadata = { SENDER, requestId };
const requestChannel = new MessageChannel();
const { port1: localPort, port2: remotePort } = requestChannel;
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,
try {
const promise = new Promise<Message>((resolve) => {
localPort.onmessage = (event: MessageEvent<MessageWithMetadata>) => 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);
if (response.type === MessageType.ErrorResponse) {
const error = new Error();
Object.assign(error, JSON.parse(response.error));
throw error;
}
return response;
} finally {
localPort.close();
}
return response;
}
}

View File

@@ -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";

View File

@@ -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<Fido2AuthenticatorMakeCredentialResult>;
/**
* 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 callers RP ID, as determined by the user agent and the client. */
rpId: string;

View File

@@ -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<CreateCredentialResult>;
/**
* Allows WebAuthn Relying Party scripts to discover and use an existing public key credential, with the users 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<AssertCredentialResult>;
isFido2FeatureEnabled: () => Promise<boolean>;
}
/**
* 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 callers 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() {

View File

@@ -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<void>;
informExcludedCredential: (
existingCipherIds: string[],
abortController?: AbortController
) => Promise<void>;
/**
* 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<void>;
/**
* Inform the user that the operation was cancelled because their vault does not contain any useable credentials.
*/
informCredentialNotFound: (abortController?: AbortController) => Promise<void>;
/**
* Close the session, including any windows that may be open.
*/
close: () => void;
}

View File

@@ -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);
});
}

View File

@@ -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
@@ -93,7 +96,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);
}
@@ -102,14 +105,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;

View File

@@ -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,
};
}

View File

@@ -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;
}
}

View File

@@ -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<Fido2UserInterfaceSession> {
newSession(): Promise<Fido2UserInterfaceSession> {
throw new Error("Not implemented exception");
}
}