1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-18 02:19:18 +00:00

Show existing login items in the UI

This commit is contained in:
Jeffrey Holland
2025-04-09 15:56:51 +02:00
committed by Anders Åberg
parent f3a392cf56
commit e4221d4d56
7 changed files with 144 additions and 30 deletions

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

@@ -3574,9 +3574,18 @@
"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."
},
"closeBitwarden": {
"message": "Close Bitwarden"
},

View File

@@ -8,6 +8,10 @@ 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 {
compareCredentialIds,
parseCredentialId,
} from "@bitwarden/common/platform/services/fido2/credential-id-utils";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import {
@@ -24,6 +28,7 @@ import {
} from "@bitwarden/components";
import { PasswordRepromptService } from "@bitwarden/vault";
import { DesktopAutofillService } from "../../../autofill/services/desktop-autofill.service";
import {
DesktopFido2UserInterfaceService,
DesktopFido2UserInterfaceSession,
@@ -52,6 +57,7 @@ export class Fido2CreateComponent implements OnInit, OnDestroy {
session?: DesktopFido2UserInterfaceSession = null;
private ciphersSubject = new BehaviorSubject<CipherView[]>([]);
ciphers$: Observable<CipherView[]> = this.ciphersSubject.asObservable();
containsExcludedCiphers: boolean = false;
readonly Icons = { BitwardenShield };
constructor(
@@ -59,6 +65,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 +76,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),
@@ -80,16 +88,42 @@ export class Fido2CreateComponent implements OnInit, OnDestroy {
this.cipherService
.getAllDecrypted(activeUserId)
.then((ciphers) => {
const relevantCiphers = ciphers.filter((cipher) => {
if (!cipher.login || !cipher.login.hasUris) {
return false;
}
if (lastRegistrationRequest.excludedCredentials.length > 0) {
const excludedCiphers = ciphers.filter((cipher) => {
const credentialId = cipher.login.hasFido2Credentials
? parseCredentialId(cipher.login.fido2Credentials[0]?.credentialId)
: new Uint8Array();
if (!cipher.login || !cipher.login.hasUris) {
return false;
}
return (
cipher.login.matchesUri(rpid, equivalentDomains) && !cipher.login.hasFido2Credentials
);
});
this.ciphersSubject.next(relevantCiphers);
return (
cipher.login.matchesUri(rpid, equivalentDomains) &&
compareCredentialIds(
credentialId,
new Uint8Array(lastRegistrationRequest.excludedCredentials[0]),
)
);
});
this.containsExcludedCiphers = excludedCiphers.length > 0;
this.ciphersSubject.next(excludedCiphers);
} else {
const relevantCiphers = ciphers.filter((cipher) => {
const credentialId = cipher.login.hasFido2Credentials
? Array.from(parseCredentialId(cipher.login.fido2Credentials[0]?.credentialId))
: [];
if (!cipher.login || !cipher.login.hasUris) {
return false;
}
return (
cipher.login.matchesUri(rpid, equivalentDomains) &&
!lastRegistrationRequest.excludedCredentials.includes(credentialId)
);
});
this.ciphersSubject.next(relevantCiphers);
}
})
.catch((error) => this.logService.error(error));
}
@@ -99,11 +133,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

@@ -22,6 +22,7 @@
</bit-section>
<bit-section class="tw-bg-background-alt tw-p-4 tw-flex tw-flex-col tw-grow">
<div *ngIf="containsExcludedCiphers">{{ "passkeyAlreadyExists" | i18n }}</div>
<bit-item *ngFor="let c of ciphers$ | async" class="">
<button type="button" bit-item-content (click)="chooseCipher(c)">
<app-vault-icon [cipher]="c" slot="start"></app-vault-icon>

View File

@@ -7,6 +7,10 @@ import { JslibModule } from "@bitwarden/angular/jslib.module";
import { BitwardenShield } from "@bitwarden/auth/angular";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import {
compareCredentialIds,
parseCredentialId,
} from "@bitwarden/common/platform/services/fido2/credential-id-utils";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherRepromptType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
@@ -23,6 +27,7 @@ import {
} from "@bitwarden/components";
import { PasswordRepromptService } from "@bitwarden/vault";
import { DesktopAutofillService } from "../../autofill/services/desktop-autofill.service";
import {
DesktopFido2UserInterfaceService,
DesktopFido2UserInterfaceSession,
@@ -53,6 +58,7 @@ export class Fido2VaultComponent implements OnInit, OnDestroy {
private ciphersSubject = new BehaviorSubject<CipherView[]>([]);
ciphers$: Observable<CipherView[]> = this.ciphersSubject.asObservable();
private cipherIdsSubject = new BehaviorSubject<string[]>([]);
protected containsExcludedCiphers: boolean = false;
cipherIds$: Observable<string[]>;
readonly Icons = { BitwardenShield };
@@ -61,12 +67,14 @@ export class Fido2VaultComponent implements OnInit, OnDestroy {
private readonly fido2UserInterfaceService: DesktopFido2UserInterfaceService,
private readonly cipherService: CipherService,
private readonly accountService: AccountService,
private readonly desktopAutofillService: DesktopAutofillService,
private readonly logService: LogService,
private readonly passwordRepromptService: PasswordRepromptService,
private readonly router: Router,
) {}
async ngOnInit() {
const lastRegistrationRequest = this.desktopAutofillService.lastRegistrationRequest;
await this.accountService.setShowHeader(false);
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
@@ -79,7 +87,27 @@ export class Fido2VaultComponent implements OnInit, OnDestroy {
this.cipherService
.getAllDecryptedForIds(activeUserId, cipherIds || [])
.then((ciphers) => {
this.ciphersSubject.next(ciphers);
if (lastRegistrationRequest) {
const excludedCiphers = ciphers.filter((cipher) => {
const credentialId = cipher.login.hasFido2Credentials
? parseCredentialId(cipher.login.fido2Credentials[0]?.credentialId)
: new Uint8Array();
if (!cipher.login || !cipher.login.hasUris) {
return false;
}
return compareCredentialIds(
credentialId,
new Uint8Array(lastRegistrationRequest.excludedCredentials[0]),
);
});
this.containsExcludedCiphers = excludedCiphers.length > 0;
this.ciphersSubject.next(excludedCiphers || ciphers);
} else {
this.ciphersSubject.next(ciphers);
}
})
.catch((error) => this.logService.error(error));
});
@@ -91,7 +119,9 @@ export class Fido2VaultComponent implements OnInit, OnDestroy {
}
async chooseCipher(cipher: CipherView) {
if (
if (this.containsExcludedCiphers) {
this.session?.confirmChosenCipher(cipher.id, false);
} else if (
cipher.reprompt !== CipherRepromptType.None &&
!(await this.passwordRepromptService.showPasswordPrompt())
) {

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

@@ -112,13 +112,18 @@ export class Fido2AuthenticatorService<ParentWindowReference>
const existingCipherIds = await this.findExcludedCredentials(
params.excludeCredentialDescriptorList,
);
if (existingCipherIds.length > 0) {
this.logService?.info(
`[Fido2Authenticator] Aborting due to excluded credential found in vault.`,
);
await userInterfaceSession.informExcludedCredential(existingCipherIds);
throw new Fido2AuthenticatorError(Fido2AuthenticatorErrorCode.NotAllowed);
}
let cipherOptions: CipherView[];
await userInterfaceSession.ensureUnlockedVault();
const assertParams = { ...params, rpId: params.rpEntity.id, extensions: {} };
// Try to find the passkey locally before causing a sync to speed things up
// only skip syncing if we found credentials AND all of them have a counter = 0
const credentials = await this.findCredential(assertParams, cipherOptions);
const masterPasswordRepromptRequired = credentials.some(
(cipher) => cipher.reprompt !== CipherRepromptType.None,
);
let cipher: CipherView;
let fido2Credential: Fido2CredentialView;
@@ -126,13 +131,31 @@ export class Fido2AuthenticatorService<ParentWindowReference>
let userVerified = false;
let credentialId: string;
let pubKeyDer: ArrayBuffer;
const response = await userInterfaceSession.confirmNewCredential({
credentialName: params.rpEntity.name,
userName: params.userEntity.name,
userHandle: Fido2Utils.bufferToString(params.userEntity.id),
userVerification: params.requireUserVerification,
rpId: params.rpEntity.id,
});
let response;
if (existingCipherIds.length > 0) {
response = await userInterfaceSession.pickCredential({
cipherIds: existingCipherIds,
userVerification: params.requireUserVerification,
assumeUserPresence: false,
masterPasswordRepromptRequired,
});
this.logService?.info(
`[Fido2Authenticator] Aborting due to excluded credential found in vault.`,
);
await userInterfaceSession.informExcludedCredential(existingCipherIds);
throw new Fido2AuthenticatorError(Fido2AuthenticatorErrorCode.NotAllowed);
} else {
response = await userInterfaceSession.confirmNewCredential({
credentialName: params.rpEntity.name,
userName: params.userEntity.name,
userHandle: Fido2Utils.bufferToString(params.userEntity.id),
userVerification: params.requireUserVerification,
rpId: params.rpEntity.id,
});
}
const cipherId = response.cipherId;
userVerified = response.userVerified;