357 lines
12 KiB
TypeScript
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);
|
|
};
|
|
}
|