diff --git a/apps/browser/src/autofill/popup/fido2/fido2.component.ts b/apps/browser/src/autofill/popup/fido2/fido2.component.ts index 0471d460fd5..2bd16bf8a2e 100644 --- a/apps/browser/src/autofill/popup/fido2/fido2.component.ts +++ b/apps/browser/src/autofill/popup/fido2/fido2.component.ts @@ -24,6 +24,7 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { Fido2Utils } from "@bitwarden/common/platform/services/fido2/fido2-utils"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { SecureNoteType, CipherType } from "@bitwarden/common/vault/enums"; import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type"; @@ -197,7 +198,7 @@ export class Fido2Component implements OnInit, OnDestroy { this.displayedCiphers = this.ciphers.filter( (cipher) => cipher.login.matchesUri(this.url, equivalentDomains) && - this.cipherHasNoOtherPasskeys(cipher, message.userHandle), + Fido2Utils.cipherHasNoOtherPasskeys(cipher, message.userHandle), ); this.passkeyAction = PasskeyActions.Register; @@ -475,16 +476,4 @@ export class Fido2Component implements OnInit, OnDestroy { ...msg, }); } - - /** - * This methods returns true if a cipher either has no passkeys, or has a passkey matching with userHandle - * @param userHandle - */ - private cipherHasNoOtherPasskeys(cipher: CipherView, userHandle: string): boolean { - if (cipher.login.fido2Credentials == null || cipher.login.fido2Credentials.length === 0) { - return true; - } - - return cipher.login.fido2Credentials.some((passkey) => passkey.userHandle === userHandle); - } } diff --git a/apps/desktop/src/app/app-routing.module.ts b/apps/desktop/src/app/app-routing.module.ts index 288335df974..ca4b391579e 100644 --- a/apps/desktop/src/app/app-routing.module.ts +++ b/apps/desktop/src/app/app-routing.module.ts @@ -56,6 +56,7 @@ import { SetPasswordComponent } from "../auth/set-password.component"; import { UpdateTempPasswordComponent } from "../auth/update-temp-password.component"; import { RemovePasswordComponent } from "../key-management/key-connector/remove-password.component"; import { Fido2CreateComponent } from "../modal/passkeys/create/fido2-create.component"; +import { Fido2ExcludedCiphersComponent } from "../modal/passkeys/fido2-excluded-ciphers.component"; import { Fido2VaultComponent } from "../modal/passkeys/fido2-vault.component"; import { VaultV2Component } from "../vault/app/vault/vault-v2.component"; import { VaultComponent } from "../vault/app/vault/vault.component"; @@ -170,6 +171,10 @@ const routes: Routes = [ path: "fido2-creation", component: Fido2CreateComponent, }, + { + path: "fido2-excluded", + component: Fido2ExcludedCiphersComponent, + }, { path: "", component: AnonLayoutWrapperComponent, diff --git a/apps/desktop/src/autofill/services/desktop-autofill.service.ts b/apps/desktop/src/autofill/services/desktop-autofill.service.ts index 464d60eecfd..54b68066a4e 100644 --- a/apps/desktop/src/autofill/services/desktop-autofill.service.ts +++ b/apps/desktop/src/autofill/services/desktop-autofill.service.ts @@ -49,6 +49,7 @@ import type { NativeWindowObject } from "./desktop-fido2-user-interface.service" @Injectable() export class DesktopAutofillService implements OnDestroy { private destroy$ = new Subject(); + private registrationRequest: autofill.PasskeyRegistrationRequest; constructor( private logService: LogService, @@ -184,8 +185,14 @@ export class DesktopAutofillService implements OnDestroy { }); } + get lastRegistrationRequest() { + return this.registrationRequest; + } + listenIpc() { ipc.autofill.listenPasskeyRegistration(async (clientId, sequenceNumber, request, callback) => { + this.registrationRequest = request; + this.logService.warning("listenPasskeyRegistration", clientId, sequenceNumber, request); this.logService.warning( "listenPasskeyRegistration2", diff --git a/apps/desktop/src/autofill/services/desktop-fido2-user-interface.service.ts b/apps/desktop/src/autofill/services/desktop-fido2-user-interface.service.ts index 35ac1fe9571..6bce6732c09 100644 --- a/apps/desktop/src/autofill/services/desktop-fido2-user-interface.service.ts +++ b/apps/desktop/src/autofill/services/desktop-fido2-user-interface.service.ts @@ -138,7 +138,7 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi // make the cipherIds available to the UI. this.availableCipherIdsSubject.next(cipherIds); - await this.showUi("/fido2-assertion", this.windowObject.windowXy); + await this.showUi("/fido2-assertion", this.windowObject.windowXy, false); const chosenCipherResponse = await this.waitForUiChosenCipher(); @@ -224,7 +224,7 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi this.rpId.next(rpId); try { - await this.showUi("/fido2-creation", this.windowObject.windowXy); + await this.showUi("/fido2-creation", this.windowObject.windowXy, false); // Wait for the UI to wrap up const confirmation = await this.waitForUiNewCredentialConfirmation(); @@ -260,10 +260,11 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi private async showUi( route: string, position?: { x: number; y: number }, + showTrafficButtons?: boolean, disableRedirect?: boolean, ): Promise { // Load the UI: - await this.desktopSettingsService.setModalMode(true, position); + await this.desktopSettingsService.setModalMode(true, showTrafficButtons, position); await this.router.navigate([ route, { @@ -328,6 +329,11 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi async informExcludedCredential(existingCipherIds: string[]): Promise { this.logService.warning("informExcludedCredential", existingCipherIds); + + // make the cipherIds available to the UI. + this.availableCipherIdsSubject.next(existingCipherIds); + + await this.showUi("/fido2-excluded", this.windowObject.windowXy, false); } async ensureUnlockedVault(): Promise { @@ -335,7 +341,7 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi const status = await firstValueFrom(this.authService.activeAccountStatus$); if (status !== AuthenticationStatus.Unlocked) { - await this.showUi("/lock", this.windowObject.windowXy, true); + await this.showUi("/lock", this.windowObject.windowXy, true, true); let status2: AuthenticationStatus; try { diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index 4adb3338aea..f31763429a9 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -3704,9 +3704,21 @@ "saveNewPasskey": { "message": "Save as new login" }, + "overwritePasskey": { + "message": "Overwrite passkey?" + }, "unableToSavePasskey": { "message": "Unable to save passkey" }, + "alreadyContainsPasskey": { + "message": "This item already contains a passkey. Are you sure you want to overwrite the current passkey?" + }, + "passkeyAlreadyExists": { + "message": "A passkey already exists for this application." + }, + "applicationDoesNotSupportDuplicates": { + "message": "This application does not support duplicates." + }, "closeBitwarden": { "message": "Close Bitwarden" }, diff --git a/apps/desktop/src/main/window.main.ts b/apps/desktop/src/main/window.main.ts index ea8ee6aeecd..74a148b87c1 100644 --- a/apps/desktop/src/main/window.main.ts +++ b/apps/desktop/src/main/window.main.ts @@ -90,7 +90,7 @@ export class WindowMain { } else if (newValue.isModalModeActive) { // Apply the popup modal styles this.logService.info("Applying popup modal styles", newValue.modalPosition); - applyPopupModalStyles(this.win, newValue.modalPosition); + applyPopupModalStyles(this.win, newValue.showTrafficButtons, newValue.modalPosition); this.win.show(); } }), diff --git a/apps/desktop/src/modal/passkeys/create/fido2-create.component.ts b/apps/desktop/src/modal/passkeys/create/fido2-create.component.ts index 48047f3a365..4548c2da119 100644 --- a/apps/desktop/src/modal/passkeys/create/fido2-create.component.ts +++ b/apps/desktop/src/modal/passkeys/create/fido2-create.component.ts @@ -8,6 +8,7 @@ import { BitwardenShield } from "@bitwarden/auth/angular"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { Fido2Utils } from "@bitwarden/common/platform/services/fido2/fido2-utils"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { @@ -24,6 +25,7 @@ import { } from "@bitwarden/components"; import { PasswordRepromptService } from "@bitwarden/vault"; +import { DesktopAutofillService } from "../../../autofill/services/desktop-autofill.service"; import { DesktopFido2UserInterfaceService, DesktopFido2UserInterfaceSession, @@ -59,6 +61,7 @@ export class Fido2CreateComponent implements OnInit, OnDestroy { private readonly fido2UserInterfaceService: DesktopFido2UserInterfaceService, private readonly accountService: AccountService, private readonly cipherService: CipherService, + private readonly desktopAutofillService: DesktopAutofillService, private readonly dialogService: DialogService, private readonly domainSettingsService: DomainSettingsService, private readonly logService: LogService, @@ -69,6 +72,7 @@ export class Fido2CreateComponent implements OnInit, OnDestroy { async ngOnInit() { await this.accountService.setShowHeader(false); this.session = this.fido2UserInterfaceService.getCurrentSession(); + const lastRegistrationRequest = this.desktopAutofillService.lastRegistrationRequest; const rpid = await this.session.getRpId(); const equivalentDomains = await firstValueFrom( this.domainSettingsService.getUrlEquivalentDomains(rpid), @@ -81,13 +85,13 @@ export class Fido2CreateComponent implements OnInit, OnDestroy { .getAllDecrypted(activeUserId) .then((ciphers) => { const relevantCiphers = ciphers.filter((cipher) => { - if (!cipher.login || !cipher.login.hasUris) { - return false; - } + const userHandle = Fido2Utils.bufferToString( + new Uint8Array(lastRegistrationRequest.userHandle), + ); return ( cipher.login.matchesUri(rpid, equivalentDomains) && - (!cipher.login.fido2Credentials || cipher.login.fido2Credentials.length === 0) + Fido2Utils.cipherHasNoOtherPasskeys(cipher, userHandle) ); }); this.ciphersSubject.next(relevantCiphers); @@ -100,11 +104,21 @@ export class Fido2CreateComponent implements OnInit, OnDestroy { } async addPasskeyToCipher(cipher: CipherView) { - const userVerified = cipher.reprompt - ? await this.passwordRepromptService.showPasswordPrompt() - : true; + let isConfirmed = true; - this.session.notifyConfirmCreateCredential(userVerified, cipher); + if (cipher.login.hasFido2Credentials) { + isConfirmed = await this.dialogService.openSimpleDialog({ + title: { key: "overwritePasskey" }, + content: { key: "alreadyContainsPasskey" }, + type: "warning", + }); + } + + if (cipher.reprompt) { + isConfirmed = await this.passwordRepromptService.showPasswordPrompt(); + } + + this.session.notifyConfirmCreateCredential(isConfirmed, cipher); } async confirmPasskey() { diff --git a/apps/desktop/src/modal/passkeys/fido2-excluded-ciphers.component.html b/apps/desktop/src/modal/passkeys/fido2-excluded-ciphers.component.html new file mode 100644 index 00000000000..3a2e9f9bc9b --- /dev/null +++ b/apps/desktop/src/modal/passkeys/fido2-excluded-ciphers.component.html @@ -0,0 +1,41 @@ +
+ + +
+ + +

