[PM-4012] Vault Timing out on Chrome and Edge breaks passkeys until page is reloaded (#6845)

* changed content script injection strategy

* added persistent connection and reinjection of the content script

* cleanup resources on disconnect

* cleanup resources on disconnect

* concluded messanger event listeners cleanup and added unit tests

* Switched to use browser api add listener instead of navtive apis

* renamed cleanup to destroy and added reconnect and disconnect command functions

* refactored to use foreach and check for only https urls

* refactored the content script to only load the page script if it currently doesn't extist of the page, and if it does sends a reconnect command to the page-script to replace the native webauthn methods

* updated unit test

* removed memoized logic

* moved the send disconect command to the messenger

* updated unit test

* test messenger handler

* [PM-4012] fix: add `senderId` to messenger

* destroy pending requets

* cleaned up page script and terminated pending request

* fixed cannot read properties of undefined

* rearranged functions, renamed misspelled words, and created test

* mocked EventTarget as there are issues on jest for listeners getting the events

* Return fall back error instead

* Update apps/browser/src/vault/fido2/content/content-script.ts

Co-authored-by: Cesar Gonzalez <cesar.a.gonzalezcs@gmail.com>

* Update apps/browser/src/vault/fido2/content/messaging/messenger.ts

Co-authored-by: Cesar Gonzalez <cesar.a.gonzalezcs@gmail.com>

* removed whitespace

---------

Co-authored-by: Andreas Coroiu <andreas.coroiu@gmail.com>
Co-authored-by: Cesar Gonzalez <cesar.a.gonzalezcs@gmail.com>
This commit is contained in:
SmithThe4th 2023-12-12 13:49:24 -05:00 committed by GitHub
parent 3e174fec81
commit f0cdcccf81
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 378 additions and 68 deletions

View File

@ -156,7 +156,9 @@ import { BrowserSendService } from "../services/browser-send.service";
import { BrowserSettingsService } from "../services/browser-settings.service";
import VaultTimeoutService from "../services/vault-timeout/vault-timeout.service";
import { BrowserFido2UserInterfaceService } from "../vault/fido2/browser-fido2-user-interface.service";
import { Fido2Service as Fido2ServiceAbstraction } from "../vault/services/abstractions/fido2.service";
import { BrowserFolderService } from "../vault/services/browser-folder.service";
import Fido2Service from "../vault/services/fido2.service";
import { VaultFilterService } from "../vault/services/vault-filter.service";
import CommandsBackground from "./commands.background";
@ -232,6 +234,7 @@ export default class MainBackground {
authRequestCryptoService: AuthRequestCryptoServiceAbstraction;
accountService: AccountServiceAbstraction;
globalStateProvider: GlobalStateProvider;
fido2Service: Fido2ServiceAbstraction;
// Passed to the popup for Safari to workaround issues with theming, downloading, etc.
backgroundWindow = window;
@ -597,6 +600,7 @@ export default class MainBackground {
this.messagingService,
);
this.fido2Service = new Fido2Service();
this.fido2UserInterfaceService = new BrowserFido2UserInterfaceService(this.authService);
this.fido2AuthenticatorService = new Fido2AuthenticatorService(
this.cipherService,
@ -645,6 +649,7 @@ export default class MainBackground {
this.messagingService,
this.logService,
this.configService,
this.fido2Service,
);
this.nativeMessagingBackground = new NativeMessagingBackground(
this.cryptoService,
@ -778,6 +783,8 @@ export default class MainBackground {
await this.idleBackground.init();
await this.webRequestBackground.init();
await this.fido2Service.init();
if (this.platformUtilsService.isFirefox() && !this.isPrivateMode) {
// Set Private Mode windows to the default icon - they do not share state with the background page
const privateWindows = await BrowserApi.getPrivateModeWindows();

View File

@ -20,6 +20,7 @@ import { BrowserStateService } from "../platform/services/abstractions/browser-s
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 { Fido2Service } from "../vault/services/abstractions/fido2.service";
import MainBackground from "./main.background";
@ -42,6 +43,7 @@ export default class RuntimeBackground {
private messagingService: MessagingService,
private logService: LogService,
private configService: ConfigServiceAbstraction,
private fido2Service: Fido2Service,
) {
// onInstalled listener must be wired up before anything else, so we do it in the ctor
chrome.runtime.onInstalled.addListener((details: any) => {
@ -257,6 +259,9 @@ export default class RuntimeBackground {
case "getClickedElementResponse":
this.platformUtilsService.copyToClipboard(msg.identifier, { window: window });
break;
case "triggerFido2ContentScriptInjection":
await this.fido2Service.injectFido2ContentScripts(sender);
break;
case "fido2AbortRequest":
this.abortManager.abort(msg.abortedRequestId);
break;

View File

@ -17,7 +17,10 @@
"content_scripts": [
{
"all_frames": true,
"js": ["content/trigger-autofill-script-injection.js", "content/fido2/content-script.js"],
"js": [
"content/trigger-autofill-script-injection.js",
"content/fido2/trigger-fido2-content-script-injection.js"
],
"matches": ["http://*/*", "https://*/*", "file:///*"],
"run_at": "document_start"
},

View File

@ -61,12 +61,28 @@ async function isLocationBitwardenVault(activeUserSettings: Record<string, any>)
return window.location.origin === activeUserSettings.serverConfig.environment.vault;
}
function initializeFido2ContentScript() {
const s = document.createElement("script");
s.src = chrome.runtime.getURL("content/fido2/page-script.js");
(document.head || document.documentElement).appendChild(s);
const messenger = Messenger.forDOMCommunication(window);
const messenger = Messenger.forDOMCommunication(window);
function injectPageScript() {
// Locate an existing page-script on the page
const existingPageScript = document.getElementById("bw-fido2-page-script");
// Inject the page-script if it doesn't exist
if (!existingPageScript) {
const s = document.createElement("script");
s.src = chrome.runtime.getURL("content/fido2/page-script.js");
s.id = "bw-fido2-page-script";
(document.head || document.documentElement).appendChild(s);
return;
}
// If the page-script already exists, send a reconnect message to the page-script
messenger.sendReconnectCommand();
}
function initializeFido2ContentScript() {
injectPageScript();
messenger.handler = async (message, abortController) => {
const requestId = Date.now().toString();
@ -78,7 +94,7 @@ function initializeFido2ContentScript() {
abortController.signal.addEventListener("abort", abortHandler);
if (message.type === MessageType.CredentialCreationRequest) {
return new Promise((resolve, reject) => {
return new Promise<Message | undefined>((resolve, reject) => {
const data: CreateCredentialParams = {
...message.data,
origin: window.location.origin,
@ -92,7 +108,7 @@ function initializeFido2ContentScript() {
requestId: requestId,
},
(response) => {
if (response.error !== undefined) {
if (response && response.error !== undefined) {
return reject(response.error);
}
@ -106,7 +122,7 @@ function initializeFido2ContentScript() {
}
if (message.type === MessageType.CredentialGetRequest) {
return new Promise((resolve, reject) => {
return new Promise<Message | undefined>((resolve, reject) => {
const data: AssertCredentialParams = {
...message.data,
origin: window.location.origin,
@ -120,7 +136,7 @@ function initializeFido2ContentScript() {
requestId: requestId,
},
(response) => {
if (response.error !== undefined) {
if (response && response.error !== undefined) {
return reject(response.error);
}
@ -155,6 +171,12 @@ async function run() {
}
initializeFido2ContentScript();
const port = chrome.runtime.connect({ name: "fido2ContentScriptReady" });
port.onDisconnect.addListener(() => {
// Cleanup the messenger and remove the event listener
messenger.destroy();
});
}
run();

View File

@ -11,6 +11,8 @@ export enum MessageType {
CredentialGetRequest,
CredentialGetResponse,
AbortRequest,
DisconnectRequest,
ReconnectRequest,
AbortResponse,
ErrorResponse,
}
@ -60,6 +62,14 @@ export type AbortRequest = {
abortedRequestId: string;
};
export type DisconnectRequest = {
type: MessageType.DisconnectRequest;
};
export type ReconnectRequest = {
type: MessageType.ReconnectRequest;
};
export type ErrorResponse = {
type: MessageType.ErrorResponse;
error: string;
@ -76,5 +86,7 @@ export type Message =
| CredentialGetRequest
| CredentialGetResponse
| AbortRequest
| DisconnectRequest
| ReconnectRequest
| AbortResponse
| ErrorResponse;

View File

@ -12,6 +12,12 @@ describe("Messenger", () => {
beforeEach(() => {
// jest does not support MessageChannel
window.MessageChannel = MockMessageChannel as any;
Object.defineProperty(window, "location", {
value: {
origin: "https://bitwarden.com",
},
writable: true,
});
const channelPair = new TestChannelPair();
messengerA = new Messenger(channelPair.channelA);
@ -27,7 +33,7 @@ describe("Messenger", () => {
const request = createRequest();
messengerA.request(request);
const received = handlerB.recieve();
const received = handlerB.receive();
expect(received.length).toBe(1);
expect(received[0].message).toMatchObject(request);
@ -37,7 +43,7 @@ describe("Messenger", () => {
const request = createRequest();
const response = createResponse();
const requestPromise = messengerA.request(request);
const received = handlerB.recieve();
const received = handlerB.receive();
received[0].respond(response);
const returned = await requestPromise;
@ -49,7 +55,7 @@ describe("Messenger", () => {
const request = createRequest();
const error = new Error("Test error");
const requestPromise = messengerA.request(request);
const received = handlerB.recieve();
const received = handlerB.receive();
received[0].reject(error);
@ -61,10 +67,60 @@ describe("Messenger", () => {
messengerA.request(createRequest(), abortController);
abortController.abort();
const received = handlerB.recieve();
const received = handlerB.receive();
expect(received[0].abortController.signal.aborted).toBe(true);
});
describe("destroy", () => {
beforeEach(() => {
/**
* In Jest's jsdom environment, there is an issue where event listeners are not
* triggered upon dispatching an event. This is a workaround to mock the EventTarget
*/
window.EventTarget = MockEventTarget as any;
});
it("should remove the message event listener", async () => {
const channelPair = new TestChannelPair();
const addEventListenerSpy = jest.spyOn(channelPair.channelA, "addEventListener");
const removeEventListenerSpy = jest.spyOn(channelPair.channelA, "removeEventListener");
messengerA = new Messenger(channelPair.channelA);
jest
.spyOn(messengerA as any, "sendDisconnectCommand")
.mockImplementation(() => Promise.resolve());
expect(addEventListenerSpy).toHaveBeenCalled();
await messengerA.destroy();
expect(removeEventListenerSpy).toHaveBeenCalled();
});
it("should dispatch the destroy event on messenger destruction", async () => {
const request = createRequest();
messengerA.request(request);
const dispatchEventSpy = jest.spyOn((messengerA as any).onDestroy, "dispatchEvent");
messengerA.destroy();
expect(dispatchEventSpy).toHaveBeenCalledWith(expect.any(Event));
});
it("should trigger onDestroyListener when the destroy event is dispatched", async () => {
const request = createRequest();
messengerA.request(request);
const onDestroyListener = jest.fn();
(messengerA as any).onDestroy.addEventListener("destroy", onDestroyListener);
messengerA.destroy();
expect(onDestroyListener).toHaveBeenCalled();
const eventArg = onDestroyListener.mock.calls[0][0];
expect(eventArg).toBeInstanceOf(Event);
expect(eventArg.type).toBe("destroy");
});
});
});
type TestMessage = MessageWithMetadata & { testId: string };
@ -86,11 +142,13 @@ class TestChannelPair {
this.channelA = {
addEventListener: (listener) => (broadcastChannel.port1.onmessage = listener),
removeEventListener: () => (broadcastChannel.port1.onmessage = null),
postMessage: (message, port) => broadcastChannel.port1.postMessage(message, port),
};
this.channelB = {
addEventListener: (listener) => (broadcastChannel.port2.onmessage = listener),
removeEventListener: () => (broadcastChannel.port1.onmessage = null),
postMessage: (message, port) => broadcastChannel.port2.postMessage(message, port),
};
}
@ -102,7 +160,7 @@ class TestMessageHandler {
abortController?: AbortController,
) => Promise<Message | undefined>;
private recievedMessages: {
private receivedMessages: {
message: TestMessage;
respond: (response: TestMessage) => void;
reject: (error: Error) => void;
@ -112,7 +170,7 @@ class TestMessageHandler {
constructor() {
this.handler = (message, abortController) =>
new Promise((resolve, reject) => {
this.recievedMessages.push({
this.receivedMessages.push({
message,
abortController,
respond: (response) => resolve(response),
@ -121,9 +179,9 @@ class TestMessageHandler {
});
}
recieve() {
const received = this.recievedMessages;
this.recievedMessages = [];
receive() {
const received = this.receivedMessages;
this.receivedMessages = [];
return received;
}
}
@ -144,7 +202,11 @@ class MockMessagePort<T> {
postMessage(message: T, port?: MessagePort) {
this.remotePort.onmessage(
new MessageEvent("message", { data: message, ports: port ? [port] : [] }),
new MessageEvent("message", {
data: message,
ports: port ? [port] : [],
origin: "https://bitwarden.com",
}),
);
}
@ -152,3 +214,20 @@ class MockMessagePort<T> {
// Do nothing
}
}
class MockEventTarget {
listeners: Record<string, EventListener[]> = {};
addEventListener(type: string, callback: EventListener) {
this.listeners[type] = this.listeners[type] || [];
this.listeners[type].push(callback);
}
dispatchEvent(event: Event) {
(this.listeners[event.type] || []).forEach((callback) => callback(event));
}
removeEventListener(type: string, callback: EventListener) {
this.listeners[type] = (this.listeners[type] || []).filter((listener) => listener !== callback);
}
}

View File

@ -1,3 +1,5 @@
import { FallbackRequestedError } from "@bitwarden/common/vault/abstractions/fido2/fido2-client.service.abstraction";
import { Message, MessageType } from "./message";
const SENDER = "bitwarden-webauthn";
@ -6,15 +8,16 @@ type PostMessageFunction = (message: MessageWithMetadata, remotePort: MessagePor
export type Channel = {
addEventListener: (listener: (message: MessageEvent<MessageWithMetadata>) => void) => void;
removeEventListener: (listener: (message: MessageEvent<MessageWithMetadata>) => void) => void;
postMessage: PostMessageFunction;
};
export type Metadata = { SENDER: typeof SENDER };
export type Metadata = { SENDER: typeof SENDER; senderId: string };
export type MessageWithMetadata = Message & Metadata;
type Handler = (
message: MessageWithMetadata,
abortController?: AbortController,
) => Promise<Message | undefined>;
) => void | Promise<Message | undefined>;
/**
* A class that handles communication between the page and content script. It converts
@ -22,6 +25,9 @@ type Handler = (
* handling aborts and exceptions across separate execution contexts.
*/
export class Messenger {
private messageEventListener: (event: MessageEvent<MessageWithMetadata>) => void | null = null;
private onDestroy = new EventTarget();
/**
* 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
@ -35,14 +41,8 @@ export class Messenger {
return new Messenger({
postMessage: (message, port) => window.postMessage(message, windowOrigin, [port]),
addEventListener: (listener) =>
window.addEventListener("message", (event: MessageEvent<unknown>) => {
if (event.origin !== windowOrigin) {
return;
}
listener(event as MessageEvent<MessageWithMetadata>);
}),
addEventListener: (listener) => window.addEventListener("message", listener),
removeEventListener: (listener) => window.removeEventListener("message", listener),
});
}
@ -53,38 +53,11 @@ export class Messenger {
*/
handler?: Handler;
private messengerId = this.generateUniqueId();
constructor(private broadcastChannel: Channel) {
this.broadcastChannel.addEventListener(async (event) => {
if (this.handler === undefined) {
return;
}
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();
}
});
this.messageEventListener = this.createMessageEventListener();
this.broadcastChannel.addEventListener(this.messageEventListener);
}
/**
@ -111,7 +84,10 @@ export class Messenger {
});
abortController?.signal.addEventListener("abort", abortListener);
this.broadcastChannel.postMessage({ ...request, SENDER }, remotePort);
this.broadcastChannel.postMessage(
{ ...request, SENDER, senderId: this.messengerId },
remotePort,
);
const response = await promise;
abortController?.signal.removeEventListener("abort", abortListener);
@ -127,4 +103,79 @@ export class Messenger {
localPort.close();
}
}
private createMessageEventListener() {
return async (event: MessageEvent<MessageWithMetadata>) => {
const windowOrigin = window.location.origin;
if (event.origin !== windowOrigin || !this.handler) {
return;
}
const message = event.data;
const port = event.ports?.[0];
if (
message?.SENDER !== SENDER ||
message.senderId == this.messengerId ||
message == null ||
port == null
) {
return;
}
const abortController = new AbortController();
port.onmessage = (event: MessageEvent<MessageWithMetadata>) => {
if (event.data.type === MessageType.AbortRequest) {
abortController.abort();
}
};
let onDestroyListener;
const destroyPromise: Promise<never> = new Promise((_, reject) => {
onDestroyListener = () => reject(new FallbackRequestedError());
this.onDestroy.addEventListener("destroy", onDestroyListener);
});
try {
const handlerResponse = await Promise.race([
this.handler(message, abortController),
destroyPromise,
]);
port.postMessage({ ...handlerResponse, SENDER });
} catch (error) {
port.postMessage({
SENDER,
type: MessageType.ErrorResponse,
error: JSON.stringify(error, Object.getOwnPropertyNames(error)),
});
} finally {
this.onDestroy.removeEventListener("destroy", onDestroyListener);
port.close();
}
};
}
/**
* Cleans up the messenger by removing the message event listener
*/
async destroy() {
this.onDestroy.dispatchEvent(new Event("destroy"));
if (this.messageEventListener) {
await this.sendDisconnectCommand();
this.broadcastChannel.removeEventListener(this.messageEventListener);
this.messageEventListener = null;
}
}
async sendReconnectCommand() {
await this.request({ type: MessageType.ReconnectRequest });
}
private async sendDisconnectCommand() {
await this.request({ type: MessageType.DisconnectRequest });
}
private generateUniqueId() {
return Date.now().toString(36) + Math.random().toString(36).substring(2);
}
}

View File

@ -53,10 +53,21 @@ const browserCredentials = {
};
const messenger = ((window as any).messenger = Messenger.forDOMCommunication(window));
navigator.credentials.create = async (
navigator.credentials.create = createWebAuthnCredential;
navigator.credentials.get = getWebAuthnCredential;
/**
* Creates a new webauthn credential.
*
* @param options Options for creating new credentials.
* @param abortController Abort controller to abort the request if needed.
* @returns Promise that resolves to the new credential object.
*/
async function createWebAuthnCredential(
options?: CredentialCreationOptions,
abortController?: AbortController,
): Promise<Credential> => {
): Promise<Credential> {
if (!isWebauthnCall(options)) {
return await browserCredentials.create(options);
}
@ -88,12 +99,19 @@ navigator.credentials.create = async (
throw error;
}
};
}
navigator.credentials.get = async (
/**
* Retrieves a webauthn credential.
*
* @param options Options for creating new credentials.
* @param abortController Abort controller to abort the request if needed.
* @returns Promise that resolves to the new credential object.
*/
async function getWebAuthnCredential(
options?: CredentialRequestOptions,
abortController?: AbortController,
): Promise<Credential> => {
): Promise<Credential> {
if (!isWebauthnCall(options)) {
return await browserCredentials.get(options);
}
@ -126,7 +144,7 @@ navigator.credentials.get = async (
throw error;
}
};
}
function isWebauthnCall(options?: CredentialCreationOptions | CredentialRequestOptions) {
return options && "publicKey" in options;
@ -174,3 +192,23 @@ async function waitForFocus(fallbackWait = 500, timeout = 5 * 60 * 1000) {
window.clearTimeout(timeoutId);
}
}
/**
* Sets up a listener to handle cleanup or reconnection when the extension's
* context changes due to being reloaded or unloaded.
*/
messenger.handler = (message, abortController) => {
const type = message.type;
// Handle cleanup for disconnect request
if (type === MessageType.DisconnectRequest && browserNativeWebauthnSupport) {
navigator.credentials.create = browserCredentials.create;
navigator.credentials.get = browserCredentials.get;
}
// Handle reinitialization for reconnect request
if (type === MessageType.ReconnectRequest && browserNativeWebauthnSupport) {
navigator.credentials.create = createWebAuthnCredential;
navigator.credentials.get = getWebAuthnCredential;
}
};

View File

@ -0,0 +1,16 @@
describe("TriggerFido2ContentScriptInjection", () => {
afterEach(() => {
jest.resetModules();
jest.clearAllMocks();
});
describe("init", () => {
it("sends a message to the extension background", () => {
require("../content/trigger-fido2-content-script-injection");
expect(chrome.runtime.sendMessage).toHaveBeenCalledWith({
command: "triggerFido2ContentScriptInjection",
});
});
});
});

View File

@ -0,0 +1,3 @@
(function () {
chrome.runtime.sendMessage({ command: "triggerFido2ContentScriptInjection" });
})();

View File

@ -0,0 +1,4 @@
export abstract class Fido2Service {
init: () => Promise<void>;
injectFido2ContentScripts: (sender: chrome.runtime.MessageSender) => Promise<void>;
}

View File

@ -0,0 +1,35 @@
import { BrowserApi } from "../../platform/browser/browser-api";
import Fido2Service from "./fido2.service";
describe("Fido2Service", () => {
let fido2Service: Fido2Service;
let tabMock: chrome.tabs.Tab;
let sender: chrome.runtime.MessageSender;
beforeEach(() => {
fido2Service = new Fido2Service();
tabMock = { id: 123, url: "https://bitwarden.com" } as chrome.tabs.Tab;
sender = { tab: tabMock };
jest.spyOn(BrowserApi, "executeScriptInTab").mockImplementation();
});
afterEach(() => {
jest.resetModules();
jest.clearAllMocks();
});
describe("injectFido2ContentScripts", () => {
const fido2ContentScript = "content/fido2/content-script.js";
const defaultExecuteScriptOptions = { runAt: "document_start" };
it("accepts an extension message sender and injects the fido2 scripts into the tab of the sender", async () => {
await fido2Service.injectFido2ContentScripts(sender);
expect(BrowserApi.executeScriptInTab).toHaveBeenCalledWith(tabMock.id, {
file: fido2ContentScript,
...defaultExecuteScriptOptions,
});
});
});
});

View File

@ -0,0 +1,33 @@
import { BrowserApi } from "../../platform/browser/browser-api";
import { Fido2Service as Fido2ServiceInterface } from "./abstractions/fido2.service";
export default class Fido2Service implements Fido2ServiceInterface {
async init() {
const tabs = await BrowserApi.tabsQuery({});
tabs.forEach((tab) => {
if (tab.url?.startsWith("https")) {
this.injectFido2ContentScripts({ tab } as chrome.runtime.MessageSender);
}
});
BrowserApi.addListener(chrome.runtime.onConnect, (port) => {
if (port.name === "fido2ContentScriptReady") {
port.postMessage({ command: "fido2ContentScriptInit" });
}
});
}
/**
* Injects the FIDO2 content script into the current tab.
* @param {chrome.runtime.MessageSender} sender
* @returns {Promise<void>}
*/
async injectFido2ContentScripts(sender: chrome.runtime.MessageSender): Promise<void> {
await BrowserApi.executeScriptInTab(sender.tab.id, {
file: "content/fido2/content-script.js",
frameId: sender.frameId,
runAt: "document_start",
});
}
}

View File

@ -171,6 +171,8 @@ const mainConfig = {
"content/notificationBar": "./src/autofill/content/notification-bar.ts",
"content/contextMenuHandler": "./src/autofill/content/context-menu-handler.ts",
"content/message_handler": "./src/autofill/content/message_handler.ts",
"content/fido2/trigger-fido2-content-script-injection":
"./src/vault/fido2/content/trigger-fido2-content-script-injection.ts",
"content/fido2/content-script": "./src/vault/fido2/content/content-script.ts",
"content/fido2/page-script": "./src/vault/fido2/content/page-script.ts",
"notification/bar": "./src/autofill/notification/bar.ts",