1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-13 15:03:26 +00:00

Autofill/PM-19511: Overwrite and reprompt (#14288)

* Show items for url that don't have passkey

* Show existing login items in the UI

* Filter available cipher results (#14399)

* Filter available cipher results

* Fix linting issues

* Update logic for eligible ciphers

* Remove unused method to check matching username

* PM-20608 update styling for excludedCredentials (#14444)

* PM-20608 update styling for excludedCredentials

* Have flow correctly move to creation for excluded cipher

* Remove duplicate confirmNeCredential call

* Revert fido2-authenticator changes and move the excluded check

* Create a separate component for excluded cipher view

* Display traffic light MacOS buttons when the vault is locked (#14673)

* Remove unneccessary filter for excludedCiphers

* Remove dead code from the excluded ciphers work

* Remove excludedCipher checks from fido2 create and vault

* Remove excludedCipher remnants from vault and simplify create cipher logic

* Move cipherHasNoOtherPasskeys to shared fido2-utils

* Remove all containsExcludedCipher references

* Use `bufferToString` to convert `userHandle`

---------

Co-authored-by: Jeffrey Holland <jholland@livefront.com>
Co-authored-by: Jeffrey Holland <124393578+jholland-livefront@users.noreply.github.com>
This commit is contained in:
Anders Åberg
2025-05-15 11:33:17 +02:00
committed by GitHub
parent 7f3f7aebcc
commit 156d96ef49
15 changed files with 217 additions and 30 deletions

View File

@@ -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);
}
}

View File

@@ -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,

View File

@@ -49,6 +49,7 @@ import type { NativeWindowObject } from "./desktop-fido2-user-interface.service"
@Injectable()
export class DesktopAutofillService implements OnDestroy {
private destroy$ = new Subject<void>();
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",

View File

@@ -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<void> {
// 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<void> {
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<void> {
@@ -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 {

View File

@@ -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"
},

View File

@@ -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();
}
}),

View File

@@ -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() {

View File

@@ -0,0 +1,41 @@
<div class="tw-flex tw-flex-col tw-h-full">
<bit-section
disableMargin
class="tw-border-0 tw-border-b tw-border-solid tw-border-secondary-300"
>
<bit-section-header class="passkey-header tw-bg-background">
<div class="tw-flex tw-items-center">
<bit-icon [icon]="Icons.BitwardenShield" class="tw-w-10 tw-mt-2 tw-ml-2"></bit-icon>
<h2 bitTypography="h4" class="tw-font-semibold tw-text-lg">
{{ "savePasskeyQuestion" | i18n }}
</h2>
</div>
<button
type="button"
bitIconButton="bwi-close"
slot="end"
class="passkey-header-close tw-mb-4 tw-mr-2"
(click)="closeModal()"
>
Close
</button>
</bit-section-header>
</bit-section>
<div class="tw-h-full tw-items-start">
<bit-section
class="tw-flex tw-bg-background-alt tw-flex-col tw-justify-start tw-items-center tw-gap-2 tw-h-full tw-px-5"
>
<div class="tw-flex tw-items-center tw-flex-col tw-p-12 tw-gap-4">
<bit-icon [icon]="fido2PasskeyExistsIcon"></bit-icon>
<div class="tw-flex tw-flex-col tw-gap-2">
<b>{{ "passkeyAlreadyExists" | i18n }}</b>
{{ "applicationDoesNotSupportDuplicates" | i18n }}
</div>
<button bitButton type="button" buttonType="primary" (click)="closeModal()">Close</button>
</div>
</bit-section>
</div>
</div>

View File

@@ -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);
}
}

View File

