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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
21
apps/browser/src/vault/background/abort-manager.ts
Normal file
21
apps/browser/src/vault/background/abort-manager.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 caller’s RP ID, as determined by the user agent and the client. */
|
||||
rpId: string;
|
||||
|
||||
@@ -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 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<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 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() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user