bitwarden-estensione-browser/apps/browser/src/vault/fido2/background/fido2.background.ts

357 lines
12 KiB
TypeScript

import { firstValueFrom, startWith } from "rxjs";
import { pairwise } from "rxjs/operators";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import {
AssertCredentialParams,
AssertCredentialResult,
CreateCredentialParams,
CreateCredentialResult,
Fido2ClientService,
} from "@bitwarden/common/vault/abstractions/fido2/fido2-client.service.abstraction";
import { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service";
import { BrowserApi } from "../../../platform/browser/browser-api";
import { ScriptInjectorService } from "../../../platform/services/abstractions/script-injector.service";
import { AbortManager } from "../../background/abort-manager";
import { Fido2ContentScript, Fido2ContentScriptId } from "../enums/fido2-content-script.enum";
import { Fido2PortName } from "../enums/fido2-port-name.enum";
import {
Fido2Background as Fido2BackgroundInterface,
Fido2BackgroundExtensionMessageHandlers,
Fido2ExtensionMessage,
SharedFido2ScriptInjectionDetails,
SharedFido2ScriptRegistrationOptions,
} from "./abstractions/fido2.background";
export class Fido2Background implements Fido2BackgroundInterface {
private abortManager = new AbortManager();
private fido2ContentScriptPortsSet = new Set<chrome.runtime.Port>();
private registeredContentScripts: browser.contentScripts.RegisteredContentScript;
private readonly sharedInjectionDetails: SharedFido2ScriptInjectionDetails = {
runAt: "document_start",
};
private readonly sharedRegistrationOptions: SharedFido2ScriptRegistrationOptions = {
matches: ["https://*/*"],
excludeMatches: ["https://*/*.xml*"],
allFrames: true,
...this.sharedInjectionDetails,
};
private readonly extensionMessageHandlers: Fido2BackgroundExtensionMessageHandlers = {
fido2AbortRequest: ({ message }) => this.abortRequest(message),
fido2RegisterCredentialRequest: ({ message, sender }) =>
this.registerCredentialRequest(message, sender),
fido2GetCredentialRequest: ({ message, sender }) => this.getCredentialRequest(message, sender),
};
constructor(
private logService: LogService,
private fido2ClientService: Fido2ClientService,
private vaultSettingsService: VaultSettingsService,
private scriptInjectorService: ScriptInjectorService,
) {}
/**
* Initializes the FIDO2 background service. Sets up the extension message
* and port listeners. Subscribes to the enablePasskeys$ observable to
* handle passkey enable/disable events.
*/
init() {
BrowserApi.messageListener("fido2.background", this.handleExtensionMessage);
BrowserApi.addListener(chrome.runtime.onConnect, this.handleInjectedScriptPortConnection);
this.vaultSettingsService.enablePasskeys$
.pipe(startWith(undefined), pairwise())
.subscribe(([previous, current]) => this.handleEnablePasskeysUpdate(previous, current));
}
/**
* Injects the FIDO2 content and page script into all existing browser tabs.
*/
async injectFido2ContentScriptsInAllTabs() {
const tabs = await BrowserApi.tabsQuery({});
for (let index = 0; index < tabs.length; index++) {
const tab = tabs[index];
if (tab.url?.startsWith("https")) {
void this.injectFido2ContentScripts(tab);
}
}
}
/**
* Handles reacting to the enablePasskeys setting being updated. If the setting
* is enabled, the FIDO2 content scripts are injected into all tabs. If the setting
* is disabled, the FIDO2 content scripts will be from all tabs. This logic will
* not trigger until after the first setting update.
*
* @param previousEnablePasskeysSetting - The previous value of the enablePasskeys setting.
* @param enablePasskeys - The new value of the enablePasskeys setting.
*/
private async handleEnablePasskeysUpdate(
previousEnablePasskeysSetting: boolean,
enablePasskeys: boolean,
) {
await this.updateContentScriptRegistration();
if (previousEnablePasskeysSetting === undefined) {
return;
}
this.destroyLoadedFido2ContentScripts();
if (enablePasskeys) {
void this.injectFido2ContentScriptsInAllTabs();
}
}
/**
* Updates the registration status of static FIDO2 content
* scripts based on the enablePasskeys setting.
*/
private async updateContentScriptRegistration() {
if (BrowserApi.isManifestVersion(2)) {
await this.updateMv2ContentScriptsRegistration();
return;
}
await this.updateMv3ContentScriptsRegistration();
}
/**
* Updates the registration status of static FIDO2 content
* scripts based on the enablePasskeys setting for manifest v2.
*/
private async updateMv2ContentScriptsRegistration() {
if (!(await this.isPasskeySettingEnabled())) {
await this.registeredContentScripts?.unregister();
return;
}
this.registeredContentScripts = await BrowserApi.registerContentScriptsMv2({
js: [
{ file: Fido2ContentScript.PageScriptAppend },
{ file: Fido2ContentScript.ContentScript },
],
...this.sharedRegistrationOptions,
});
}
/**
* Updates the registration status of static FIDO2 content
* scripts based on the enablePasskeys setting for manifest v3.
*/
private async updateMv3ContentScriptsRegistration() {
if (await this.isPasskeySettingEnabled()) {
void BrowserApi.registerContentScriptsMv3([
{
id: Fido2ContentScriptId.PageScript,
js: [Fido2ContentScript.PageScript],
world: "MAIN",
...this.sharedRegistrationOptions,
},
{
id: Fido2ContentScriptId.ContentScript,
js: [Fido2ContentScript.ContentScript],
...this.sharedRegistrationOptions,
},
]);
return;
}
void BrowserApi.unregisterContentScriptsMv3({
ids: [Fido2ContentScriptId.PageScript, Fido2ContentScriptId.ContentScript],
});
}
/**
* Injects the FIDO2 content and page script into the current tab.
*
* @param tab - The current tab to inject the scripts into.
*/
private async injectFido2ContentScripts(tab: chrome.tabs.Tab): Promise<void> {
void this.scriptInjectorService.inject({
tabId: tab.id,
injectDetails: { frame: "all_frames", ...this.sharedInjectionDetails },
mv2Details: { file: Fido2ContentScript.PageScriptAppend },
mv3Details: { file: Fido2ContentScript.PageScript, world: "MAIN" },
});
void this.scriptInjectorService.inject({
tabId: tab.id,
injectDetails: {
file: Fido2ContentScript.ContentScript,
frame: "all_frames",
...this.sharedInjectionDetails,
},
});
}
/**
* Iterates over the set of injected FIDO2 content script ports
* and disconnects them, destroying the content scripts.
*/
private destroyLoadedFido2ContentScripts() {
this.fido2ContentScriptPortsSet.forEach((port) => {
port.disconnect();
this.fido2ContentScriptPortsSet.delete(port);
});
}
/**
* Aborts the FIDO2 request with the provided requestId.
*
* @param message - The FIDO2 extension message containing the requestId to abort.
*/
private abortRequest(message: Fido2ExtensionMessage) {
this.abortManager.abort(message.abortedRequestId);
}
/**
* Registers a new FIDO2 credential with the provided request data.
*
* @param message - The FIDO2 extension message containing the request data.
* @param sender - The sender of the message.
*/
private async registerCredentialRequest(
message: Fido2ExtensionMessage,
sender: chrome.runtime.MessageSender,
): Promise<CreateCredentialResult> {
return await this.handleCredentialRequest<CreateCredentialResult>(
message,
sender.tab,
this.fido2ClientService.createCredential.bind(this.fido2ClientService),
);
}
/**
* Gets a FIDO2 credential with the provided request data.
*
* @param message - The FIDO2 extension message containing the request data.
* @param sender - The sender of the message.
*/
private async getCredentialRequest(
message: Fido2ExtensionMessage,
sender: chrome.runtime.MessageSender,
): Promise<AssertCredentialResult> {
return await this.handleCredentialRequest<AssertCredentialResult>(
message,
sender.tab,
this.fido2ClientService.assertCredential.bind(this.fido2ClientService),
);
}
/**
* Handles Fido2 credential requests by calling the provided callback with the
* request data, tab, and abort controller. The callback is expected to return
* a promise that resolves with the result of the credential request.
*
* @param requestId - The request ID associated with the request.
* @param data - The request data to handle.
* @param tab - The tab associated with the request.
* @param callback - The callback to call with the request data, tab, and abort controller.
*/
private handleCredentialRequest = async <T>(
{ requestId, data }: Fido2ExtensionMessage,
tab: chrome.tabs.Tab,
callback: (
data: AssertCredentialParams | CreateCredentialParams,
tab: chrome.tabs.Tab,
abortController: AbortController,
) => Promise<T>,
) => {
return await this.abortManager.runWithAbortController(requestId, async (abortController) => {
try {
return await callback(data, tab, abortController);
} finally {
await BrowserApi.focusTab(tab.id);
await BrowserApi.focusWindow(tab.windowId);
}
});
};
/**
* Checks if the enablePasskeys setting is enabled.
*/
private async isPasskeySettingEnabled() {
return await firstValueFrom(this.vaultSettingsService.enablePasskeys$);
}
/**
* Handles the FIDO2 extension message by calling the
* appropriate handler based on the message command.
*
* @param message - The FIDO2 extension message to handle.
* @param sender - The sender of the message.
* @param sendResponse - The function to call with the response.
*/
private handleExtensionMessage = (
message: Fido2ExtensionMessage,
sender: chrome.runtime.MessageSender,
sendResponse: (response?: any) => void,
) => {
const handler: CallableFunction | undefined = this.extensionMessageHandlers[message?.command];
if (!handler) {
return;
}
const messageResponse = handler({ message, sender });
if (!messageResponse) {
return;
}
Promise.resolve(messageResponse)
.then(
(response) => sendResponse(response),
(error) => sendResponse({ error: { ...error, message: error.message } }),
)
.catch(this.logService.error);
return true;
};
/**
* Handles the connection of a FIDO2 content script port by checking if the
* FIDO2 feature is enabled for the sender's hostname and origin. If the feature
* is not enabled, the port is disconnected.
*
* @param port - The port which is connecting
*/
private handleInjectedScriptPortConnection = async (port: chrome.runtime.Port) => {
if (port.name !== Fido2PortName.InjectedScript || !port.sender?.url) {
return;
}
try {
const { hostname, origin } = new URL(port.sender.url);
if (!(await this.fido2ClientService.isFido2FeatureEnabled(hostname, origin))) {
port.disconnect();
return;
}
this.fido2ContentScriptPortsSet.add(port);
port.onDisconnect.addListener(this.handleInjectScriptPortOnDisconnect);
} catch (error) {
this.logService.error(error);
port.disconnect();
}
};
/**
* Handles the disconnection of a FIDO2 content script port
* by removing it from the set of connected ports.
*
* @param port - The port which is disconnecting
*/
private handleInjectScriptPortOnDisconnect = (port: chrome.runtime.Port) => {
if (port.name !== Fido2PortName.InjectedScript) {
return;
}
this.fido2ContentScriptPortsSet.delete(port);
};
}