diff --git a/apps/desktop/src/app/app-routing.module.ts b/apps/desktop/src/app/app-routing.module.ts index 3bb130d321d..3b4d5994e8b 100644 --- a/apps/desktop/src/app/app-routing.module.ts +++ b/apps/desktop/src/app/app-routing.module.ts @@ -55,9 +55,10 @@ import { RemovePasswordComponent } from "../auth/remove-password.component"; import { SetPasswordComponent } from "../auth/set-password.component"; import { TwoFactorComponentV1 } from "../auth/two-factor-v1.component"; import { UpdateTempPasswordComponent } from "../auth/update-temp-password.component"; +import { Fido2CreateComponent } from "../modal/passkeys/create/fido2-create.component"; +import { Fido2VaultComponent } from "../modal/passkeys/fido2-vault.component"; import { VaultComponent } from "../vault/app/vault/vault.component"; -import { Fido2PlaceholderComponent } from "./components/fido2placeholder.component"; import { SendComponent } from "./tools/send/send.component"; /** @@ -179,12 +180,12 @@ const routes: Routes = [ canActivate: [authGuard], }, { - path: "passkeys", - component: Fido2PlaceholderComponent, + path: "fido2-assertion", + component: Fido2VaultComponent, }, { - path: "passkeys", - component: Fido2PlaceholderComponent, + path: "fido2-creation", + component: Fido2CreateComponent, }, { path: "", diff --git a/apps/desktop/src/app/components/fido2placeholder.component.ts b/apps/desktop/src/app/components/fido2placeholder.component.ts index b95dcc6d890..2982b380939 100644 --- a/apps/desktop/src/app/components/fido2placeholder.component.ts +++ b/apps/desktop/src/app/components/fido2placeholder.component.ts @@ -97,7 +97,7 @@ export class Fido2PlaceholderComponent implements OnInit, OnDestroy { // userVerification: true, // }); - this.session.notifyConfirmNewCredential(true); + this.session.notifyConfirmCreateCredential(true); // Not sure this clean up should happen here or in session. // The session currently toggles modal on and send us here @@ -113,7 +113,7 @@ export class Fido2PlaceholderComponent implements OnInit, OnDestroy { await this.router.navigate(["/"]); await this.desktopSettingsService.setModalMode(false); - this.session.notifyConfirmNewCredential(false); + this.session.notifyConfirmCreateCredential(false); // little bit hacky: this.session.confirmChosenCipher(null); } diff --git a/apps/desktop/src/autofill/services/desktop-autofill.service.ts b/apps/desktop/src/autofill/services/desktop-autofill.service.ts index 9912e9459a1..b7d9894907e 100644 --- a/apps/desktop/src/autofill/services/desktop-autofill.service.ts +++ b/apps/desktop/src/autofill/services/desktop-autofill.service.ts @@ -60,10 +60,6 @@ export class DesktopAutofillService implements OnDestroy { .pipe( distinctUntilChanged(), switchMap((enabled) => { - // if (!enabled) { - // return EMPTY; - // } - return this.accountService.activeAccount$.pipe( map((account) => account?.id), filter((userId): userId is UserId => userId != null), 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 3caf13fa5b7..2763e439c7d 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 @@ -94,9 +94,12 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi ) {} private confirmCredentialSubject = new Subject(); - private createdCipher: Cipher; - private availableCipherIdsSubject = new BehaviorSubject(null); + private createdCipher: Cipher = new Cipher(); + private updatedCipher: CipherView = new CipherView(); + + private rpId = new BehaviorSubject(""); + private availableCipherIdsSubject = new BehaviorSubject([""]); /** * Observable that emits available cipher IDs once they're confirmed by the UI */ @@ -136,15 +139,15 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi // make the cipherIds available to the UI. this.availableCipherIdsSubject.next(cipherIds); - await this.showUi("/passkeys", this.windowObject.windowXy); + await this.showUi("/fido2-assertion", this.windowObject.windowXy); const chosenCipherResponse = await this.waitForUiChosenCipher(); this.logService.debug("Received chosen cipher", chosenCipherResponse); return { - cipherId: chosenCipherResponse.cipherId, - userVerified: chosenCipherResponse.userVerified, + cipherId: chosenCipherResponse?.cipherId, + userVerified: chosenCipherResponse?.userVerified, }; } finally { // Make sure to clean up so the app is never stuck in modal mode? @@ -152,6 +155,15 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi } } + async getRpId(): Promise { + return lastValueFrom( + this.rpId.pipe( + filter((id) => id != null), + take(1), + ), + ); + } + confirmChosenCipher(cipherId: string, userVerified: boolean = false): void { this.chosenCipherSubject.next({ cipherId, userVerified }); this.chosenCipherSubject.complete(); @@ -159,7 +171,7 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi private async waitForUiChosenCipher( timeoutMs: number = 60000, - ): Promise<{ cipherId: string; userVerified: boolean } | undefined> { + ): Promise<{ cipherId?: string; userVerified: boolean } | undefined> { try { return await lastValueFrom(this.chosenCipherSubject.pipe(timeout(timeoutMs))); } catch { @@ -174,7 +186,10 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi /** * Notifies the Fido2UserInterfaceSession that the UI operations has completed and it can return to the OS. */ - notifyConfirmNewCredential(confirmed: boolean): void { + notifyConfirmCreateCredential(confirmed: boolean, updatedCipher?: CipherView): void { + if (updatedCipher) { + this.updatedCipher = updatedCipher; + } this.confirmCredentialSubject.next(confirmed); this.confirmCredentialSubject.complete(); } @@ -195,42 +210,43 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi async confirmNewCredential({ credentialName, userName, + userHandle, userVerification, rpId, - }: NewCredentialParams): Promise<{ cipherId: string; userVerified: boolean }> { + }: NewCredentialParams): Promise<{ cipherId?: string; userVerified: boolean }> { this.logService.warning( "confirmNewCredential", credentialName, userName, + userHandle, userVerification, rpId, ); + this.rpId.next(rpId); try { - await this.showUi("/passkeys", this.windowObject.windowXy); + await this.showUi("/fido2-creation", this.windowObject.windowXy); // Wait for the UI to wrap up const confirmation = await this.waitForUiNewCredentialConfirmation(); if (!confirmation) { return { cipherId: undefined, userVerified: false }; } - // 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.setModalMode(false); @@ -240,15 +256,16 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi private async showUi(route: string, position?: { x: number; y: number }): Promise { // Load the UI: await this.desktopSettingsService.setModalMode(true, position); - await this.router.navigate(["/passkeys"]); + 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 { + async createCipher({ credentialName, userName, rpId }: NewCredentialParams): Promise { // Store the passkey on a new cipher to avoid replacing something important + const cipher = new CipherView(); cipher.name = credentialName; @@ -267,12 +284,34 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi this.accountService.activeAccount$.pipe(map((a) => a?.id)), ); + if (!activeUserId) { + throw new Error("No active user ID found!"); + } + const encCipher = await this.cipherService.encrypt(cipher, activeUserId); - const createdCipher = await this.cipherService.createWithServer(encCipher); - this.createdCipher = createdCipher; + try { + const createdCipher = await this.cipherService.createWithServer(encCipher); + this.createdCipher = createdCipher; - return createdCipher; + return createdCipher; + } catch { + throw new Error("Unable to create cipher"); + } + } + + async updateCredential(cipher: CipherView): Promise { + this.logService.warning("updateCredential"); + await firstValueFrom( + this.accountService.activeAccount$.pipe( + map(async (a) => { + if (a) { + const encCipher = await this.cipherService.encrypt(cipher, a.id); + await this.cipherService.updateWithServer(encCipher); + } + }), + ), + ); } async informExcludedCredential(existingCipherIds: string[]): Promise { diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index f93db44aa69..168753a1f2c 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -797,6 +797,12 @@ "unexpectedError": { "message": "An unexpected error has occurred." }, + "unexpectedErrorShort": { + "message": "Unexpected error" + }, + "closeThisBitwardenWindow": { + "message": "Close this Bitwarden window and try again." + }, "itemInformation": { "message": "Item information" }, @@ -3559,6 +3565,21 @@ "changeAcctEmail": { "message": "Change account email" }, + "passkeyLogin": { + "message": "Log in with passkey?" + }, + "savePasskeyQuestion": { + "message": "Save passkey?" + }, + "saveNewPasskey": { + "message": "Save as new login" + }, + "unableToSavePasskey": { + "message": "Unable to save passkey" + }, + "closeBitwarden": { + "message": "Close Bitwarden" + }, "allowScreenshots": { "message": "Allow screen capture" }, diff --git a/apps/desktop/src/main/tray.main.ts b/apps/desktop/src/main/tray.main.ts index b7ddefe6e1b..81df6497ca8 100644 --- a/apps/desktop/src/main/tray.main.ts +++ b/apps/desktop/src/main/tray.main.ts @@ -53,9 +53,14 @@ export class TrayMain { }, { visible: isDev(), - label: "Fake Popup", + label: "Fake Popup Select", click: () => this.fakePopup(), }, + { + visible: isDev(), + label: "Fake Popup Create", + click: () => this.fakePopupCreate(), + }, { type: "separator" }, { label: this.i18nService.t("exit"), @@ -218,4 +223,8 @@ export class TrayMain { private async fakePopup() { await this.messagingService.send("loadurl", { url: "/passkeys", modal: true }); } + + private async fakePopupCreate() { + await this.messagingService.send("loadurl", { url: "/create-passkey", modal: true }); + } } diff --git a/apps/desktop/src/modal/passkeys/create/fido2-create.component.html b/apps/desktop/src/modal/passkeys/create/fido2-create.component.html new file mode 100644 index 00000000000..e3423d6d7f8 --- /dev/null +++ b/apps/desktop/src/modal/passkeys/create/fido2-create.component.html @@ -0,0 +1,48 @@ +
+ + +
+ + +

