diff --git a/apps/desktop/src/app/app-routing.module.ts b/apps/desktop/src/app/app-routing.module.ts index 071706a1e32..935291be2ae 100644 --- a/apps/desktop/src/app/app-routing.module.ts +++ b/apps/desktop/src/app/app-routing.module.ts @@ -338,7 +338,7 @@ const routes: Routes = [ component: Fido2VaultComponent, }, { - path: "create-passkey", + path: "passkey-create", component: Fido2CreateComponent, }, { diff --git a/apps/desktop/src/app/components/fido2placeholder.component.ts b/apps/desktop/src/app/components/fido2placeholder.component.ts new file mode 100644 index 00000000000..fa6fbae86d8 --- /dev/null +++ b/apps/desktop/src/app/components/fido2placeholder.component.ts @@ -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: ` +
+

Select your passkey

+ +
+ +
+ +
+ + +
+ `, +}) +export class Fido2PlaceholderComponent implements OnInit, OnDestroy { + session?: DesktopFido2UserInterfaceSession = null; + private cipherIdsSubject = new BehaviorSubject([]); + cipherIds$: Observable = this.cipherIdsSubject.asObservable(); + + constructor( + private readonly desktopSettingsService: DesktopSettingsService, + private readonly fido2UserInterfaceService: DesktopFido2UserInterfaceService, + private readonly router: Router, + ) {} + + async ngOnInit(): Promise { + 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); + } +} diff --git a/apps/desktop/src/autofill/services/desktop-autofill.service.ts b/apps/desktop/src/autofill/services/desktop-autofill.service.ts index 1a1c46c822a..3d19699c2eb 100644 --- a/apps/desktop/src/autofill/services/desktop-autofill.service.ts +++ b/apps/desktop/src/autofill/services/desktop-autofill.service.ts @@ -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, }; } 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 919a9e0365c..4e89af55535 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 @@ -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(); + private createdCipher: Cipher; + private updatedCipher: CipherView; + + private availableCipherIds = new BehaviorSubject(null); + private rpId = new BehaviorSubject(null); + + private chosenCipherSubject = new Subject(); // 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 { + return lastValueFrom( + this.availableCipherIds.pipe( + filter((ids) => ids != null), + take(1), + timeout(50000), + ), + ); + } + + async getRpId(): Promise { + 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 { + 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 { + private async waitForUiNewCredentialConfirmation(): Promise { 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 { + async createCipher({ credentialName, userName, rpId }: NewCredentialParams): Promise { // 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 { + 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 { this.logService.warning("informExcludedCredential", existingCipherIds); } diff --git a/apps/desktop/src/modal/passkeys/create/fido2-create.component.html b/apps/desktop/src/modal/passkeys/create/fido2-create.component.html index e205ab52fd6..8dbf6639a67 100644 --- a/apps/desktop/src/modal/passkeys/create/fido2-create.component.html +++ b/apps/desktop/src/modal/passkeys/create/fido2-create.component.html @@ -23,15 +23,24 @@ - - {{ c.subTitle }} - Select + 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 index d7e8b9b693e..34bb0b163da 100644 --- a/apps/desktop/src/modal/passkeys/create/fido2-create.component.ts +++ b/apps/desktop/src/modal/passkeys/create/fido2-create.component.ts @@ -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([]); + ciphers$: Observable = 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() { diff --git a/apps/desktop/src/modal/passkeys/fido2-vault.component.html b/apps/desktop/src/modal/passkeys/fido2-vault.component.html index c3960141709..9e1cf9433be 100644 --- a/apps/desktop/src/modal/passkeys/fido2-vault.component.html +++ b/apps/desktop/src/modal/passkeys/fido2-vault.component.html @@ -22,8 +22,8 @@ - -