-
-
-
-
-
-
{{ biometricError }}
-
- {{ "awaitDesktop" | i18n }}
-
+
+
+
+
+
{{ biometricError }}
+
+ {{ "awaitDesktop" | i18n }}
+
+
+
+
diff --git a/apps/browser/src/auth/popup/lock.component.ts b/apps/browser/src/auth/popup/lock.component.ts
index f5f8a29eb69..4a0c752a5d2 100644
--- a/apps/browser/src/auth/popup/lock.component.ts
+++ b/apps/browser/src/auth/popup/lock.component.ts
@@ -22,6 +22,8 @@ import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/pass
import { DialogService } from "@bitwarden/components";
import { BiometricErrors, BiometricErrorTypes } from "../../models/biometricErrors";
+import { BrowserRouterService } from "../../platform/popup/services/browser-router.service";
+import { fido2PopoutSessionData$ } from "../../vault/fido2/browser-fido2-user-interface.service";
@Component({
selector: "app-lock",
@@ -32,6 +34,7 @@ export class LockComponent extends BaseLockComponent {
biometricError: string;
pendingBiometric = false;
+ fido2PopoutSessionData$ = fido2PopoutSessionData$();
constructor(
router: Router,
@@ -52,7 +55,8 @@ export class LockComponent extends BaseLockComponent {
private authService: AuthService,
dialogService: DialogService,
deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction,
- userVerificationService: UserVerificationService
+ userVerificationService: UserVerificationService,
+ private routerService: BrowserRouterService
) {
super(
router,
@@ -76,6 +80,15 @@ export class LockComponent extends BaseLockComponent {
);
this.successRoute = "/tabs/current";
this.isInitialLockScreen = (window as any).previousPopupUrl == null;
+
+ super.onSuccessfulSubmit = async () => {
+ const previousUrl = this.routerService.getPreviousUrl();
+ if (previousUrl) {
+ this.router.navigateByUrl(previousUrl);
+ } else {
+ this.router.navigate([this.successRoute]);
+ }
+ };
}
async ngOnInit() {
diff --git a/apps/browser/src/autofill/background/context-menus.background.ts b/apps/browser/src/autofill/background/context-menus.background.ts
index bc26353cbd9..add392f3293 100644
--- a/apps/browser/src/autofill/background/context-menus.background.ts
+++ b/apps/browser/src/autofill/background/context-menus.background.ts
@@ -20,17 +20,16 @@ export default class ContextMenusBackground {
BrowserApi.messageListener(
"contextmenus.background",
- async (
+ (
msg: { command: string; data: LockedVaultPendingNotificationsItem },
- sender: chrome.runtime.MessageSender,
- sendResponse: any
+ sender: chrome.runtime.MessageSender
) => {
if (msg.command === "unlockCompleted" && msg.data.target === "contextmenus.background") {
- await this.contextMenuClickedHandler.cipherAction(
- msg.data.commandToRetry.msg.data,
- msg.data.commandToRetry.sender.tab
- );
- await BrowserApi.tabSendMessageData(sender.tab, "closeNotificationBar");
+ this.contextMenuClickedHandler
+ .cipherAction(msg.data.commandToRetry.msg.data, msg.data.commandToRetry.sender.tab)
+ .then(() => {
+ BrowserApi.tabSendMessageData(sender.tab, "closeNotificationBar");
+ });
}
}
);
diff --git a/apps/browser/src/autofill/background/notification.background.ts b/apps/browser/src/autofill/background/notification.background.ts
index 73bdc2cd16f..f95ca60bab1 100644
--- a/apps/browser/src/autofill/background/notification.background.ts
+++ b/apps/browser/src/autofill/background/notification.background.ts
@@ -47,8 +47,8 @@ export default class NotificationBackground {
BrowserApi.messageListener(
"notification.background",
- async (msg: any, sender: chrome.runtime.MessageSender) => {
- await this.processMessage(msg, sender);
+ (msg: any, sender: chrome.runtime.MessageSender) => {
+ this.processMessage(msg, sender);
}
);
diff --git a/apps/browser/src/background/commands.background.ts b/apps/browser/src/background/commands.background.ts
index 0cbf91c6666..77300272d26 100644
--- a/apps/browser/src/background/commands.background.ts
+++ b/apps/browser/src/background/commands.background.ts
@@ -25,17 +25,11 @@ export default class CommandsBackground {
}
async init() {
- BrowserApi.messageListener(
- "commands.background",
- async (msg: any, sender: chrome.runtime.MessageSender, sendResponse: any) => {
- if (msg.command === "unlockCompleted" && msg.data.target === "commands.background") {
- await this.processCommand(
- msg.data.commandToRetry.msg.command,
- msg.data.commandToRetry.sender
- );
- }
+ BrowserApi.messageListener("commands.background", (msg: any) => {
+ if (msg.command === "unlockCompleted" && msg.data.target === "commands.background") {
+ this.processCommand(msg.data.commandToRetry.msg.command, msg.data.commandToRetry.sender);
}
- );
+ });
if (chrome && chrome.commands) {
chrome.commands.onCommand.addListener(async (command: string) => {
diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts
index caac8e6bb81..4fcfa685270 100644
--- a/apps/browser/src/background/main.background.ts
+++ b/apps/browser/src/background/main.background.ts
@@ -87,6 +87,9 @@ import { SendApiService as SendApiServiceAbstraction } from "@bitwarden/common/t
import { InternalSendService as InternalSendServiceAbstraction } from "@bitwarden/common/tools/send/services/send.service.abstraction";
import { CipherService as CipherServiceAbstraction } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CollectionService as CollectionServiceAbstraction } from "@bitwarden/common/vault/abstractions/collection.service";
+import { Fido2AuthenticatorService as Fido2AuthenticatorServiceAbstraction } from "@bitwarden/common/vault/abstractions/fido2/fido2-authenticator.service.abstraction";
+import { Fido2ClientService as Fido2ClientServiceAbstraction } from "@bitwarden/common/vault/abstractions/fido2/fido2-client.service.abstraction";
+import { Fido2UserInterfaceService as Fido2UserInterfaceServiceAbstraction } from "@bitwarden/common/vault/abstractions/fido2/fido2-user-interface.service.abstraction";
import { CipherFileUploadService as CipherFileUploadServiceAbstraction } from "@bitwarden/common/vault/abstractions/file-upload/cipher-file-upload.service";
import { FolderApiServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder-api.service.abstraction";
import { InternalFolderService as InternalFolderServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
@@ -95,6 +98,8 @@ import { SyncService as SyncServiceAbstraction } from "@bitwarden/common/vault/a
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { CipherService } from "@bitwarden/common/vault/services/cipher.service";
import { CollectionService } from "@bitwarden/common/vault/services/collection.service";
+import { Fido2AuthenticatorService } from "@bitwarden/common/vault/services/fido2/fido2-authenticator.service";
+import { Fido2ClientService } from "@bitwarden/common/vault/services/fido2/fido2-client.service";
import { CipherFileUploadService } from "@bitwarden/common/vault/services/file-upload/cipher-file-upload.service";
import { FolderApiService } from "@bitwarden/common/vault/services/folder/folder-api.service";
import { SyncNotifierService } from "@bitwarden/common/vault/services/sync/sync-notifier.service";
@@ -138,9 +143,11 @@ import BrowserPlatformUtilsService from "../platform/services/browser-platform-u
import { BrowserStateService } from "../platform/services/browser-state.service";
import { KeyGenerationService } from "../platform/services/key-generation.service";
import { LocalBackedSessionStorageService } from "../platform/services/local-backed-session-storage.service";
+import { PopupUtilsService } from "../popup/services/popup-utils.service";
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 { BrowserFolderService } from "../vault/services/browser-folder.service";
import { VaultFilterService } from "../vault/services/vault-filter.service";
@@ -204,6 +211,9 @@ export default class MainBackground {
sendApiService: SendApiServiceAbstraction;
userVerificationApiService: UserVerificationApiServiceAbstraction;
syncNotifierService: SyncNotifierServiceAbstraction;
+ fido2UserInterfaceService: Fido2UserInterfaceServiceAbstraction;
+ fido2AuthenticatorService: Fido2AuthenticatorServiceAbstraction;
+ fido2ClientService: Fido2ClientServiceAbstraction;
avatarUpdateService: AvatarUpdateServiceAbstraction;
mainContextMenuHandler: MainContextMenuHandler;
cipherContextMenuHandler: CipherContextMenuHandler;
@@ -213,6 +223,7 @@ export default class MainBackground {
devicesService: DevicesServiceAbstraction;
deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction;
authRequestCryptoService: AuthRequestCryptoServiceAbstraction;
+ popupUtilsService: PopupUtilsService;
browserPopoutWindowService: BrowserPopoutWindowService;
// Passed to the popup for Safari to workaround issues with theming, downloading, etc.
@@ -370,7 +381,7 @@ export default class MainBackground {
// AuthService should send the messages to the background not popup.
send = (subscriber: string, arg: any = {}) => {
const message = Object.assign({}, { command: subscriber }, arg);
- that.runtimeBackground.processMessage(message, that as any, null);
+ that.runtimeBackground.processMessage(message, that as any);
};
})();
@@ -569,6 +580,22 @@ export default class MainBackground {
this.browserPopoutWindowService = new BrowserPopoutWindowService();
+ this.fido2UserInterfaceService = new BrowserFido2UserInterfaceService(
+ this.browserPopoutWindowService
+ );
+ this.fido2AuthenticatorService = new Fido2AuthenticatorService(
+ this.cipherService,
+ this.fido2UserInterfaceService,
+ this.syncService,
+ this.logService
+ );
+ this.fido2ClientService = new Fido2ClientService(
+ this.fido2AuthenticatorService,
+ this.configService,
+ this.authService,
+ this.logService
+ );
+
const systemUtilsServiceReloadCallback = () => {
const forceWindowReload =
this.platformUtilsService.isSafari() ||
diff --git a/apps/browser/src/background/nativeMessaging.background.ts b/apps/browser/src/background/nativeMessaging.background.ts
index 88fd81a3a70..f4eed16823a 100644
--- a/apps/browser/src/background/nativeMessaging.background.ts
+++ b/apps/browser/src/background/nativeMessaging.background.ts
@@ -383,7 +383,7 @@ export class NativeMessagingBackground {
return;
}
- this.runtimeBackground.processMessage({ command: "unlocked" }, null, null);
+ this.runtimeBackground.processMessage({ command: "unlocked" }, null);
}
break;
}
diff --git a/apps/browser/src/background/runtime.background.ts b/apps/browser/src/background/runtime.background.ts
index e921a736f71..53711c1dc11 100644
--- a/apps/browser/src/background/runtime.background.ts
+++ b/apps/browser/src/background/runtime.background.ts
@@ -14,6 +14,7 @@ import { BrowserPopoutWindowService } from "../platform/popup/abstractions/brows
import { BrowserStateService } from "../platform/services/abstractions/browser-state.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";
@@ -23,6 +24,7 @@ export default class RuntimeBackground {
private pageDetailsToAutoFill: any[] = [];
private onInstalledReason: string = null;
private lockedVaultPendingNotifications: LockedVaultPendingNotificationsItem[] = [];
+ private abortManager = new AbortManager();
constructor(
private main: MainBackground,
@@ -50,12 +52,27 @@ export default class RuntimeBackground {
}
await this.checkOnInstalled();
- const backgroundMessageListener = async (
+ const backgroundMessageListener = (
msg: any,
sender: chrome.runtime.MessageSender,
sendResponse: any
) => {
- await this.processMessage(msg, sender, sendResponse);
+ const messagesWithResponse = [
+ "checkFido2FeatureEnabled",
+ "fido2RegisterCredentialRequest",
+ "fido2GetCredentialRequest",
+ ];
+
+ if (messagesWithResponse.includes(msg.command)) {
+ this.processMessage(msg, sender).then(
+ (value) => sendResponse({ result: value }),
+ (error) => sendResponse({ error: { ...error, message: error.message } })
+ );
+ return true;
+ }
+
+ this.processMessage(msg, sender);
+ return false;
};
BrowserApi.messageListener("runtime.background", backgroundMessageListener);
@@ -64,7 +81,7 @@ export default class RuntimeBackground {
}
}
- async processMessage(msg: any, sender: chrome.runtime.MessageSender, sendResponse: any) {
+ async processMessage(msg: any, sender: chrome.runtime.MessageSender) {
const cipherId = msg.data?.cipherId;
switch (msg.command) {
@@ -282,8 +299,19 @@ export default class RuntimeBackground {
case "getClickedElementResponse":
this.platformUtilsService.copyToClipboard(msg.identifier, { window: window });
break;
- default:
+ case "fido2AbortRequest":
+ this.abortManager.abort(msg.abortedRequestId);
break;
+ case "checkFido2FeatureEnabled":
+ return await this.main.fido2ClientService.isFido2FeatureEnabled();
+ case "fido2RegisterCredentialRequest":
+ return await this.abortManager.runWithAbortController(msg.requestId, (abortController) =>
+ this.main.fido2ClientService.createCredential(msg.data, sender.tab, abortController)
+ );
+ case "fido2GetCredentialRequest":
+ return await this.abortManager.runWithAbortController(msg.requestId, (abortController) =>
+ this.main.fido2ClientService.assertCredential(msg.data, sender.tab, abortController)
+ );
}
}
diff --git a/apps/browser/src/manifest.json b/apps/browser/src/manifest.json
index 41efa59632b..f0da7195f29 100644
--- a/apps/browser/src/manifest.json
+++ b/apps/browser/src/manifest.json
@@ -17,7 +17,7 @@
"content_scripts": [
{
"all_frames": true,
- "js": ["content/trigger-autofill-script-injection.js"],
+ "js": ["content/trigger-autofill-script-injection.js", "content/fido2/content-script.js"],
"matches": ["http://*/*", "https://*/*", "file:///*"],
"run_at": "document_start"
},
@@ -93,6 +93,7 @@
}
},
"web_accessible_resources": [
+ "content/fido2/page-script.js",
"notification/bar.html",
"images/icon38.png",
"images/icon38_locked.png"
diff --git a/apps/browser/src/manifest.v3.json b/apps/browser/src/manifest.v3.json
index ba8668984de..e87cdc0023b 100644
--- a/apps/browser/src/manifest.v3.json
+++ b/apps/browser/src/manifest.v3.json
@@ -106,7 +106,12 @@
},
"web_accessible_resources": [
{
- "resources": ["notification/bar.html", "images/icon38.png", "images/icon38_locked.png"],
+ "resources": [
+ "content/webauthn/page-script.js",
+ "notification/bar.html",
+ "images/icon38.png",
+ "images/icon38_locked.png"
+ ],
"matches": ["
"]
}
],
diff --git a/apps/browser/src/platform/browser/browser-api.ts b/apps/browser/src/platform/browser/browser-api.ts
index 5d0c2f1a519..e8070f0d341 100644
--- a/apps/browser/src/platform/browser/browser-api.ts
+++ b/apps/browser/src/platform/browser/browser-api.ts
@@ -1,3 +1,5 @@
+import { Observable } from "rxjs";
+
import { DeviceType } from "@bitwarden/common/enums";
import { TabMessage } from "../../types/tab-messages";
@@ -35,6 +37,10 @@ export class BrowserApi {
);
}
+ static async removeWindow(windowId: number) {
+ await chrome.windows.remove(windowId);
+ }
+
static async getTabFromCurrentWindowId(): Promise | null {
return await BrowserApi.tabsQueryFirst({
active: true,
@@ -199,6 +205,14 @@ export class BrowserApi {
BrowserApi.removeTab(tabToClose.id);
}
+ static createNewWindow(
+ url: string,
+ focused = true,
+ type: chrome.windows.createTypeEnum = "normal"
+ ) {
+ chrome.windows.create({ url, focused, type });
+ }
+
// Keep track of all the events registered in a Safari popup so we can remove
// them when the popup gets unloaded, otherwise we cause a memory leak
private static registeredMessageListeners: any[] = [];
@@ -206,7 +220,11 @@ export class BrowserApi {
static messageListener(
name: string,
- callback: (message: any, sender: chrome.runtime.MessageSender, response: any) => void
+ callback: (
+ message: any,
+ sender: chrome.runtime.MessageSender,
+ sendResponse: any
+ ) => boolean | void
) {
// eslint-disable-next-line no-restricted-syntax
chrome.runtime.onMessage.addListener(callback);
@@ -244,6 +262,27 @@ export class BrowserApi {
};
}
+ static messageListener$() {
+ return new Observable((subscriber) => {
+ const handler = (message: unknown) => {
+ subscriber.next(message);
+ };
+
+ BrowserApi.messageListener("message", handler);
+
+ return () => {
+ chrome.runtime.onMessage.removeListener(handler);
+
+ if (BrowserApi.isSafariApi) {
+ const index = BrowserApi.registeredMessageListeners.indexOf(handler);
+ if (index !== -1) {
+ BrowserApi.registeredMessageListeners.splice(index, 1);
+ }
+ }
+ };
+ });
+ }
+
static sendMessage(subscriber: string, arg: any = {}) {
const message = Object.assign({}, { command: subscriber }, arg);
return chrome.runtime.sendMessage(message);
diff --git a/apps/browser/src/platform/decorators/session-sync-observable/session-syncer.ts b/apps/browser/src/platform/decorators/session-sync-observable/session-syncer.ts
index 001c546b9c6..120c4b8b58c 100644
--- a/apps/browser/src/platform/decorators/session-sync-observable/session-syncer.ts
+++ b/apps/browser/src/platform/decorators/session-sync-observable/session-syncer.ts
@@ -73,10 +73,9 @@ export class SessionSyncer {
private listenForUpdates() {
// This is an unawaited promise, but it will be executed asynchronously in the background.
- BrowserApi.messageListener(
- this.updateMessageCommand,
- async (message) => await this.updateFromMessage(message)
- );
+ BrowserApi.messageListener(this.updateMessageCommand, (message) => {
+ this.updateFromMessage(message);
+ });
}
async updateFromMessage(message: any) {
diff --git a/apps/browser/src/platform/popup/abstractions/browser-popout-window.service.ts b/apps/browser/src/platform/popup/abstractions/browser-popout-window.service.ts
index 0ded45bea94..f48649c4f23 100644
--- a/apps/browser/src/platform/popup/abstractions/browser-popout-window.service.ts
+++ b/apps/browser/src/platform/popup/abstractions/browser-popout-window.service.ts
@@ -28,6 +28,15 @@ interface BrowserPopoutWindowService {
}
): Promise;
closePasswordRepromptPrompt(): Promise;
+ openFido2Popout(
+ senderWindow: chrome.tabs.Tab,
+ promptData: {
+ sessionId: string;
+ senderTabId: number;
+ fallbackSupported: boolean;
+ }
+ ): Promise;
+ closeFido2Popout(): Promise;
}
export { BrowserPopoutWindowService };
diff --git a/apps/browser/src/platform/popup/browser-popout-window.service.ts b/apps/browser/src/platform/popup/browser-popout-window.service.ts
index f5ac2f3128e..f72f7bfb3e0 100644
--- a/apps/browser/src/platform/popup/browser-popout-window.service.ts
+++ b/apps/browser/src/platform/popup/browser-popout-window.service.ts
@@ -95,29 +95,71 @@ class BrowserPopoutWindowService implements BrowserPopupWindowServiceInterface {
await this.closeSingleActionPopout("passwordReprompt");
}
+ async openFido2Popout(
+ senderWindow: chrome.tabs.Tab,
+ {
+ sessionId,
+ senderTabId,
+ fallbackSupported,
+ }: {
+ sessionId: string;
+ senderTabId: number;
+ fallbackSupported: boolean;
+ }
+ ): Promise {
+ await this.closeFido2Popout();
+
+ const promptWindowPath =
+ "popup/index.html#/fido2" +
+ "?uilocation=popout" +
+ `&sessionId=${sessionId}` +
+ `&fallbackSupported=${fallbackSupported}` +
+ `&senderTabId=${senderTabId}` +
+ `&senderUrl=${encodeURIComponent(senderWindow.url)}`;
+
+ return await this.openSingleActionPopout(
+ senderWindow.windowId,
+ promptWindowPath,
+ "fido2Popout",
+ {
+ width: 200,
+ height: 500,
+ }
+ );
+ }
+
+ async closeFido2Popout(): Promise {
+ await this.closeSingleActionPopout("fido2Popout");
+ }
+
private async openSingleActionPopout(
senderWindowId: number,
popupWindowURL: string,
- singleActionPopoutKey: string
- ) {
+ singleActionPopoutKey: string,
+ options: chrome.windows.CreateData = {}
+ ): Promise {
const senderWindow = senderWindowId && (await BrowserApi.getWindow(senderWindowId));
const url = chrome.extension.getURL(popupWindowURL);
const offsetRight = 15;
const offsetTop = 90;
- const popupWidth = this.defaultPopoutWindowOptions.width;
+ /// Use overrides in `options` if provided, otherwise use default
+ const popupWidth = options?.width || this.defaultPopoutWindowOptions.width;
const windowOptions = senderWindow
? {
...this.defaultPopoutWindowOptions,
- url,
left: senderWindow.left + senderWindow.width - popupWidth - offsetRight,
top: senderWindow.top + offsetTop,
+ ...options,
+ url,
}
- : { ...this.defaultPopoutWindowOptions, url };
+ : { ...this.defaultPopoutWindowOptions, url, ...options };
const popupWindow = await BrowserApi.createWindow(windowOptions);
await this.closeSingleActionPopout(singleActionPopoutKey);
this.singleActionPopoutTabIds[singleActionPopoutKey] = popupWindow?.tabs[0].id;
+
+ return popupWindow.id;
}
private async closeSingleActionPopout(popoutKey: string) {
diff --git a/apps/browser/src/platform/popup/services/browser-router.service.ts b/apps/browser/src/platform/popup/services/browser-router.service.ts
new file mode 100644
index 00000000000..dfc816f4ccc
--- /dev/null
+++ b/apps/browser/src/platform/popup/services/browser-router.service.ts
@@ -0,0 +1,37 @@
+import { Injectable } from "@angular/core";
+import { ActivatedRouteSnapshot, NavigationEnd, Router } from "@angular/router";
+import { filter } from "rxjs";
+
+@Injectable({
+ providedIn: "root",
+})
+export class BrowserRouterService {
+ private previousUrl?: string = undefined;
+
+ constructor(router: Router) {
+ router.events
+ .pipe(filter((e) => e instanceof NavigationEnd))
+ .subscribe((event: NavigationEnd) => {
+ const state: ActivatedRouteSnapshot = router.routerState.snapshot.root;
+
+ let child = state.firstChild;
+ while (child.firstChild) {
+ child = child.firstChild;
+ }
+
+ const updateUrl = !child?.data?.doNotSaveUrl ?? true;
+
+ if (updateUrl) {
+ this.setPreviousUrl(event.url);
+ }
+ });
+ }
+
+ getPreviousUrl() {
+ return this.previousUrl;
+ }
+
+ setPreviousUrl(url: string) {
+ this.previousUrl = url;
+ }
+}
diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts
index 8ef51913f6d..10159a715f0 100644
--- a/apps/browser/src/popup/app-routing.module.ts
+++ b/apps/browser/src/popup/app-routing.module.ts
@@ -11,6 +11,7 @@ import {
import { canAccessFeature } from "@bitwarden/angular/guard/feature-flag.guard";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
+import { fido2AuthGuard } from "../auth/guards/fido2-auth.guard";
import { EnvironmentComponent } from "../auth/popup/environment.component";
import { HintComponent } from "../auth/popup/hint.component";
import { HomeComponent } from "../auth/popup/home.component";
@@ -31,6 +32,7 @@ import { SendAddEditComponent } from "../tools/popup/send/send-add-edit.componen
import { SendGroupingsComponent } from "../tools/popup/send/send-groupings.component";
import { SendTypeComponent } from "../tools/popup/send/send-type.component";
import { ExportComponent } from "../tools/popup/settings/export.component";
+import { Fido2Component } from "../vault/popup/components/fido2/fido2.component";
import { AddEditComponent } from "../vault/popup/components/vault/add-edit.component";
import { AttachmentsComponent } from "../vault/popup/components/vault/attachments.component";
import { CollectionsComponent } from "../vault/popup/components/vault/collections.component";
@@ -73,6 +75,12 @@ const routes: Routes = [
canActivate: [UnauthGuard],
data: { state: "home" },
},
+ {
+ path: "fido2",
+ component: Fido2Component,
+ canActivate: [fido2AuthGuard],
+ data: { state: "fido2" },
+ },
{
path: "login",
component: LoginComponent,
@@ -95,7 +103,7 @@ const routes: Routes = [
path: "lock",
component: LockComponent,
canActivate: [lockGuard()],
- data: { state: "lock" },
+ data: { state: "lock", doNotSaveUrl: true },
},
{
path: "2fa",
diff --git a/apps/browser/src/popup/app.component.ts b/apps/browser/src/popup/app.component.ts
index 93f824a7dd1..16ef77ed8f5 100644
--- a/apps/browser/src/popup/app.component.ts
+++ b/apps/browser/src/popup/app.component.ts
@@ -80,11 +80,7 @@ export class AppComponent implements OnInit, OnDestroy {
window.onkeypress = () => this.recordActivity();
});
- (window as any).bitwardenPopupMainMessageListener = async (
- msg: any,
- sender: any,
- sendResponse: any
- ) => {
+ const bitwardenPopupMainMessageListener = (msg: any, sender: any) => {
if (msg.command === "doneLoggingOut") {
this.authService.logOut(async () => {
if (msg.expired) {
@@ -102,15 +98,13 @@ export class AppComponent implements OnInit, OnDestroy {
this.changeDetectorRef.detectChanges();
} else if (msg.command === "authBlocked") {
this.router.navigate(["home"]);
- } else if (msg.command === "locked") {
- if (msg.userId == null || msg.userId === (await this.stateService.getUserId())) {
- this.router.navigate(["lock"]);
- }
+ } else if (msg.command === "locked" && msg.userId == null) {
+ this.router.navigate(["lock"]);
} else if (msg.command === "showDialog") {
- await this.ngZone.run(() => this.showDialog(msg));
+ this.showDialog(msg);
} else if (msg.command === "showNativeMessagingFinterprintDialog") {
// TODO: Should be refactored to live in another service.
- await this.ngZone.run(() => this.showNativeMessagingFingerprintDialog(msg));
+ this.showNativeMessagingFingerprintDialog(msg);
} else if (msg.command === "showToast") {
this.showToast(msg);
} else if (msg.command === "reloadProcess") {
@@ -133,7 +127,8 @@ export class AppComponent implements OnInit, OnDestroy {
}
};
- BrowserApi.messageListener("app.component", (window as any).bitwardenPopupMainMessageListener);
+ (window as any).bitwardenPopupMainMessageListener = bitwardenPopupMainMessageListener;
+ BrowserApi.messageListener("app.component", bitwardenPopupMainMessageListener);
// eslint-disable-next-line rxjs/no-async-subscribe
this.router.events.pipe(takeUntil(this.destroy$)).subscribe(async (event) => {
diff --git a/apps/browser/src/popup/app.module.ts b/apps/browser/src/popup/app.module.ts
index 9dbd87cff57..dd27a419a34 100644
--- a/apps/browser/src/popup/app.module.ts
+++ b/apps/browser/src/popup/app.module.ts
@@ -39,6 +39,9 @@ import { SendTypeComponent } from "../tools/popup/send/send-type.component";
import { ExportComponent } from "../tools/popup/settings/export.component";
import { ActionButtonsComponent } from "../vault/popup/components/action-buttons.component";
import { CipherRowComponent } from "../vault/popup/components/cipher-row.component";
+import { Fido2CipherRowComponent } from "../vault/popup/components/fido2/fido2-cipher-row.component";
+import { Fido2UseBrowserLinkComponent } from "../vault/popup/components/fido2/fido2-use-browser-link.component";
+import { Fido2Component } from "../vault/popup/components/fido2/fido2.component";
import { AddEditCustomFieldsComponent } from "../vault/popup/components/vault/add-edit-custom-fields.component";
import { AddEditComponent } from "../vault/popup/components/vault/add-edit.component";
import { AttachmentsComponent } from "../vault/popup/components/vault/attachments.component";
@@ -111,6 +114,8 @@ import "../platform/popup/locales";
EnvironmentComponent,
ExcludedDomainsComponent,
ExportComponent,
+ Fido2CipherRowComponent,
+ Fido2UseBrowserLinkComponent,
FolderAddEditComponent,
FoldersComponent,
VaultFilterComponent,
@@ -148,6 +153,7 @@ import "../platform/popup/locales";
ViewCustomFieldsComponent,
RemovePasswordComponent,
VaultSelectComponent,
+ Fido2Component,
HelpAndFeedbackComponent,
AutofillComponent,
EnvironmentSelectorComponent,
diff --git a/apps/browser/src/popup/images/bwi-passkey.png b/apps/browser/src/popup/images/bwi-passkey.png
new file mode 100644
index 00000000000..702be33446e
Binary files /dev/null and b/apps/browser/src/popup/images/bwi-passkey.png differ
diff --git a/apps/browser/src/popup/scss/pages.scss b/apps/browser/src/popup/scss/pages.scss
index e104570e563..8c2e69092bc 100644
--- a/apps/browser/src/popup/scss/pages.scss
+++ b/apps/browser/src/popup/scss/pages.scss
@@ -153,6 +153,14 @@ body.body-full {
margin: 15px 0 15px 0;
}
+.useBrowserlink {
+ padding: 0 10px 5px 10px;
+ position: fixed;
+ bottom: 10px;
+ left: 0;
+ right: 0;
+}
+
app-options {
.box {
margin: 10px 0;
@@ -175,3 +183,170 @@ app-vault-attachments {
}
}
}
+
+app-fido2 {
+ .auth-wrapper {
+ display: flex;
+ flex-direction: column;
+ padding: 12px 24px 12px 24px;
+
+ .auth-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+
+ .left {
+ padding-right: 10px;
+
+ .logo {
+ display: inline-flex;
+ align-items: center;
+
+ i.bwi {
+ font-size: 35px;
+ margin-right: 3px;
+ @include themify($themes) {
+ color: themed("primaryColor");
+ }
+ }
+
+ span {
+ font-size: 45px;
+ font-weight: 300;
+ margin-top: -3px;
+ @include themify($themes) {
+ color: themed("primaryColor");
+ }
+ }
+ }
+ }
+
+ .search {
+ padding: 7px 10px;
+ width: 100%;
+ text-align: left;
+ position: relative;
+ display: flex;
+
+ .bwi {
+ position: absolute;
+ top: 15px;
+ left: 20px;
+
+ @include themify($themes) {
+ color: themed("labelColor");
+ }
+ }
+
+ input {
+ width: 100%;
+ margin: 0;
+ border: none;
+ padding: 5px 10px 5px 30px;
+ border-radius: $border-radius;
+
+ &:focus {
+ border-radius: $border-radius;
+ outline: none;
+ }
+
+ &[type="search"]::-webkit-search-cancel-button {
+ -webkit-appearance: none;
+ appearance: none;
+ background-repeat: no-repeat;
+ mask-image: none;
+ -webkit-mask-image: none;
+ }
+ }
+ }
+ }
+
+ .auth-flow {
+ display: flex;
+ align-items: flex-start;
+ flex-direction: column;
+ margin-top: 32px;
+ margin-bottom: 32px;
+
+ .subtitle {
+ font-family: Open Sans;
+ font-size: 24px;
+ font-style: normal;
+ font-weight: 600;
+ line-height: 32px;
+ }
+
+ .box.list {
+ overflow-y: auto;
+ }
+
+ .box-content {
+ max-height: 140px;
+ }
+
+ @media screen and (min-height: 501px) and (max-height: 600px) {
+ .box-content {
+ max-height: 200px;
+ }
+ }
+
+ @media screen and (min-height: 601px) {
+ .box-content {
+ max-height: 260px;
+ }
+ }
+
+ .box-content-row {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ margin: 0px;
+ padding: 0px;
+ margin-bottom: 12px;
+
+ button {
+ min-height: 44px;
+ }
+
+ .row-main {
+ border-radius: 6px;
+ padding: 5px 0px 5px 12px;
+
+ &:focus {
+ @include themify($themes) {
+ padding: 3px 0px 3px 10px;
+ border: 2px solid themed("headerInputBackgroundFocusColor");
+ }
+ }
+
+ &.row-selected {
+ @include themify($themes) {
+ outline: none;
+ padding-left: 7px;
+ border-left: 5px solid themed("primaryColor");
+ background-color: themed("headerBackgroundHoverColor");
+ color: themed("headerColor");
+ }
+ }
+ }
+
+ .row-main-content {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+
+ .detail {
+ min-height: 15px;
+ display: block;
+ }
+ }
+ }
+
+ .btn {
+ width: 100%;
+ font-size: 16px;
+ font-weight: 600;
+ }
+ }
+ }
+}
diff --git a/apps/browser/src/popup/services/popup-utils.service.ts b/apps/browser/src/popup/services/popup-utils.service.ts
index 1ab80a4564d..b5a5a058171 100644
--- a/apps/browser/src/popup/services/popup-utils.service.ts
+++ b/apps/browser/src/popup/services/popup-utils.service.ts
@@ -3,6 +3,16 @@ import { fromEvent, Subscription } from "rxjs";
import { BrowserApi } from "../../platform/browser/browser-api";
+export type Popout =
+ | {
+ type: "window";
+ window: chrome.windows.Window;
+ }
+ | {
+ type: "tab";
+ tab: chrome.tabs.Tab;
+ };
+
@Injectable()
export class PopupUtilsService {
private unloadSubscription: Subscription;
@@ -45,12 +55,16 @@ export class PopupUtilsService {
}
}
- popOut(win: Window, href: string = null): void {
+ async popOut(
+ win: Window,
+ href: string = null,
+ options: { center?: boolean } = {}
+ ): Promise {
if (href === null) {
href = win.location.href;
}
- if (typeof chrome !== "undefined" && chrome.windows && chrome.windows.create) {
+ if (typeof chrome !== "undefined" && chrome?.windows?.create != null) {
if (href.indexOf("?uilocation=") > -1) {
href = href
.replace("uilocation=popup", "uilocation=popout")
@@ -63,24 +77,43 @@ export class PopupUtilsService {
}
const bodyRect = document.querySelector("body").getBoundingClientRect();
- chrome.windows.create({
+ const width = Math.round(bodyRect.width ? bodyRect.width + 60 : 375);
+ const height = Math.round(bodyRect.height || 600);
+ const top = options.center ? Math.round((screen.height - height) / 2) : undefined;
+ const left = options.center ? Math.round((screen.width - width) / 2) : undefined;
+ const window = await BrowserApi.createWindow({
url: href,
type: "popup",
- width: Math.round(bodyRect.width ? bodyRect.width + 60 : 375),
- height: Math.round(bodyRect.height || 600),
+ width,
+ height,
+ top,
+ left,
});
- if (this.inPopup(win)) {
+ if (win && this.inPopup(win)) {
BrowserApi.closePopup(win);
}
- } else if (typeof chrome !== "undefined" && chrome.tabs && chrome.tabs.create) {
+
+ return { type: "window", window };
+ } else if (chrome?.tabs?.create != null) {
href = href
.replace("uilocation=popup", "uilocation=tab")
.replace("uilocation=popout", "uilocation=tab")
.replace("uilocation=sidebar", "uilocation=tab");
- chrome.tabs.create({
- url: href,
- });
+
+ const tab = await BrowserApi.createNewTab(href);
+ return { type: "tab", tab };
+ } else {
+ throw new Error("Cannot open tab or window");
+ }
+ }
+
+ closePopOut(popout: Popout): Promise {
+ switch (popout.type) {
+ case "window":
+ return BrowserApi.removeWindow(popout.window.id);
+ case "tab":
+ return BrowserApi.removeTab(popout.tab.id);
}
}
diff --git a/apps/browser/src/vault/background/abort-manager.ts b/apps/browser/src/vault/background/abort-manager.ts
new file mode 100644
index 00000000000..8e61ca7a7b4
--- /dev/null
+++ b/apps/browser/src/vault/background/abort-manager.ts
@@ -0,0 +1,21 @@
+type Runner = (abortController: AbortController) => Promise;
+
+/**
+ * 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();
+
+ runWithAbortController(id: string, runner: Runner): Promise {
+ 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();
+ }
+}
diff --git a/apps/browser/src/vault/fido2/browser-fido2-user-interface.service.ts b/apps/browser/src/vault/fido2/browser-fido2-user-interface.service.ts
new file mode 100644
index 00000000000..80af1e05136
--- /dev/null
+++ b/apps/browser/src/vault/fido2/browser-fido2-user-interface.service.ts
@@ -0,0 +1,366 @@
+import { inject } from "@angular/core";
+import { ActivatedRoute } from "@angular/router";
+import {
+ BehaviorSubject,
+ EmptyError,
+ filter,
+ firstValueFrom,
+ fromEvent,
+ fromEventPattern,
+ map,
+ merge,
+ Observable,
+ Subject,
+ switchMap,
+ take,
+ takeUntil,
+ throwError,
+} from "rxjs";
+
+import { Utils } from "@bitwarden/common/platform/misc/utils";
+import { UserRequestedFallbackAbortReason } from "@bitwarden/common/vault/abstractions/fido2/fido2-client.service.abstraction";
+import {
+ Fido2UserInterfaceService as Fido2UserInterfaceServiceAbstraction,
+ Fido2UserInterfaceSession,
+ NewCredentialParams,
+ PickCredentialParams,
+} from "@bitwarden/common/vault/abstractions/fido2/fido2-user-interface.service.abstraction";
+
+import { BrowserApi } from "../../platform/browser/browser-api";
+import { BrowserPopoutWindowService } from "../../platform/popup/abstractions/browser-popout-window.service";
+
+const BrowserFido2MessageName = "BrowserFido2UserInterfaceServiceMessage";
+
+/**
+ * Function to retrieve FIDO2 session data from query parameters.
+ * Expected to be used within components tied to routes with these query parameters.
+ */
+export function fido2PopoutSessionData$() {
+ const route = inject(ActivatedRoute);
+
+ return route.queryParams.pipe(
+ map((queryParams) => ({
+ isFido2Session: queryParams.sessionId != null,
+ sessionId: queryParams.sessionId as string,
+ fallbackSupported: queryParams.fallbackSupported === "true",
+ userVerification: queryParams.userVerification === "true",
+ }))
+ );
+}
+
+export class SessionClosedError extends Error {
+ constructor() {
+ super("Fido2UserInterfaceSession was closed");
+ }
+}
+
+export type BrowserFido2Message = { sessionId: string } & (
+ | /**
+ * This message is used by popouts to announce that they are ready
+ * to recieve messages.
+ **/ {
+ type: "ConnectResponse";
+ }
+ /**
+ * This message is used to announce the creation of a new session.
+ * It is used by popouts to know when to close.
+ **/
+ | {
+ type: "NewSessionCreatedRequest";
+ }
+ | {
+ type: "PickCredentialRequest";
+ cipherIds: string[];
+ userVerification: boolean;
+ fallbackSupported: boolean;
+ }
+ | {
+ type: "PickCredentialResponse";
+ cipherId?: string;
+ userVerified: boolean;
+ }
+ | {
+ type: "ConfirmNewCredentialRequest";
+ credentialName: string;
+ userName: string;
+ userVerification: boolean;
+ fallbackSupported: boolean;
+ }
+ | {
+ type: "ConfirmNewCredentialResponse";
+ cipherId: string;
+ userVerified: boolean;
+ }
+ | {
+ type: "InformExcludedCredentialRequest";
+ existingCipherIds: string[];
+ fallbackSupported: boolean;
+ }
+ | {
+ type: "InformCredentialNotFoundRequest";
+ fallbackSupported: boolean;
+ }
+ | {
+ type: "AbortRequest";
+ }
+ | {
+ type: "AbortResponse";
+ fallbackRequested: boolean;
+ }
+);
+
+/**
+ * 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 browserPopoutWindowService: BrowserPopoutWindowService) {}
+
+ async newSession(
+ fallbackSupported: boolean,
+ tab: chrome.tabs.Tab,
+ abortController?: AbortController
+ ): Promise {
+ return await BrowserFido2UserInterfaceSession.create(
+ this.browserPopoutWindowService,
+ fallbackSupported,
+ tab,
+ abortController
+ );
+ }
+}
+
+export class BrowserFido2UserInterfaceSession implements Fido2UserInterfaceSession {
+ static async create(
+ browserPopoutWindowService: BrowserPopoutWindowService,
+ fallbackSupported: boolean,
+ tab: chrome.tabs.Tab,
+ abortController?: AbortController
+ ): Promise {
+ return new BrowserFido2UserInterfaceSession(
+ browserPopoutWindowService,
+ fallbackSupported,
+ tab,
+ abortController
+ );
+ }
+
+ static sendMessage(msg: BrowserFido2Message) {
+ BrowserApi.sendMessage(BrowserFido2MessageName, msg);
+ }
+
+ static abortPopout(sessionId: string, fallbackRequested = false) {
+ this.sendMessage({
+ sessionId: sessionId,
+ type: "AbortResponse",
+ fallbackRequested: fallbackRequested,
+ });
+ }
+
+ static confirmNewCredentialResponse(sessionId: string, cipherId: string, userVerified: boolean) {
+ this.sendMessage({
+ sessionId: sessionId,
+ type: "ConfirmNewCredentialResponse",
+ cipherId,
+ userVerified,
+ });
+ }
+
+ private closed = false;
+ private messages$ = (BrowserApi.messageListener$() as Observable).pipe(
+ filter((msg) => msg.sessionId === this.sessionId)
+ );
+ private connected$ = new BehaviorSubject(false);
+ private windowClosed$: Observable;
+ private destroy$ = new Subject();
+
+ private constructor(
+ private readonly browserPopoutWindowService: BrowserPopoutWindowService,
+ private readonly fallbackSupported: boolean,
+ private readonly tab: chrome.tabs.Tab,
+ readonly abortController = new AbortController(),
+ readonly sessionId = Utils.newGuid()
+ ) {
+ this.messages$
+ .pipe(
+ filter((msg) => msg.type === "ConnectResponse"),
+ take(1),
+ takeUntil(this.destroy$)
+ )
+ .subscribe(() => {
+ this.connected$.next(true);
+ });
+
+ // Handle session aborted by RP
+ fromEvent(abortController.signal, "abort")
+ .pipe(takeUntil(this.destroy$))
+ .subscribe(() => {
+ this.close();
+ BrowserFido2UserInterfaceSession.sendMessage({
+ type: "AbortRequest",
+ sessionId: this.sessionId,
+ });
+ });
+
+ // Handle session aborted by user
+ this.messages$
+ .pipe(
+ filter((msg) => msg.type === "AbortResponse"),
+ take(1),
+ takeUntil(this.destroy$)
+ )
+ .subscribe((msg) => {
+ if (msg.type === "AbortResponse") {
+ this.close();
+ this.abort(msg.fallbackRequested);
+ }
+ });
+
+ this.windowClosed$ = fromEventPattern(
+ (handler: any) => chrome.windows.onRemoved.addListener(handler),
+ (handler: any) => chrome.windows.onRemoved.removeListener(handler)
+ );
+
+ BrowserFido2UserInterfaceSession.sendMessage({
+ type: "NewSessionCreatedRequest",
+ sessionId,
+ });
+ }
+
+ async pickCredential({
+ cipherIds,
+ userVerification,
+ }: PickCredentialParams): Promise<{ cipherId: string; userVerified: boolean }> {
+ const data: BrowserFido2Message = {
+ type: "PickCredentialRequest",
+ cipherIds,
+ sessionId: this.sessionId,
+ userVerification,
+ fallbackSupported: this.fallbackSupported,
+ };
+
+ await this.send(data);
+ const response = await this.receive("PickCredentialResponse");
+
+ return { cipherId: response.cipherId, userVerified: response.userVerified };
+ }
+
+ async confirmNewCredential({
+ credentialName,
+ userName,
+ userVerification,
+ }: NewCredentialParams): Promise<{ cipherId: string; userVerified: boolean }> {
+ const data: BrowserFido2Message = {
+ type: "ConfirmNewCredentialRequest",
+ sessionId: this.sessionId,
+ credentialName,
+ userName,
+ userVerification,
+ fallbackSupported: this.fallbackSupported,
+ };
+
+ await this.send(data);
+ const response = await this.receive("ConfirmNewCredentialResponse");
+
+ return { cipherId: response.cipherId, userVerified: response.userVerified };
+ }
+
+ async informExcludedCredential(existingCipherIds: string[]): Promise {
+ const data: BrowserFido2Message = {
+ type: "InformExcludedCredentialRequest",
+ sessionId: this.sessionId,
+ existingCipherIds,
+ fallbackSupported: this.fallbackSupported,
+ };
+
+ await this.send(data);
+ await this.receive("AbortResponse");
+ }
+
+ async ensureUnlockedVault(): Promise {
+ await this.connect();
+ }
+
+ async informCredentialNotFound(): Promise {
+ const data: BrowserFido2Message = {
+ type: "InformCredentialNotFoundRequest",
+ sessionId: this.sessionId,
+ fallbackSupported: this.fallbackSupported,
+ };
+
+ await this.send(data);
+ await this.receive("AbortResponse");
+ }
+
+ async close() {
+ await this.browserPopoutWindowService.closeFido2Popout();
+ this.closed = true;
+ this.destroy$.next();
+ this.destroy$.complete();
+ }
+
+ private async abort(fallback = false) {
+ this.abortController.abort(fallback ? UserRequestedFallbackAbortReason : undefined);
+ }
+
+ private async send(msg: BrowserFido2Message): Promise {
+ if (!this.connected$.value) {
+ await this.connect();
+ }
+ BrowserFido2UserInterfaceSession.sendMessage(msg);
+ }
+
+ private async receive(
+ type: T
+ ): Promise {
+ try {
+ const response = await firstValueFrom(
+ this.messages$.pipe(
+ filter((msg) => msg.sessionId === this.sessionId && msg.type === type),
+ takeUntil(this.destroy$)
+ )
+ );
+ return response as BrowserFido2Message & { type: T };
+ } catch (error) {
+ if (error instanceof EmptyError) {
+ throw new SessionClosedError();
+ }
+ throw error;
+ }
+ }
+
+ private async connect(): Promise {
+ if (this.closed) {
+ throw new Error("Cannot re-open closed session");
+ }
+
+ const connectPromise = firstValueFrom(
+ merge(
+ this.connected$.pipe(filter((connected) => connected === true)),
+ fromEvent(this.abortController.signal, "abort").pipe(
+ switchMap(() => throwError(() => new SessionClosedError()))
+ )
+ )
+ );
+
+ const popoutId = await this.browserPopoutWindowService.openFido2Popout(this.tab, {
+ sessionId: this.sessionId,
+ senderTabId: this.tab.id,
+ fallbackSupported: this.fallbackSupported,
+ });
+
+ this.windowClosed$
+ .pipe(
+ filter((windowId) => {
+ return popoutId === windowId;
+ }),
+ takeUntil(this.destroy$)
+ )
+ .subscribe(() => {
+ this.close();
+ this.abort();
+ });
+
+ await connectPromise;
+ }
+}
diff --git a/apps/browser/src/vault/fido2/content/content-script.ts b/apps/browser/src/vault/fido2/content/content-script.ts
new file mode 100644
index 00000000000..bf147c4b58f
--- /dev/null
+++ b/apps/browser/src/vault/fido2/content/content-script.ts
@@ -0,0 +1,81 @@
+import { Message, MessageType } from "./messaging/message";
+import { Messenger } from "./messaging/messenger";
+
+function checkFido2FeatureEnabled() {
+ chrome.runtime.sendMessage(
+ { command: "checkFido2FeatureEnabled" },
+ (response: { result?: boolean }) => initializeFido2ContentScript(response.result)
+ );
+}
+
+function initializeFido2ContentScript(isFido2FeatureEnabled: boolean) {
+ if (isFido2FeatureEnabled !== true) {
+ return;
+ }
+
+ 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);
+
+ messenger.handler = async (message, abortController) => {
+ const requestId = Date.now().toString();
+ const abortHandler = () =>
+ chrome.runtime.sendMessage({
+ command: "fido2AbortRequest",
+ abortedRequestId: requestId,
+ });
+ abortController.signal.addEventListener("abort", abortHandler);
+
+ if (message.type === MessageType.CredentialCreationRequest) {
+ return new Promise((resolve, reject) => {
+ chrome.runtime.sendMessage(
+ {
+ command: "fido2RegisterCredentialRequest",
+ data: message.data,
+ requestId: requestId,
+ },
+ (response) => {
+ if (response.error !== undefined) {
+ return reject(response.error);
+ }
+
+ resolve({
+ type: MessageType.CredentialCreationResponse,
+ result: response.result,
+ });
+ }
+ );
+ });
+ }
+
+ if (message.type === MessageType.CredentialGetRequest) {
+ return new Promise((resolve, reject) => {
+ chrome.runtime.sendMessage(
+ {
+ command: "fido2GetCredentialRequest",
+ data: message.data,
+ requestId: requestId,
+ },
+ (response) => {
+ if (response.error !== undefined) {
+ return reject(response.error);
+ }
+
+ resolve({
+ type: MessageType.CredentialGetResponse,
+ result: response.result,
+ });
+ }
+ );
+ }).finally(() =>
+ abortController.signal.removeEventListener("abort", abortHandler)
+ ) as Promise;
+ }
+
+ return undefined;
+ };
+}
+
+checkFido2FeatureEnabled();
diff --git a/apps/browser/src/vault/fido2/content/messaging/message.ts b/apps/browser/src/vault/fido2/content/messaging/message.ts
new file mode 100644
index 00000000000..01a19a1f8a4
--- /dev/null
+++ b/apps/browser/src/vault/fido2/content/messaging/message.ts
@@ -0,0 +1,60 @@
+import {
+ CreateCredentialParams,
+ CreateCredentialResult,
+ AssertCredentialParams,
+ AssertCredentialResult,
+} from "@bitwarden/common/vault/abstractions/fido2/fido2-client.service.abstraction";
+
+export enum MessageType {
+ CredentialCreationRequest,
+ CredentialCreationResponse,
+ CredentialGetRequest,
+ CredentialGetResponse,
+ AbortRequest,
+ AbortResponse,
+ ErrorResponse,
+}
+
+export type CredentialCreationRequest = {
+ type: MessageType.CredentialCreationRequest;
+ data: CreateCredentialParams;
+};
+
+export type CredentialCreationResponse = {
+ type: MessageType.CredentialCreationResponse;
+ result?: CreateCredentialResult;
+};
+
+export type CredentialGetRequest = {
+ type: MessageType.CredentialGetRequest;
+ data: AssertCredentialParams;
+};
+
+export type CredentialGetResponse = {
+ type: MessageType.CredentialGetResponse;
+ result?: AssertCredentialResult;
+};
+
+export type AbortRequest = {
+ type: MessageType.AbortRequest;
+ abortedRequestId: string;
+};
+
+export type ErrorResponse = {
+ type: MessageType.ErrorResponse;
+ error: string;
+};
+
+export type AbortResponse = {
+ type: MessageType.AbortResponse;
+ abortedRequestId: string;
+};
+
+export type Message =
+ | CredentialCreationRequest
+ | CredentialCreationResponse
+ | CredentialGetRequest
+ | CredentialGetResponse
+ | AbortRequest
+ | AbortResponse
+ | ErrorResponse;
diff --git a/apps/browser/src/vault/fido2/content/messaging/messenger.spec.ts b/apps/browser/src/vault/fido2/content/messaging/messenger.spec.ts
new file mode 100644
index 00000000000..505682d997d
--- /dev/null
+++ b/apps/browser/src/vault/fido2/content/messaging/messenger.spec.ts
@@ -0,0 +1,154 @@
+import { Utils } from "@bitwarden/common/platform/misc/utils";
+
+import { Message } from "./message";
+import { Channel, MessageWithMetadata, Messenger } from "./messenger";
+
+describe("Messenger", () => {
+ let messengerA: Messenger;
+ let messengerB: Messenger;
+ let handlerA: TestMessageHandler;
+ 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);
+
+ handlerA = new TestMessageHandler();
+ handlerB = new TestMessageHandler();
+ messengerA.handler = handlerA.handler;
+ messengerB.handler = handlerB.handler;
+ });
+
+ it("should deliver message to B when sending request from A", () => {
+ const request = createRequest();
+ messengerA.request(request);
+
+ const received = handlerB.recieve();
+
+ expect(received.length).toBe(1);
+ expect(received[0].message).toMatchObject(request);
+ });
+
+ it("should return response from B when sending request from A", async () => {
+ const request = createRequest();
+ const response = createResponse();
+ const requestPromise = messengerA.request(request);
+ const received = handlerB.recieve();
+ received[0].respond(response);
+
+ const returned = await requestPromise;
+
+ expect(returned).toMatchObject(response);
+ });
+
+ it("should throw error from B when sending request from A that fails", async () => {
+ const request = createRequest();
+ const error = new Error("Test error");
+ const requestPromise = messengerA.request(request);
+ const received = handlerB.recieve();
+
+ received[0].reject(error);
+
+ await expect(requestPromise).rejects.toThrow();
+ });
+
+ it("should deliver abort signal to B when requesting abort", () => {
+ const abortController = new AbortController();
+ messengerA.request(createRequest(), abortController);
+ abortController.abort();
+
+ const received = handlerB.recieve();
+
+ expect(received[0].abortController.signal.aborted).toBe(true);
+ });
+});
+
+type TestMessage = MessageWithMetadata & { testId: string };
+
+function createRequest(): TestMessage {
+ return { testId: Utils.newGuid(), type: "TestRequest" } as any;
+}
+
+function createResponse(): TestMessage {
+ return { testId: Utils.newGuid(), type: "TestResponse" } as any;
+}
+
+class TestChannelPair {
+ readonly channelA: Channel;
+ readonly channelB: Channel;
+
+ constructor() {
+ const broadcastChannel = new MockMessageChannel();
+
+ this.channelA = {
+ addEventListener: (listener) => (broadcastChannel.port1.onmessage = listener),
+ postMessage: (message, port) => broadcastChannel.port1.postMessage(message, port),
+ };
+
+ this.channelB = {
+ addEventListener: (listener) => (broadcastChannel.port2.onmessage = listener),
+ postMessage: (message, port) => broadcastChannel.port2.postMessage(message, port),
+ };
+ }
+}
+
+class TestMessageHandler {
+ readonly handler: (
+ message: TestMessage,
+ abortController?: AbortController
+ ) => Promise;
+
+ private recievedMessages: {
+ message: TestMessage;
+ respond: (response: TestMessage) => void;
+ reject: (error: Error) => void;
+ abortController?: AbortController;
+ }[] = [];
+
+ constructor() {
+ this.handler = (message, abortController) =>
+ new Promise((resolve, reject) => {
+ this.recievedMessages.push({
+ message,
+ abortController,
+ respond: (response) => resolve(response),
+ reject: (error) => reject(error),
+ });
+ });
+ }
+
+ recieve() {
+ const received = this.recievedMessages;
+ this.recievedMessages = [];
+ return received;
+ }
+}
+
+class MockMessageChannel {
+ port1 = new MockMessagePort();
+ port2 = new MockMessagePort();
+
+ constructor() {
+ this.port1.remotePort = this.port2;
+ this.port2.remotePort = this.port1;
+ }
+}
+
+class MockMessagePort {
+ onmessage: ((ev: MessageEvent) => any) | null;
+ remotePort: MockMessagePort;
+
+ postMessage(message: T, port?: MessagePort) {
+ this.remotePort.onmessage(
+ new MessageEvent("message", { data: message, ports: port ? [port] : [] })
+ );
+ }
+
+ close() {
+ // Do nothing
+ }
+}
diff --git a/apps/browser/src/vault/fido2/content/messaging/messenger.ts b/apps/browser/src/vault/fido2/content/messaging/messenger.ts
new file mode 100644
index 00000000000..aeb835e2d5f
--- /dev/null
+++ b/apps/browser/src/vault/fido2/content/messaging/messenger.ts
@@ -0,0 +1,130 @@
+import { Message, MessageType } from "./message";
+
+const SENDER = "bitwarden-webauthn";
+
+type PostMessageFunction = (message: MessageWithMetadata, remotePort: MessagePort) => void;
+
+export type Channel = {
+ addEventListener: (listener: (message: MessageEvent) => void) => void;
+ postMessage: PostMessageFunction;
+};
+
+export type Metadata = { SENDER: typeof SENDER };
+export type MessageWithMetadata = Message & Metadata;
+type Handler = (
+ message: MessageWithMetadata,
+ abortController?: AbortController
+) => Promise;
+
+/**
+ * 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, port) => window.postMessage(message, windowOrigin, [port]),
+ addEventListener: (listener) =>
+ window.addEventListener("message", (event: MessageEvent) => {
+ if (event.origin !== windowOrigin) {
+ return;
+ }
+
+ listener(event as MessageEvent);
+ }),
+ });
+ }
+
+ /**
+ * 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;
+
+ 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) => {
+ 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 {
+ const requestChannel = new MessageChannel();
+ const { port1: localPort, port2: remotePort } = requestChannel;
+
+ try {
+ const promise = new Promise((resolve) => {
+ localPort.onmessage = (event: MessageEvent) => resolve(event.data);
+ });
+
+ const abortListener = () =>
+ localPort.postMessage({
+ metadata: { SENDER },
+ type: MessageType.AbortRequest,
+ });
+ abortController?.signal.addEventListener("abort", abortListener);
+
+ this.broadcastChannel.postMessage({ ...request, SENDER }, remotePort);
+ const response = await promise;
+
+ 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();
+ }
+ }
+}
diff --git a/apps/browser/src/vault/fido2/content/page-script.ts b/apps/browser/src/vault/fido2/content/page-script.ts
new file mode 100644
index 00000000000..1f5d98289ba
--- /dev/null
+++ b/apps/browser/src/vault/fido2/content/page-script.ts
@@ -0,0 +1,140 @@
+import { FallbackRequestedError } from "@bitwarden/common/vault/abstractions/fido2/fido2-client.service.abstraction";
+
+import { WebauthnUtils } from "../webauthn-utils";
+
+import { MessageType } from "./messaging/message";
+import { Messenger } from "./messaging/messenger";
+
+const BrowserPublicKeyCredential = window.PublicKeyCredential;
+
+const browserNativeWebauthnSupport = window.PublicKeyCredential != undefined;
+let browserNativeWebauthnPlatformAuthenticatorSupport = false;
+if (!browserNativeWebauthnSupport) {
+ // Polyfill webauthn support
+ try {
+ // credentials is read-only if supported, use type-casting to force assignment
+ (navigator as any).credentials = {
+ async create() {
+ throw new Error("Webauthn not supported in this browser.");
+ },
+ async get() {
+ throw new Error("Webauthn not supported in this browser.");
+ },
+ };
+ window.PublicKeyCredential = class PolyfillPublicKeyCredential {
+ static isUserVerifyingPlatformAuthenticatorAvailable() {
+ return Promise.resolve(true);
+ }
+ } as any;
+ window.AuthenticatorAttestationResponse =
+ class PolyfillAuthenticatorAttestationResponse {} as any;
+ } catch {
+ /* empty */
+ }
+}
+
+if (browserNativeWebauthnSupport) {
+ BrowserPublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable().then((available) => {
+ browserNativeWebauthnPlatformAuthenticatorSupport = available;
+
+ if (!available) {
+ // Polyfill platform authenticator support
+ window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable = () =>
+ Promise.resolve(true);
+ }
+ });
+}
+
+const browserCredentials = {
+ create: navigator.credentials.create.bind(
+ navigator.credentials
+ ) as typeof navigator.credentials.create,
+ get: navigator.credentials.get.bind(navigator.credentials) as typeof navigator.credentials.get,
+};
+
+const messenger = Messenger.forDOMCommunication(window);
+
+function isSameOriginWithAncestors() {
+ try {
+ return window.self === window.top;
+ } catch {
+ return false;
+ }
+}
+
+navigator.credentials.create = async (
+ options?: CredentialCreationOptions,
+ abortController?: AbortController
+): Promise => {
+ const fallbackSupported =
+ (options?.publicKey?.authenticatorSelection.authenticatorAttachment === "platform" &&
+ browserNativeWebauthnPlatformAuthenticatorSupport) ||
+ (options?.publicKey?.authenticatorSelection.authenticatorAttachment !== "platform" &&
+ browserNativeWebauthnSupport);
+ try {
+ const isNotIframe = isSameOriginWithAncestors();
+
+ const response = await messenger.request(
+ {
+ type: MessageType.CredentialCreationRequest,
+ data: WebauthnUtils.mapCredentialCreationOptions(
+ options,
+ window.location.origin,
+ isNotIframe,
+ fallbackSupported
+ ),
+ },
+ abortController
+ );
+
+ if (response.type !== MessageType.CredentialCreationResponse) {
+ throw new Error("Something went wrong.");
+ }
+
+ return WebauthnUtils.mapCredentialRegistrationResult(response.result);
+ } catch (error) {
+ if (error && error.fallbackRequested && fallbackSupported) {
+ return await browserCredentials.create(options);
+ }
+
+ throw error;
+ }
+};
+
+navigator.credentials.get = async (
+ options?: CredentialRequestOptions,
+ abortController?: AbortController
+): Promise => {
+ const fallbackSupported = browserNativeWebauthnSupport;
+
+ try {
+ if (options?.mediation && options.mediation !== "optional") {
+ throw new FallbackRequestedError();
+ }
+
+ const response = await messenger.request(
+ {
+ type: MessageType.CredentialGetRequest,
+ data: WebauthnUtils.mapCredentialRequestOptions(
+ options,
+ window.location.origin,
+ true,
+ fallbackSupported
+ ),
+ },
+ abortController
+ );
+
+ if (response.type !== MessageType.CredentialGetResponse) {
+ throw new Error("Something went wrong.");
+ }
+
+ return WebauthnUtils.mapCredentialAssertResult(response.result);
+ } catch (error) {
+ if (error && error.fallbackRequested && fallbackSupported) {
+ return await browserCredentials.get(options);
+ }
+
+ throw error;
+ }
+};
diff --git a/apps/browser/src/vault/fido2/webauthn-utils.ts b/apps/browser/src/vault/fido2/webauthn-utils.ts
new file mode 100644
index 00000000000..2422736077f
--- /dev/null
+++ b/apps/browser/src/vault/fido2/webauthn-utils.ts
@@ -0,0 +1,141 @@
+import {
+ CreateCredentialParams,
+ CreateCredentialResult,
+ AssertCredentialParams,
+ AssertCredentialResult,
+} from "@bitwarden/common/vault/abstractions/fido2/fido2-client.service.abstraction";
+import { Fido2Utils } from "@bitwarden/common/vault/services/fido2/fido2-utils";
+
+export class WebauthnUtils {
+ static mapCredentialCreationOptions(
+ options: CredentialCreationOptions,
+ origin: string,
+ sameOriginWithAncestors: boolean,
+ fallbackSupported: boolean
+ ): CreateCredentialParams {
+ const keyOptions = options.publicKey;
+
+ if (keyOptions == undefined) {
+ throw new Error("Public-key options not found");
+ }
+
+ return {
+ origin,
+ attestation: keyOptions.attestation,
+ authenticatorSelection: {
+ requireResidentKey: keyOptions.authenticatorSelection?.requireResidentKey,
+ residentKey: keyOptions.authenticatorSelection?.residentKey,
+ userVerification: keyOptions.authenticatorSelection?.userVerification,
+ },
+ challenge: Fido2Utils.bufferToString(keyOptions.challenge),
+ excludeCredentials: keyOptions.excludeCredentials?.map((credential) => ({
+ id: Fido2Utils.bufferToString(credential.id),
+ transports: credential.transports,
+ type: credential.type,
+ })),
+ extensions: undefined, // extensions not currently supported
+ pubKeyCredParams: keyOptions.pubKeyCredParams.map((params) => ({
+ alg: params.alg,
+ type: params.type,
+ })),
+ rp: {
+ id: keyOptions.rp.id,
+ name: keyOptions.rp.name,
+ },
+ user: {
+ id: Fido2Utils.bufferToString(keyOptions.user.id),
+ displayName: keyOptions.user.displayName,
+ },
+ timeout: keyOptions.timeout,
+ sameOriginWithAncestors,
+ fallbackSupported,
+ };
+ }
+
+ static mapCredentialRegistrationResult(result: CreateCredentialResult): PublicKeyCredential {
+ const credential = {
+ id: result.credentialId,
+ rawId: Fido2Utils.stringToBuffer(result.credentialId),
+ type: "public-key",
+ authenticatorAttachment: "cross-platform",
+ response: {
+ clientDataJSON: Fido2Utils.stringToBuffer(result.clientDataJSON),
+ attestationObject: Fido2Utils.stringToBuffer(result.attestationObject),
+
+ getAuthenticatorData(): ArrayBuffer {
+ return Fido2Utils.stringToBuffer(result.authData);
+ },
+
+ getPublicKey(): ArrayBuffer {
+ return null;
+ },
+
+ getPublicKeyAlgorithm(): number {
+ return result.publicKeyAlgorithm;
+ },
+
+ getTransports(): string[] {
+ return result.transports;
+ },
+ } as AuthenticatorAttestationResponse,
+ getClientExtensionResults: () => ({}),
+ } as PublicKeyCredential;
+
+ // Modify prototype chains to fix `instanceof` calls.
+ // This makes these objects indistinguishable from the native classes.
+ // Unfortunately PublicKeyCredential does not have a javascript constructor so `extends` does not work here.
+ Object.setPrototypeOf(credential.response, AuthenticatorAttestationResponse.prototype);
+ Object.setPrototypeOf(credential, PublicKeyCredential.prototype);
+
+ return credential;
+ }
+
+ static mapCredentialRequestOptions(
+ options: CredentialRequestOptions,
+ origin: string,
+ sameOriginWithAncestors: boolean,
+ fallbackSupported: boolean
+ ): AssertCredentialParams {
+ const keyOptions = options.publicKey;
+
+ if (keyOptions == undefined) {
+ throw new Error("Public-key options not found");
+ }
+
+ return {
+ origin,
+ allowedCredentialIds:
+ keyOptions.allowCredentials?.map((c) => Fido2Utils.bufferToString(c.id)) ?? [],
+ challenge: Fido2Utils.bufferToString(keyOptions.challenge),
+ rpId: keyOptions.rpId,
+ userVerification: keyOptions.userVerification,
+ timeout: keyOptions.timeout,
+ sameOriginWithAncestors,
+ fallbackSupported,
+ };
+ }
+
+ static mapCredentialAssertResult(result: AssertCredentialResult): PublicKeyCredential {
+ const credential = {
+ id: result.credentialId,
+ rawId: Fido2Utils.stringToBuffer(result.credentialId),
+ type: "public-key",
+ response: {
+ authenticatorData: Fido2Utils.stringToBuffer(result.authenticatorData),
+ clientDataJSON: Fido2Utils.stringToBuffer(result.clientDataJSON),
+ signature: Fido2Utils.stringToBuffer(result.signature),
+ userHandle: Fido2Utils.stringToBuffer(result.userHandle),
+ } as AuthenticatorAssertionResponse,
+ getClientExtensionResults: () => ({}),
+ authenticatorAttachment: "cross-platform",
+ } as PublicKeyCredential;
+
+ // Modify prototype chains to fix `instanceof` calls.
+ // This makes these objects indistinguishable from the native classes.
+ // Unfortunately PublicKeyCredential does not have a javascript constructor so `extends` does not work here.
+ Object.setPrototypeOf(credential.response, AuthenticatorAssertionResponse.prototype);
+ Object.setPrototypeOf(credential, PublicKeyCredential.prototype);
+
+ return credential;
+ }
+}
diff --git a/apps/browser/src/vault/popup/components/fido2/fido2-cipher-row.component.html b/apps/browser/src/vault/popup/components/fido2/fido2-cipher-row.component.html
new file mode 100644
index 00000000000..42e8a6b6298
--- /dev/null
+++ b/apps/browser/src/vault/popup/components/fido2/fido2-cipher-row.component.html
@@ -0,0 +1,27 @@
+
diff --git a/apps/browser/src/vault/popup/components/fido2/fido2-cipher-row.component.ts b/apps/browser/src/vault/popup/components/fido2/fido2-cipher-row.component.ts
new file mode 100644
index 00000000000..21ff136bf42
--- /dev/null
+++ b/apps/browser/src/vault/popup/components/fido2/fido2-cipher-row.component.ts
@@ -0,0 +1,20 @@
+import { Component, EventEmitter, Input, Output } from "@angular/core";
+
+import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
+
+@Component({
+ selector: "app-fido2-cipher-row",
+ templateUrl: "fido2-cipher-row.component.html",
+})
+export class Fido2CipherRowComponent {
+ @Output() onSelected = new EventEmitter();
+ @Input() cipher: CipherView;
+ @Input() last: boolean;
+ @Input() title: string;
+ @Input() isSearching: boolean;
+ @Input() isSelected: boolean;
+
+ selectCipher(c: CipherView) {
+ this.onSelected.emit(c);
+ }
+}
diff --git a/apps/browser/src/vault/popup/components/fido2/fido2-use-browser-link.component.html b/apps/browser/src/vault/popup/components/fido2/fido2-use-browser-link.component.html
new file mode 100644
index 00000000000..3e71675aa2c
--- /dev/null
+++ b/apps/browser/src/vault/popup/components/fido2/fido2-use-browser-link.component.html
@@ -0,0 +1,5 @@
+
+
+
diff --git a/apps/browser/src/vault/popup/components/fido2/fido2-use-browser-link.component.ts b/apps/browser/src/vault/popup/components/fido2/fido2-use-browser-link.component.ts
new file mode 100644
index 00000000000..712f728c320
--- /dev/null
+++ b/apps/browser/src/vault/popup/components/fido2/fido2-use-browser-link.component.ts
@@ -0,0 +1,21 @@
+import { Component } from "@angular/core";
+import { firstValueFrom } from "rxjs";
+
+import {
+ BrowserFido2UserInterfaceSession,
+ fido2PopoutSessionData$,
+} from "../../../fido2/browser-fido2-user-interface.service";
+
+@Component({
+ selector: "app-fido2-use-browser-link",
+ templateUrl: "fido2-use-browser-link.component.html",
+})
+export class Fido2UseBrowserLinkComponent {
+ fido2PopoutSessionData$ = fido2PopoutSessionData$();
+
+ async abort() {
+ const sessionData = await firstValueFrom(this.fido2PopoutSessionData$);
+ BrowserFido2UserInterfaceSession.abortPopout(sessionData.sessionId, true);
+ return;
+ }
+}
diff --git a/apps/browser/src/vault/popup/components/fido2/fido2.component.html b/apps/browser/src/vault/popup/components/fido2/fido2.component.html
new file mode 100644
index 00000000000..0f298b67fb6
--- /dev/null
+++ b/apps/browser/src/vault/popup/components/fido2/fido2.component.html
@@ -0,0 +1,139 @@
+
+
+
+
+
+
+
+
+ {{ subtitleText | i18n }}
+
+
+
0">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ "passkeyAlreadyExists" | i18n }}
+
+
+
+
+
+
+
{{ "noPasskeysFoundForThisApplication" | i18n }}
+
+
+
+
+
+
+
+
+
diff --git a/apps/browser/src/vault/popup/components/fido2/fido2.component.ts b/apps/browser/src/vault/popup/components/fido2/fido2.component.ts
new file mode 100644
index 00000000000..ed0ddbd1443
--- /dev/null
+++ b/apps/browser/src/vault/popup/components/fido2/fido2.component.ts
@@ -0,0 +1,427 @@
+import { Component, OnDestroy, OnInit } from "@angular/core";
+import { ActivatedRoute, Router } from "@angular/router";
+import {
+ BehaviorSubject,
+ combineLatest,
+ concatMap,
+ filter,
+ map,
+ Observable,
+ Subject,
+ take,
+ takeUntil,
+} from "rxjs";
+
+import { SearchService } from "@bitwarden/common/abstractions/search.service";
+import { SettingsService } from "@bitwarden/common/abstractions/settings.service";
+import { SecureNoteType } from "@bitwarden/common/enums";
+import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
+import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
+import { Utils } from "@bitwarden/common/platform/misc/utils";
+import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
+import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type";
+import { CipherType } from "@bitwarden/common/vault/enums/cipher-type";
+import { CardView } from "@bitwarden/common/vault/models/view/card.view";
+import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
+import { IdentityView } from "@bitwarden/common/vault/models/view/identity.view";
+import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view";
+import { LoginView } from "@bitwarden/common/vault/models/view/login.view";
+import { SecureNoteView } from "@bitwarden/common/vault/models/view/secure-note.view";
+import { DialogService } from "@bitwarden/components";
+import { PasswordRepromptService } from "@bitwarden/vault";
+
+import { BrowserApi } from "../../../../platform/browser/browser-api";
+import {
+ BrowserFido2Message,
+ BrowserFido2UserInterfaceSession,
+} from "../../../fido2/browser-fido2-user-interface.service";
+
+interface ViewData {
+ message: BrowserFido2Message;
+ fallbackSupported: boolean;
+}
+
+@Component({
+ selector: "app-fido2",
+ templateUrl: "fido2.component.html",
+ styleUrls: [],
+})
+export class Fido2Component implements OnInit, OnDestroy {
+ private destroy$ = new Subject();
+ private hasSearched = false;
+ private searchTimeout: any = null;
+ private hasLoadedAllCiphers = false;
+
+ protected cipher: CipherView;
+ protected searchTypeSearch = false;
+ protected searchPending = false;
+ protected searchText: string;
+ protected url: string;
+ protected hostname: string;
+ protected data$: Observable;
+ protected sessionId?: string;
+ protected senderTabId?: string;
+ protected ciphers?: CipherView[] = [];
+ protected displayedCiphers?: CipherView[] = [];
+ protected loading = false;
+ protected subtitleText: string;
+ protected credentialText: string;
+
+ private message$ = new BehaviorSubject(null);
+
+ constructor(
+ private router: Router,
+ private activatedRoute: ActivatedRoute,
+ private cipherService: CipherService,
+ private passwordRepromptService: PasswordRepromptService,
+ private platformUtilsService: PlatformUtilsService,
+ private settingsService: SettingsService,
+ private searchService: SearchService,
+ private logService: LogService,
+ private dialogService: DialogService
+ ) {}
+
+ ngOnInit() {
+ this.searchTypeSearch = !this.platformUtilsService.isSafari();
+
+ const queryParams$ = this.activatedRoute.queryParamMap.pipe(
+ take(1),
+ map((queryParamMap) => ({
+ sessionId: queryParamMap.get("sessionId"),
+ senderTabId: queryParamMap.get("senderTabId"),
+ senderUrl: queryParamMap.get("senderUrl"),
+ }))
+ );
+
+ combineLatest([queryParams$, BrowserApi.messageListener$() as Observable])
+ .pipe(
+ concatMap(async ([queryParams, message]) => {
+ this.sessionId = queryParams.sessionId;
+ this.senderTabId = queryParams.senderTabId;
+ this.url = queryParams.senderUrl;
+ // For a 'NewSessionCreatedRequest', abort if it doesn't belong to the current session.
+ if (
+ message.type === "NewSessionCreatedRequest" &&
+ message.sessionId !== queryParams.sessionId
+ ) {
+ this.abort(false);
+ return;
+ }
+
+ // Ignore messages that don't belong to the current session.
+ if (message.sessionId !== queryParams.sessionId) {
+ return;
+ }
+
+ if (message.type === "AbortRequest") {
+ this.abort(false);
+ return;
+ }
+
+ // Show dialog if user account does not have master password
+ if (!(await this.passwordRepromptService.enabled())) {
+ await this.dialogService.openSimpleDialog({
+ title: { key: "featureNotSupported" },
+ content: { key: "passkeyFeatureIsNotImplementedForAccountsWithoutMasterPassword" },
+ acceptButtonText: { key: "ok" },
+ cancelButtonText: null,
+ type: "info",
+ });
+
+ this.abort(true);
+ return;
+ }
+
+ return message;
+ }),
+ filter((message) => !!message),
+ takeUntil(this.destroy$)
+ )
+ .subscribe((message) => {
+ this.message$.next(message);
+ });
+
+ this.data$ = this.message$.pipe(
+ filter((message) => message != undefined),
+ concatMap(async (message) => {
+ switch (message.type) {
+ case "ConfirmNewCredentialRequest": {
+ const equivalentDomains = this.settingsService.getEquivalentDomains(this.url);
+
+ this.ciphers = (await this.cipherService.getAllDecrypted()).filter(
+ (cipher) => cipher.type === CipherType.Login && !cipher.isDeleted
+ );
+ this.displayedCiphers = this.ciphers.filter((cipher) =>
+ cipher.login.matchesUri(this.url, equivalentDomains)
+ );
+
+ if (this.displayedCiphers.length > 0) {
+ this.selectedPasskey(this.displayedCiphers[0]);
+ }
+ break;
+ }
+
+ case "PickCredentialRequest": {
+ this.ciphers = await Promise.all(
+ message.cipherIds.map(async (cipherId) => {
+ const cipher = await this.cipherService.get(cipherId);
+ return cipher.decrypt(
+ await this.cipherService.getKeyForCipherKeyDecryption(cipher)
+ );
+ })
+ );
+ this.displayedCiphers = [...this.ciphers];
+ if (this.displayedCiphers.length > 0) {
+ this.selectedPasskey(this.displayedCiphers[0]);
+ }
+ break;
+ }
+
+ case "InformExcludedCredentialRequest": {
+ this.ciphers = await Promise.all(
+ message.existingCipherIds.map(async (cipherId) => {
+ const cipher = await this.cipherService.get(cipherId);
+ return cipher.decrypt(
+ await this.cipherService.getKeyForCipherKeyDecryption(cipher)
+ );
+ })
+ );
+ this.displayedCiphers = [...this.ciphers];
+
+ if (this.displayedCiphers.length > 0) {
+ this.selectedPasskey(this.displayedCiphers[0]);
+ }
+ break;
+ }
+ }
+
+ this.subtitleText =
+ this.displayedCiphers.length > 0
+ ? this.getCredentialSubTitleText(message.type)
+ : "noMatchingPasskeyLogin";
+
+ this.credentialText = this.getCredentialButtonText(message.type);
+ return {
+ message,
+ fallbackSupported: "fallbackSupported" in message && message.fallbackSupported,
+ };
+ }),
+ takeUntil(this.destroy$)
+ );
+
+ queryParams$.pipe(takeUntil(this.destroy$)).subscribe((queryParams) => {
+ this.send({
+ sessionId: queryParams.sessionId,
+ type: "ConnectResponse",
+ });
+ });
+ }
+
+ async submit() {
+ const data = this.message$.value;
+ if (data?.type === "PickCredentialRequest") {
+ const userVerified = await this.handleUserVerification(data.userVerification, this.cipher);
+
+ this.send({
+ sessionId: this.sessionId,
+ cipherId: this.cipher.id,
+ type: "PickCredentialResponse",
+ userVerified,
+ });
+ } else if (data?.type === "ConfirmNewCredentialRequest") {
+ if (this.cipher.login.hasFido2Credentials) {
+ const confirmed = await this.dialogService.openSimpleDialog({
+ title: { key: "overwritePasskey" },
+ content: { key: "overwritePasskeyAlert" },
+ type: "info",
+ });
+
+ if (!confirmed) {
+ return false;
+ }
+ }
+
+ const userVerified = await this.handleUserVerification(data.userVerification, this.cipher);
+
+ this.send({
+ sessionId: this.sessionId,
+ cipherId: this.cipher.id,
+ type: "ConfirmNewCredentialResponse",
+ userVerified,
+ });
+ }
+
+ this.loading = true;
+ }
+
+ async saveNewLogin() {
+ const data = this.message$.value;
+ if (data?.type === "ConfirmNewCredentialRequest") {
+ let userVerified = false;
+ if (data.userVerification) {
+ userVerified = await this.passwordRepromptService.showPasswordPrompt();
+ }
+
+ if (!data.userVerification || userVerified) {
+ await this.createNewCipher();
+ }
+
+ this.send({
+ sessionId: this.sessionId,
+ cipherId: this.cipher?.id,
+ type: "ConfirmNewCredentialResponse",
+ userVerified,
+ });
+ }
+
+ this.loading = true;
+ }
+
+ getCredentialSubTitleText(messageType: string): string {
+ return messageType == "ConfirmNewCredentialRequest" ? "choosePasskey" : "logInWithPasskey";
+ }
+
+ getCredentialButtonText(messageType: string): string {
+ return messageType == "ConfirmNewCredentialRequest" ? "savePasskey" : "confirm";
+ }
+
+ selectedPasskey(item: CipherView) {
+ this.cipher = item;
+ }
+
+ viewPasskey() {
+ this.router.navigate(["/view-cipher"], {
+ queryParams: {
+ cipherId: this.cipher.id,
+ uilocation: "popout",
+ senderTabId: this.senderTabId,
+ sessionId: this.sessionId,
+ },
+ });
+ }
+
+ addCipher() {
+ const data = this.message$.value;
+
+ if (data?.type !== "ConfirmNewCredentialRequest") {
+ return;
+ }
+
+ this.router.navigate(["/add-cipher"], {
+ queryParams: {
+ name: Utils.getHostname(this.url),
+ uri: this.url,
+ uilocation: "popout",
+ senderTabId: this.senderTabId,
+ sessionId: this.sessionId,
+ userVerification: data.userVerification,
+ },
+ });
+ }
+
+ async loadLoginCiphers() {
+ this.ciphers = (await this.cipherService.getAllDecrypted()).filter(
+ (cipher) => cipher.type === CipherType.Login && !cipher.isDeleted
+ );
+ if (!this.hasLoadedAllCiphers) {
+ this.hasLoadedAllCiphers = !this.searchService.isSearchable(this.searchText);
+ }
+ await this.search(null);
+ }
+
+ async search(timeout: number = null) {
+ this.searchPending = false;
+ if (this.searchTimeout != null) {
+ clearTimeout(this.searchTimeout);
+ }
+
+ if (timeout == null) {
+ this.hasSearched = this.searchService.isSearchable(this.searchText);
+ this.displayedCiphers = await this.searchService.searchCiphers(
+ this.searchText,
+ null,
+ this.ciphers
+ );
+ return;
+ }
+ this.searchPending = true;
+ this.searchTimeout = setTimeout(async () => {
+ this.hasSearched = this.searchService.isSearchable(this.searchText);
+ if (!this.hasLoadedAllCiphers && !this.hasSearched) {
+ await this.loadLoginCiphers();
+ } else {
+ this.displayedCiphers = await this.searchService.searchCiphers(
+ this.searchText,
+ null,
+ this.ciphers
+ );
+ }
+ this.searchPending = false;
+ this.selectedPasskey(this.displayedCiphers[0]);
+ }, timeout);
+ }
+
+ abort(fallback: boolean) {
+ this.unload(fallback);
+ window.close();
+ }
+
+ unload(fallback = false) {
+ this.send({
+ sessionId: this.sessionId,
+ type: "AbortResponse",
+ fallbackRequested: fallback,
+ });
+ }
+
+ ngOnDestroy(): void {
+ this.destroy$.next();
+ this.destroy$.complete();
+ }
+
+ private buildCipher() {
+ this.cipher = new CipherView();
+ this.cipher.name = Utils.getHostname(this.url);
+ this.cipher.type = CipherType.Login;
+ this.cipher.login = new LoginView();
+ this.cipher.login.uris = [new LoginUriView()];
+ this.cipher.login.uris[0].uri = this.url;
+ this.cipher.card = new CardView();
+ this.cipher.identity = new IdentityView();
+ this.cipher.secureNote = new SecureNoteView();
+ this.cipher.secureNote.type = SecureNoteType.Generic;
+ this.cipher.reprompt = CipherRepromptType.None;
+ }
+
+ private async createNewCipher() {
+ this.buildCipher();
+ const cipher = await this.cipherService.encrypt(this.cipher);
+ try {
+ await this.cipherService.createWithServer(cipher);
+ this.cipher.id = cipher.id;
+ } catch (e) {
+ this.logService.error(e);
+ }
+ }
+
+ private async handleUserVerification(
+ userVerification: boolean,
+ cipher: CipherView
+ ): Promise {
+ const masterPasswordRepromptRequiered = cipher && cipher.reprompt !== 0;
+ const verificationRequired = userVerification || masterPasswordRepromptRequiered;
+
+ if (!verificationRequired) {
+ return false;
+ }
+
+ return await this.passwordRepromptService.showPasswordPrompt();
+ }
+
+ private send(msg: BrowserFido2Message) {
+ BrowserFido2UserInterfaceSession.sendMessage({
+ sessionId: this.sessionId,
+ ...msg,
+ });
+ }
+}
diff --git a/apps/browser/src/vault/popup/components/vault/add-edit.component.html b/apps/browser/src/vault/popup/components/vault/add-edit.component.html
index dda71cb0d6e..b2a42776e18 100644
--- a/apps/browser/src/vault/popup/components/vault/add-edit.component.html
+++ b/apps/browser/src/vault/popup/components/vault/add-edit.component.html
@@ -129,6 +129,18 @@