+ {{ "savePasskeyQuestion" | i18n }} +

+
+ + +
+
+ + + + + {{ c.subTitle }} + Save + + + + + + + + +
diff --git a/apps/desktop/src/modal/passkeys/create/fido2-create.component.ts b/apps/desktop/src/modal/passkeys/create/fido2-create.component.ts new file mode 100644 index 00000000000..776ceae9d85 --- /dev/null +++ b/apps/desktop/src/modal/passkeys/create/fido2-create.component.ts @@ -0,0 +1,143 @@ +import { CommonModule } from "@angular/common"; +import { Component, OnInit } from "@angular/core"; +import { RouterModule, Router } from "@angular/router"; +import { BehaviorSubject, firstValueFrom, map, Observable } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +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 { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { + DialogService, + 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"; + +@Component({ + standalone: true, + imports: [ + CommonModule, + RouterModule, + SectionHeaderComponent, + BitIconButtonComponent, + TableModule, + JslibModule, + IconModule, + ButtonModule, + DialogModule, + SectionComponent, + ItemModule, + BadgeModule, + ], + templateUrl: "fido2-create.component.html", +}) +export class Fido2CreateComponent implements OnInit { + session?: DesktopFido2UserInterfaceSession = null; + private ciphersSubject = new BehaviorSubject([]); + ciphers$: Observable = this.ciphersSubject.asObservable(); + readonly Icons = { BitwardenShield }; + + constructor( + private readonly desktopSettingsService: DesktopSettingsService, + private readonly fido2UserInterfaceService: DesktopFido2UserInterfaceService, + private readonly accountService: AccountService, + private readonly cipherService: CipherService, + private readonly dialogService: DialogService, + private readonly domainSettingsService: DomainSettingsService, + private readonly logService: LogService, + private readonly router: Router, + ) {} + + async ngOnInit() { + this.session = this.fido2UserInterfaceService.getCurrentSession(); + const rpid = await this.session.getRpId(); + const equivalentDomains = await firstValueFrom( + this.domainSettingsService.getUrlEquivalentDomains(rpid), + ); + const activeUserId = await firstValueFrom( + this.accountService.activeAccount$.pipe(map((a) => a?.id)), + ); + + this.cipherService + .getAllDecrypted(activeUserId) + .then((ciphers) => { + const relevantCiphers = ciphers.filter((cipher) => { + if (!cipher.login || !cipher.login.hasUris) { + return false; + } + + return ( + cipher.login.matchesUri(rpid, equivalentDomains) && + (!cipher.login.fido2Credentials || cipher.login.fido2Credentials.length === 0) + ); + }); + this.ciphersSubject.next(relevantCiphers); + }) + .catch((error) => this.logService.error(error)); + } + + async addPasskeyToCipher(cipher: CipherView) { + this.session.notifyConfirmCreateCredential(true, cipher); + } + + async confirmPasskey() { + try { + // Retrieve the current UI session to control the flow + if (!this.session) { + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: "unexpectedErrorShort" }, + content: { key: "closeThisBitwardenWindow" }, + type: "danger", + acceptButtonText: { key: "closeBitwarden" }, + cancelButtonText: null, + }); + if (confirmed) { + await this.closeModal(); + } + } else { + this.session.notifyConfirmCreateCredential(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.setModalMode(false); + } catch { + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: "unableToSavePasskey" }, + content: { key: "closeThisBitwardenWindow" }, + type: "danger", + acceptButtonText: { key: "closeBitwarden" }, + cancelButtonText: null, + }); + + if (confirmed) { + await this.closeModal(); + } + } + } + + 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-vault.component.html b/apps/desktop/src/modal/passkeys/fido2-vault.component.html new file mode 100644 index 00000000000..edc241c4d21 --- /dev/null +++ b/apps/desktop/src/modal/passkeys/fido2-vault.component.html @@ -0,0 +1,36 @@ +
+ + +
+ + +

{{ "passkeyLogin" | i18n }}

+
+ +
+
+ + + + + {{ c.subTitle }} + Select + + + +
diff --git a/apps/desktop/src/modal/passkeys/fido2-vault.component.ts b/apps/desktop/src/modal/passkeys/fido2-vault.component.ts new file mode 100644 index 00000000000..0549d70fa0a --- /dev/null +++ b/apps/desktop/src/modal/passkeys/fido2-vault.component.ts @@ -0,0 +1,102 @@ +import { CommonModule } from "@angular/common"; +import { Component, OnInit, OnDestroy } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { RouterModule, Router } from "@angular/router"; +import { firstValueFrom, map, BehaviorSubject, Observable } from "rxjs"; + +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 { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { + BadgeModule, + ButtonModule, + DialogModule, + IconModule, + ItemModule, + SectionComponent, + TableModule, + BitIconButtonComponent, + SectionHeaderComponent, +} from "@bitwarden/components"; + +import { + DesktopFido2UserInterfaceService, + DesktopFido2UserInterfaceSession, +} from "../../autofill/services/desktop-fido2-user-interface.service"; +import { DesktopSettingsService } from "../../platform/services/desktop-settings.service"; + +@Component({ + standalone: true, + imports: [ + CommonModule, + RouterModule, + SectionHeaderComponent, + BitIconButtonComponent, + TableModule, + JslibModule, + IconModule, + ButtonModule, + DialogModule, + SectionComponent, + ItemModule, + BadgeModule, + ], + templateUrl: "fido2-vault.component.html", +}) +export class Fido2VaultComponent implements OnInit, OnDestroy { + session?: DesktopFido2UserInterfaceSession = null; + private ciphersSubject = new BehaviorSubject([]); + ciphers$: Observable = this.ciphersSubject.asObservable(); + private cipherIdsSubject = new BehaviorSubject([]); + cipherIds$: Observable; + readonly Icons = { BitwardenShield }; + + constructor( + private readonly desktopSettingsService: DesktopSettingsService, + private readonly fido2UserInterfaceService: DesktopFido2UserInterfaceService, + private readonly cipherService: CipherService, + private readonly accountService: AccountService, + private readonly logService: LogService, + private readonly router: Router, + ) {} + + async ngOnInit() { + const activeUserId = await firstValueFrom( + this.accountService.activeAccount$.pipe(map((a) => a?.id)), + ); + + this.session = this.fido2UserInterfaceService.getCurrentSession(); + this.cipherIds$ = this.session?.availableCipherIds$; + + this.cipherIds$.pipe(takeUntilDestroyed()).subscribe((cipherIds) => { + this.cipherService + .getAllDecrypted(activeUserId) + .then((ciphers) => { + this.ciphersSubject.next(ciphers.filter((cipher) => cipherIds.includes(cipher.id))); + }) + .catch((error) => this.logService.error(error)); + }); + } + + ngOnDestroy() { + this.cipherIdsSubject.complete(); // Clean up the BehaviorSubject + } + + async chooseCipher(cipherId: string) { + this.session?.confirmChosenCipher(cipherId, true); + + await this.router.navigate(["/"]); + await this.desktopSettingsService.setModalMode(false); + } + + async closeModal() { + await this.router.navigate(["/"]); + await this.desktopSettingsService.setModalMode(false); + + this.session.notifyConfirmCreateCredential(false); + this.session.confirmChosenCipher(null); + } +} diff --git a/libs/common/src/platform/abstractions/fido2/fido2-user-interface.service.abstraction.ts b/libs/common/src/platform/abstractions/fido2/fido2-user-interface.service.abstraction.ts index 1f871f6c70f..b8aed853eac 100644 --- a/libs/common/src/platform/abstractions/fido2/fido2-user-interface.service.abstraction.ts +++ b/libs/common/src/platform/abstractions/fido2/fido2-user-interface.service.abstraction.ts @@ -97,7 +97,7 @@ export abstract class Fido2UserInterfaceSession { */ confirmNewCredential: ( params: NewCredentialParams, - ) => Promise<{ cipherId: string; userVerified: boolean }>; + ) => Promise<{ cipherId?: string; userVerified: boolean }>; /** * Make sure that the vault is unlocked. diff --git a/libs/components/src/icon-button/index.ts b/libs/components/src/icon-button/index.ts index 9da4a3162bf..b753e53c96a 100644 --- a/libs/components/src/icon-button/index.ts +++ b/libs/components/src/icon-button/index.ts @@ -1 +1,2 @@ export * from "./icon-button.module"; +export * from "./icon-button.component";