+ {{ "savePasskeyQuestion" | i18n }} +

+
+ + +
+
+ +
+ +
+ +
+ {{ "passkeyAlreadyExists" | i18n }} + {{ "applicationDoesNotSupportDuplicates" | i18n }} +
+ +
+
+
+
diff --git a/apps/desktop/src/modal/passkeys/fido2-excluded-ciphers.component.ts b/apps/desktop/src/modal/passkeys/fido2-excluded-ciphers.component.ts new file mode 100644 index 00000000000..1872ff16b3c --- /dev/null +++ b/apps/desktop/src/modal/passkeys/fido2-excluded-ciphers.component.ts @@ -0,0 +1,73 @@ +import { CommonModule } from "@angular/common"; +import { Component, OnInit, OnDestroy } from "@angular/core"; +import { RouterModule, Router } from "@angular/router"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { BitwardenShield } from "@bitwarden/auth/angular"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { + BadgeModule, + ButtonModule, + DialogModule, + IconModule, + ItemModule, + SectionComponent, + TableModule, + SectionHeaderComponent, + BitIconButtonComponent, +} from "@bitwarden/components"; + +import { + DesktopFido2UserInterfaceService, + DesktopFido2UserInterfaceSession, +} from "../../autofill/services/desktop-fido2-user-interface.service"; +import { DesktopSettingsService } from "../../platform/services/desktop-settings.service"; + +import { Fido2PasskeyExistsIcon } from "./fido2-passkey-exists-icon"; + +@Component({ + standalone: true, + imports: [ + CommonModule, + RouterModule, + SectionHeaderComponent, + BitIconButtonComponent, + TableModule, + JslibModule, + IconModule, + ButtonModule, + DialogModule, + SectionComponent, + ItemModule, + BadgeModule, + ], + templateUrl: "fido2-excluded-ciphers.component.html", +}) +export class Fido2ExcludedCiphersComponent implements OnInit, OnDestroy { + session?: DesktopFido2UserInterfaceSession = null; + readonly Icons = { BitwardenShield }; + protected fido2PasskeyExistsIcon = Fido2PasskeyExistsIcon; + + constructor( + private readonly desktopSettingsService: DesktopSettingsService, + private readonly fido2UserInterfaceService: DesktopFido2UserInterfaceService, + private readonly accountService: AccountService, + private readonly router: Router, + ) {} + + async ngOnInit() { + await this.accountService.setShowHeader(false); + this.session = this.fido2UserInterfaceService.getCurrentSession(); + } + + async ngOnDestroy() { + await this.accountService.setShowHeader(true); + } + + async closeModal() { + await this.router.navigate(["/"]); + await this.desktopSettingsService.setModalMode(false); + this.session.notifyConfirmCreateCredential(false); + this.session.confirmChosenCipher(null); + } +} diff --git a/apps/desktop/src/modal/passkeys/fido2-passkey-exists-icon.ts b/apps/desktop/src/modal/passkeys/fido2-passkey-exists-icon.ts new file mode 100644 index 00000000000..5a179f595fd --- /dev/null +++ b/apps/desktop/src/modal/passkeys/fido2-passkey-exists-icon.ts @@ -0,0 +1,16 @@ +import { svgIcon } from "@bitwarden/components"; + +export const Fido2PasskeyExistsIcon = svgIcon` + + + + + + + + + + + + +`; diff --git a/apps/desktop/src/platform/models/domain/window-state.ts b/apps/desktop/src/platform/models/domain/window-state.ts index 0efc9a1efab..ab52531bb5d 100644 --- a/apps/desktop/src/platform/models/domain/window-state.ts +++ b/apps/desktop/src/platform/models/domain/window-state.ts @@ -14,5 +14,6 @@ export class WindowState { export class ModalModeState { isModalModeActive: boolean; + showTrafficButtons?: boolean; modalPosition?: { x: number; y: number }; // Modal position is often passed from the native UI } diff --git a/apps/desktop/src/platform/popup-modal-styles.ts b/apps/desktop/src/platform/popup-modal-styles.ts index 05a8ec1025f..1ef4b901c76 100644 --- a/apps/desktop/src/platform/popup-modal-styles.ts +++ b/apps/desktop/src/platform/popup-modal-styles.ts @@ -8,10 +8,14 @@ const popupHeight = 600; type Position = { x: number; y: number }; -export function applyPopupModalStyles(window: BrowserWindow, position?: Position) { +export function applyPopupModalStyles( + window: BrowserWindow, + showTrafficButtons: boolean = true, + position?: Position, +) { window.unmaximize(); window.setSize(popupWidth, popupHeight); - window.setWindowButtonVisibility?.(false); + window.setWindowButtonVisibility?.(showTrafficButtons); window.setMenuBarVisibility?.(false); window.setResizable(false); window.setAlwaysOnTop(true); diff --git a/apps/desktop/src/platform/services/desktop-settings.service.ts b/apps/desktop/src/platform/services/desktop-settings.service.ts index f5789d6f40c..ba59f7d4623 100644 --- a/apps/desktop/src/platform/services/desktop-settings.service.ts +++ b/apps/desktop/src/platform/services/desktop-settings.service.ts @@ -306,9 +306,14 @@ export class DesktopSettingsService { * Sets the modal mode of the application. Setting this changes the windows-size and other properties. * @param value `true` if the application is in modal mode, `false` if it is not. */ - async setModalMode(value: boolean, modalPosition?: { x: number; y: number }) { + async setModalMode( + value: boolean, + showTrafficButtons?: boolean, + modalPosition?: { x: number; y: number }, + ) { await this.modalModeState.update(() => ({ isModalModeActive: value, + showTrafficButtons, modalPosition, })); } diff --git a/libs/common/src/platform/abstractions/fido2/fido2-authenticator.service.abstraction.ts b/libs/common/src/platform/abstractions/fido2/fido2-authenticator.service.abstraction.ts index e9e68ca92c3..3e63ec2d3df 100644 --- a/libs/common/src/platform/abstractions/fido2/fido2-authenticator.service.abstraction.ts +++ b/libs/common/src/platform/abstractions/fido2/fido2-authenticator.service.abstraction.ts @@ -136,7 +136,7 @@ export interface Fido2AuthenticatorGetAssertionParams { rpId: string; /** The hash of the serialized client data, provided by the client. */ hash: BufferSource; - allowCredentialDescriptorList: PublicKeyCredentialDescriptor[]; + allowCredentialDescriptorList?: PublicKeyCredentialDescriptor[]; /** The effective user verification requirement for assertion, a Boolean value provided by the client. */ requireUserVerification: boolean; /** The constant Boolean value true. It is included here as a pseudo-parameter to simplify applying this abstract authenticator model to implementations that may wish to make a test of user presence optional although WebAuthn does not. */ diff --git a/libs/common/src/platform/services/fido2/fido2-utils.ts b/libs/common/src/platform/services/fido2/fido2-utils.ts index b9f3c8f8c48..59ce7dd723a 100644 --- a/libs/common/src/platform/services/fido2/fido2-utils.ts +++ b/libs/common/src/platform/services/fido2/fido2-utils.ts @@ -1,3 +1,5 @@ +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; + // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore export class Fido2Utils { @@ -72,4 +74,16 @@ export class Fido2Utils { return output; } + + /** + * This methods returns true if a cipher either has no passkeys, or has a passkey matching with userHandle + * @param userHandle + */ + static cipherHasNoOtherPasskeys(cipher: CipherView, userHandle: string): boolean { + if (cipher.login.fido2Credentials == null || cipher.login.fido2Credentials.length === 0) { + return true; + } + + return cipher.login.fido2Credentials.some((passkey) => passkey.userHandle === userHandle); + } }