1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-22 04:14:04 +00:00

update cipher retrieval

This commit is contained in:
Evan Bassler
2025-02-14 17:28:43 -06:00
10 changed files with 329 additions and 106 deletions

View File

@@ -338,7 +338,7 @@ const routes: Routes = [
component: Fido2VaultComponent,
},
{
path: "create-passkey",
path: "passkey-create",
component: Fido2CreateComponent,
},
{

View File

@@ -0,0 +1,125 @@
import { CommonModule } from "@angular/common"; // Add this
import { Component, OnDestroy, OnInit } from "@angular/core";
import { Router } from "@angular/router";
import { BehaviorSubject, Observable } from "rxjs";
import {
DesktopFido2UserInterfaceService,
DesktopFido2UserInterfaceSession,
} from "../../autofill/services/desktop-fido2-user-interface.service";
import { DesktopSettingsService } from "../../platform/services/desktop-settings.service";
@Component({
standalone: true,
imports: [CommonModule], // Add this
template: `
<div
style="background:white; display:flex; justify-content: center; align-items: center; flex-direction: column"
>
<h1 style="color: black">Select your passkey</h1>
<div *ngFor="let item of cipherIds$ | async">
<button
style="color:black; padding: 10px 20px; border: 1px solid blue; margin: 10px"
bitButton
type="button"
buttonType="secondary"
(click)="chooseCipher(item)"
>
{{ item }}
</button>
</div>
<br />
<button
style="color:black; padding: 10px 20px; border: 1px solid black; margin: 10px"
bitButton
type="button"
buttonType="secondary"
(click)="confirmPasskey()"
>
Confirm passkey
</button>
<button
style="color:black; padding: 10px 20px; border: 1px solid black; margin: 10px"
bitButton
type="button"
buttonType="secondary"
(click)="closeModal()"
>
Close
</button>
</div>
`,
})
export class Fido2PlaceholderComponent implements OnInit, OnDestroy {
session?: DesktopFido2UserInterfaceSession = null;
private cipherIdsSubject = new BehaviorSubject<string[]>([]);
cipherIds$: Observable<string[]> = this.cipherIdsSubject.asObservable();
constructor(
private readonly desktopSettingsService: DesktopSettingsService,
private readonly fido2UserInterfaceService: DesktopFido2UserInterfaceService,
private readonly router: Router,
) {}
async ngOnInit(): Promise<void> {
this.session = this.fido2UserInterfaceService.getCurrentSession();
const cipherIds = await this.session?.getAvailableCipherIds();
this.cipherIdsSubject.next(cipherIds || []);
// eslint-disable-next-line no-console
console.log("Available cipher IDs", cipherIds);
}
async chooseCipher(cipherId: string) {
this.session?.confirmChosenCipher(cipherId);
await this.router.navigate(["/"]);
await this.desktopSettingsService.setInModalMode(false);
}
ngOnDestroy() {
this.cipherIdsSubject.complete(); // Clean up the BehaviorSubject
}
async confirmPasskey() {
try {
// Retrieve the current UI session to control the flow
if (!this.session) {
// todo: handle error
throw new Error("No session found");
}
// If we want to we could submit information to the session in order to create the credential
// const cipher = await session.createCredential({
// userHandle: "userHandle2",
// userName: "username2",
// credentialName: "zxsd2",
// rpId: "webauthn.io",
// userVerification: true,
// });
this.session.notifyConfirmCredential(true);
// Not sure this clean up should happen here or in session.
// The session currently toggles modal on and send us here
// But if this route is somehow opened outside of session we want to make sure we clean up?
await this.router.navigate(["/"]);
await this.desktopSettingsService.setInModalMode(false);
} catch (error) {
// TODO: Handle error appropriately
}
}
async closeModal() {
await this.router.navigate(["/"]);
await this.desktopSettingsService.setInModalMode(false);
this.session.notifyConfirmCredential(false);
// little bit hacky:
this.session.confirmChosenCipher(null);
}
}

View File

@@ -159,7 +159,12 @@ export class DesktopAutofillService implements OnDestroy {
ipc.autofill.listenPasskeyAssertionWithoutUserInterface(
async (clientId, sequenceNumber, request, callback) => {
this.logService.warning("listenPasskeyAssertion", clientId, sequenceNumber, request);
this.logService.warning(
"listenPasskeyAssertion without user interface",
clientId,
sequenceNumber,
request,
);
// TODO: For some reason the credentialId is passed as an empty array in the request, so we need to
// get it from the cipher. For that we use the recordIdentifier, which is the cipherId.
@@ -193,7 +198,7 @@ export class DesktopAutofillService implements OnDestroy {
const controller = new AbortController();
void this.fido2AuthenticatorService
.getAssertion(this.convertAssertionRequest(request), null, controller)
.getAssertion(this.convertAssertionRequest(request, true), null, controller)
.then((response) => {
callback(null, this.convertAssertionResponse(request, response));
})
@@ -261,10 +266,17 @@ export class DesktopAutofillService implements OnDestroy {
};
}
/**
*
* @param request
* @param assumeUserPresence For WithoutUserInterface requests, we assume the user is present
* @returns
*/
private convertAssertionRequest(
request:
| autofill.PasskeyAssertionRequest
| autofill.PasskeyAssertionWithoutUserInterfaceRequest,
assumeUserPresence: boolean = false,
): Fido2AuthenticatorGetAssertionParams {
let allowedCredentials;
if ("credentialId" in request) {
@@ -289,6 +301,7 @@ export class DesktopAutofillService implements OnDestroy {
requireUserVerification:
request.userVerification === "required" || request.userVerification === "preferred",
fallbackSupported: false,
assumeUserPresence: assumeUserPresence,
};
}

View File

@@ -1,5 +1,14 @@
import { Router } from "@angular/router";
import { lastValueFrom, firstValueFrom, map, Subject } from "rxjs";
import {
lastValueFrom,
firstValueFrom,
map,
Subject,
filter,
take,
timeout,
BehaviorSubject,
} from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
@@ -77,30 +86,105 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi
) {}
private confirmCredentialSubject = new Subject<boolean>();
private createdCipher: Cipher;
private updatedCipher: CipherView;
private availableCipherIds = new BehaviorSubject<string[]>(null);
private rpId = new BehaviorSubject<string>(null);
private chosenCipherSubject = new Subject<string>();
// Method implementation
async pickCredential(
params: PickCredentialParams,
): Promise<{ cipherId: string; userVerified: boolean }> {
this.logService.warning("pickCredential desktop function", params);
async pickCredential({
cipherIds,
userVerification,
assumeUserPresence,
masterPasswordRepromptRequired,
}: PickCredentialParams): Promise<{ cipherId: string; userVerified: boolean }> {
this.logService.warning("pickCredential desktop function", {
cipherIds,
userVerification,
assumeUserPresence,
masterPasswordRepromptRequired,
});
try {
await this.showUi();
// Check if we can return the credential without user interaction
// TODO: Assume user presence is undefined
if (cipherIds.length === 1 && !masterPasswordRepromptRequired) {
this.logService.debug(
"shortcut - Assuming user presence and returning cipherId",
cipherIds[0],
);
return { cipherId: cipherIds[0], userVerified: userVerification };
}
await this.waitForUiCredentialConfirmation();
this.logService.debug("Could not shortcut, showing UI");
return { cipherId: params.cipherIds[0], userVerified: true };
// make the cipherIds available to the UI.
// Not sure if the UI also need to know about masterPasswordRepromptRequired -- probably not, otherwise we can send all of the params.
this.availableCipherIds.next(cipherIds);
await this.showUi("/passkeys");
const chosenCipherId = await this.waitForUiChosenCipher();
this.logService.debug("Received chosen cipher", chosenCipherId);
if (!chosenCipherId) {
throw new Error("User cancelled");
}
const resultCipherId = cipherIds.find((id) => id === chosenCipherId);
// TODO: perform userverification
return { cipherId: resultCipherId, userVerified: true };
} finally {
// Make sure to clean up so the app is never stuck in modal mode?
await this.desktopSettingsService.setInModalMode(false);
}
}
/**
* Returns once the UI has confirmed and completed the operation
* @returns
*/
async getAvailableCipherIds(): Promise<string[]> {
return lastValueFrom(
this.availableCipherIds.pipe(
filter((ids) => ids != null),
take(1),
timeout(50000),
),
);
}
async getRpId(): Promise<string> {
return lastValueFrom(
this.rpId.pipe(
filter((id) => id != null),
take(1),
timeout(50000),
),
);
}
confirmChosenCipher(cipherId: string): void {
this.chosenCipherSubject.next(cipherId);
this.chosenCipherSubject.complete();
}
private async waitForUiChosenCipher(): Promise<string> {
return lastValueFrom(this.chosenCipherSubject);
}
/**
* Notifies the Fido2UserInterfaceSession that the UI operations has completed and it can return to the OS.
*/
notifyConfirmCredential(confirmed: boolean): void {
notifyConfirmCredential(confirmed: boolean, updatedCipher?: CipherView): void {
if (updatedCipher) {
this.updatedCipher = updatedCipher;
}
this.confirmCredentialSubject.next(confirmed);
this.confirmCredentialSubject.complete();
}
@@ -109,7 +193,7 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi
* Returns once the UI has confirmed and completed the operation
* @returns
*/
private async waitForUiCredentialConfirmation(): Promise<boolean> {
private async waitForUiNewCredentialConfirmation(): Promise<boolean> {
return lastValueFrom(this.confirmCredentialSubject);
}
@@ -133,52 +217,53 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi
);
try {
await this.showUi(rpId);
await this.showUi("/passkey-create");
// Wait for the UI to wrap up
const confirmation = await this.waitForUiCredentialConfirmation();
const confirmation = await this.waitForUiNewCredentialConfirmation();
if (!confirmation) {
throw new Error("User cancelled");
//if existing credential is selected, update credential
}
// Create the credential
await this.createCredential({
credentialName,
userName,
rpId,
userHandle: "",
userVerification,
});
// wait for 10ms to help RXJS catch up(?)
// We sometimes get a race condition from this.createCredential not updating cipherService in time
//console.log("waiting 10ms..");
//await new Promise((resolve) => setTimeout(resolve, 10));
//console.log("Just waited 10ms");
// Return the new cipher (this.createdCipher)
return { cipherId: this.createdCipher.id, userVerified: userVerification };
if (this.updatedCipher) {
await this.updateCredential(this.updatedCipher);
return { cipherId: this.updatedCipher.id, userVerified: userVerification };
} else {
// Create the credential
await this.createCipher({
credentialName,
userName,
rpId,
userHandle: "",
userVerification,
});
return { cipherId: this.createdCipher.id, userVerified: userVerification };
}
} finally {
// Make sure to clean up so the app is never stuck in modal mode?
await this.desktopSettingsService.setInModalMode(false);
}
}
private async showUi(rpId?: string) {
private async showUi(route: string) {
// Load the UI:
// maybe toggling to modal mode shouldn't be done here?
await this.desktopSettingsService.setInModalMode(true);
//pass the rpid to the fido2placeholder component through routing parameter
// await this.router.navigate(["/passkeys"]);
await this.router.navigate(["/passkeys"], { state: { rpid: rpId } });
await this.router.navigate([route]);
}
/**
* Can be called by the UI to create a new credential with user input etc.
* @param param0
*/
async createCredential({ credentialName, userName, rpId }: NewCredentialParams): Promise<Cipher> {
async createCipher({ credentialName, userName, rpId }: NewCredentialParams): Promise<Cipher> {
// Store the passkey on a new cipher to avoid replacing something important
this.rpId.next(rpId);
const cipher = new CipherView();
cipher.name = credentialName;
@@ -205,6 +290,15 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi
return createdCipher;
}
async updateCredential(cipher: CipherView): Promise<void> {
this.logService.warning("updateCredential");
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
const encCipher = await this.cipherService.encrypt(cipher, activeUserId);
await this.cipherService.updateWithServer(encCipher);
}
async informExcludedCredential(existingCipherIds: string[]): Promise<void> {
this.logService.warning("informExcludedCredential", existingCipherIds);
}

View File

@@ -23,15 +23,24 @@
</bit-section>
<bit-section class="tw-bg-background-alt tw-p-4">
<bit-item *ngFor="let c of ciphers" class="">
<button type="button" bit-item-content (click)="confirmPasskey()">
<bit-item *ngFor="let c of ciphers$ | async" class="">
<button type="button" bit-item-content (click)="addPasskeyToCipher(c)">
<app-vault-icon [cipher]="c" slot="start"></app-vault-icon>
<button bitLink [title]="c.name" type="button">
{{ c.name }}
</button>
<span slot="secondary">{{ c.subTitle }}</span>
<span bitBadge slot="end">Select</span>
<span bitBadge slot="end">Save</span>
</button>
</bit-item>
<button
style="color: black; padding: 10px 20px; border: 1px solid black; margin: 10px"
bitButton
type="button"
buttonType="secondary"
(click)="confirmPasskey()"
>
Save as new login
</button>
</bit-section>
</div>

View File

@@ -1,10 +1,11 @@
import { CommonModule } from "@angular/common";
import { Component, OnInit } from "@angular/core";
import { RouterModule, Router } from "@angular/router";
import { BehaviorSubject, firstValueFrom, Observable } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherType } from "@bitwarden/common/vault/enums/cipher-type";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import {
BadgeModule,
@@ -47,38 +48,44 @@ import { DesktopSettingsService } from "../../../platform/services/desktop-setti
templateUrl: "fido2-create.component.html",
})
export class Fido2CreateComponent implements OnInit {
ciphers: CipherView[];
rpId: string;
session?: DesktopFido2UserInterfaceSession = null;
private ciphersSubject = new BehaviorSubject<CipherView[]>([]);
ciphers$: Observable<CipherView[]> = this.ciphersSubject.asObservable();
readonly Icons = { BitwardenShield };
session?: DesktopFido2UserInterfaceSession = null;
constructor(
private readonly desktopSettingsService: DesktopSettingsService,
private readonly fido2UserInterfaceService: DesktopFido2UserInterfaceService,
private readonly cipherService: CipherService,
private readonly domainSettingsService: DomainSettingsService,
private readonly router: Router,
) {}
async ngOnInit() {
this.rpId = history.state.rpid;
this.session = this.fido2UserInterfaceService.getCurrentSession();
if (!this.session) {
await this.fido2UserInterfaceService.newSession(false, null);
this.session = this.fido2UserInterfaceService.getCurrentSession();
}
let allCiphers = [];
const rpid = await this.session.getRpId();
const equivalentDomains = await firstValueFrom(
this.domainSettingsService.getUrlEquivalentDomains(rpid),
);
if (this.rpId) {
allCiphers = await this.cipherService.getAllDecryptedForUrl(this.rpId, [CipherType.Login]);
} else {
allCiphers = await this.cipherService.getAllDecrypted();
}
this.cipherService
.getPasskeyCiphersForUrl(rpid)
.then((ciphers) => {
const relevantCiphers = ciphers.filter(
(cipher) =>
cipher.login.matchesUri(rpid, equivalentDomains) &&
(!cipher.login.fido2Credentials || cipher.login.fido2Credentials.length === 0),
);
this.ciphersSubject.next(relevantCiphers);
})
.catch(() => {
// console.error(err);
});
}
//filter all ciphers to only return login ciphers without fido2Credentials
this.ciphers = allCiphers.filter((cipher) => {
return cipher.type === CipherType.Login && cipher.login.fido2Credentials.length === 0;
});
async addPasskeyToCipher(cipher: CipherView) {
this.session.notifyConfirmCredential(true, cipher);
}
async confirmPasskey() {

View File

@@ -22,8 +22,8 @@
</bit-section>
<bit-section class="tw-bg-background-alt tw-p-4">
<bit-item *ngFor="let c of ciphers" class="">
<button type="button" bit-item-content (click)="confirmPasskey()">
<bit-item *ngFor="let c of ciphers$ | async" class="">
<button type="button" bit-item-content (click)="chooseCipher(c.id)">
<app-vault-icon [cipher]="c" slot="start"></app-vault-icon>
<button bitLink [title]="c.name" type="button">
{{ c.name }}

View File

@@ -1,9 +1,9 @@
import { CommonModule } from "@angular/common";
import { Component, OnInit } from "@angular/core";
import { RouterModule, Router } from "@angular/router";
import { BehaviorSubject, Observable } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import {
@@ -24,7 +24,6 @@ import {
DesktopFido2UserInterfaceSession,
} from "../../autofill/services/desktop-fido2-user-interface.service";
import { DesktopSettingsService } from "../../platform/services/desktop-settings.service";
// import { AnchorLinkDirective } from "../../../../../libs/components/src/link/link.directive";
@Component({
standalone: true,
@@ -45,68 +44,38 @@ import { DesktopSettingsService } from "../../platform/services/desktop-settings
templateUrl: "fido2-vault.component.html",
})
export class Fido2VaultComponent implements OnInit {
ciphers: CipherView[];
rpId: string;
session?: DesktopFido2UserInterfaceSession = null;
private ciphersSubject = new BehaviorSubject<CipherView[]>([]);
ciphers$: Observable<CipherView[]> = this.ciphersSubject.asObservable();
readonly Icons = { BitwardenShield };
session?: DesktopFido2UserInterfaceSession = null;
constructor(
private readonly desktopSettingsService: DesktopSettingsService,
private readonly fido2UserInterfaceService: DesktopFido2UserInterfaceService,
private readonly cipherService: CipherService,
private readonly authService: AuthService,
private readonly router: Router,
) {}
async ngOnInit() {
this.rpId = history.state.rpid;
this.session = this.fido2UserInterfaceService.getCurrentSession();
if (!this.session) {
await this.fido2UserInterfaceService.newSession(false, null);
this.session = this.fido2UserInterfaceService.getCurrentSession();
}
const cipherIds = await this.session?.getAvailableCipherIds();
if (this.rpId) {
this.ciphers = await this.cipherService.getPasskeyCiphersForUrl(this.rpId);
} else {
this.ciphers = await this.cipherService.getAllDecrypted();
}
this.cipherService
.getAllDecryptedForIds(cipherIds || [])
.then((ciphers) => {
this.ciphersSubject.next(ciphers);
})
.catch(() => {
// console.error(err);
});
}
async confirmPasskey() {
try {
this.session = this.fido2UserInterfaceService.getCurrentSession();
async chooseCipher(cipherId: string) {
this.session?.confirmChosenCipher(cipherId);
// this.session.pickCredential({
// cipherIds: [],
// userVerification: false,
// });
// Retrieve the current UI session to control the flow
if (!this.session) {
// todo: handle error
throw new Error("No session found");
}
// If we want to we could submit information to the session in order to create the credential
// const cipher = await session.createCredential({
// userHandle: "userHandle2",
// userName: "username2",
// credentialName: "zxsd2",
// rpId: "webauthn.io",
// userVerification: true,
// });
this.session.notifyConfirmCredential(true);
// Not sure this clean up should happen here or in session.
// The session currently toggles modal on and send us here
// But if this route is somehow opened outside of session we want to make sure we clean up?
await this.router.navigate(["/"]);
await this.desktopSettingsService.setInModalMode(false);
} catch (error) {
// TODO: Handle error appropriately
}
await this.router.navigate(["/"]);
await this.desktopSettingsService.setInModalMode(false);
}
async closeModal() {

View File

@@ -45,6 +45,7 @@ export abstract class CipherService implements UserKeyRotationDataProvider<Ciphe
includeOtherTypes?: CipherType[],
defaultMatch?: UriMatchStrategySetting,
) => Promise<CipherView[]>;
getAllDecryptedForIds: (ids: string[]) => Promise<CipherView[]>;
getPasskeyCiphersForUrl: (url: string) => Promise<CipherView[]>;
getPasskeyCiphers: () => Promise<CipherView[]>;
filterCiphersForUrl: (

View File

@@ -485,6 +485,11 @@ export class CipherService implements CipherServiceAbstraction {
return await this.filterCiphersForUrl(ciphers, url, includeOtherTypes, defaultMatch);
}
async getAllDecryptedForIds(ids: string[]): Promise<CipherView[]> {
const ciphers = await this.getAllDecrypted();
return ciphers.filter((cipher) => ids.includes(cipher.id));
}
async filterCiphersForUrl(
ciphers: CipherView[],
url: string,