@@ -0,0 +1,16 @@
import { svgIcon } from "@bitwarden/components";
export const Fido2PasskeyExistsIcon = svgIcon`<svg width="98" height="95" viewBox="0 0 98 95" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.85443 86.2917L56.99 86.2917C60.232 86.2917 62.8602 83.6635 62.8602 80.4215L62.8602 31.7675C62.8602 30.2469 62.2701 28.7857 61.2143 27.6914L47.5535 13.5343C46.4472 12.3878 44.9224 11.7402 43.3292 11.7402L8.85444 11.7402C5.61243 11.7402 2.98425 14.3684 2.98425 17.6104L2.98425 80.4215C2.98425 83.6635 5.61242 86.2917 8.85443 86.2917Z" stroke="#020F66" stroke-width="1.76106"/>
<path d="M18.8336 76.3123L66.9691 76.3123C70.2112 76.3123 72.8393 73.6841 72.8393 70.4421L72.8393 21.3268C72.8393 19.8141 72.2554 18.3599 71.2093 17.2672L57.535 2.98447C56.4277 1.82789 54.896 1.17383 53.2948 1.17383L18.8336 1.17383C15.5916 1.17383 12.9634 3.80201 12.9634 7.04402L12.9634 70.4421C12.9634 73.6841 15.5915 76.3123 18.8336 76.3123Z" fill="white" stroke="#020F66" stroke-width="1.76106"/>
<path d="M54.3485 1.76074L54.3485 13.5011C54.3485 16.7431 56.9767 19.3713 60.2187 19.3713L72.5461 19.3713" stroke="#020F66" stroke-width="1.76106"/>
<path d="M20.0914 15.4858L43.5722 15.4858" stroke="#15C0CB" stroke-width="0.880529" stroke-linecap="round" stroke-dasharray="11.74 4.7"/>
<path d="M20.0914 30.394L51.2034 30.394" stroke="#15C0CB" stroke-width="0.880529" stroke-linecap="round" stroke-dasharray="11.74 4.7"/>
<path d="M20.0914 45.3027L45.9203 45.3027" stroke="#15C0CB" stroke-width="0.880529" stroke-linecap="round" stroke-dasharray="11.74 4.7"/>
<path d="M20.0914 60.2109L45.9203 60.2109" stroke="#15C0CB" stroke-width="0.880529" stroke-linecap="round" stroke-dasharray="11.74 4.7"/>
<path d="M85.4233 53.4449C81.9863 66.272 68.6683 73.8484 55.6768 70.3674C42.6852 66.8863 34.9397 53.6659 38.3767 40.8388C41.8137 28.0117 55.1317 20.4353 68.1233 23.9163C81.1148 27.3974 88.8603 40.6178 85.4233 53.4449Z" fill="white" stroke="#020F66" stroke-width="1.76106" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M55.1859 41.0395C55.1859 41.0395 55.2828 38.7314 57.5434 36.773C58.8998 35.584 60.5145 35.2692 61.9678 35.2343C63.2919 35.1993 64.4868 35.4441 65.1649 35.8288C66.3921 36.4583 68.7497 37.962 68.7497 41.2144C68.7497 44.6416 66.6828 46.1804 64.3576 47.894C62.0324 49.6076 62.3667 51.8385 62.3667 53.727" stroke="#15C0CB" stroke-width="1.76106" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M62.2727 58.7015C62.759 58.7015 63.1533 58.3073 63.1533 57.821C63.1533 57.3347 62.759 56.9404 62.2727 56.9404C61.7864 56.9404 61.3922 57.3347 61.3922 57.821C61.3922 58.3073 61.7864 58.7015 62.2727 58.7015Z" fill="#15C0CB"/>
<path d="M96.0333 88.5621L95.4703 89.0329C94.2269 90.0728 92.3758 89.9078 91.3359 88.6644L78.2766 73.0488L74.79 68.8798C74.4843 68.5105 74.6096 67.9514 75.0271 67.7155C76.7198 66.7592 78.097 65.4974 78.8894 64.6364C79.1502 64.353 79.6089 64.3477 79.856 64.6431L83.3425 68.8121L96.4018 84.4277C97.4418 85.6712 97.2768 87.5222 96.0333 88.5621Z" fill="#F6F7F9" stroke="#020F66" stroke-width="1.76106" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
`;

View File

@@ -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
}

View File

@@ -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);

View File

@@ -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,
}));
}

View File

@@ -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. */

View File

@@ -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);
}
}