diff --git a/apps/desktop/desktop_native/napi/index.d.ts b/apps/desktop/desktop_native/napi/index.d.ts index 375c65edb8d..1e0c9cca5cd 100644 --- a/apps/desktop/desktop_native/napi/index.d.ts +++ b/apps/desktop/desktop_native/napi/index.d.ts @@ -9,13 +9,14 @@ export declare namespace autofill { * connection and must be the same for both the server and client. @param callback * This function will be called whenever a message is received from a client. */ - static listen(name: string, registrationCallback: (error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyRegistrationRequest) => void, assertionCallback: (error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyAssertionRequest) => void, assertionWithoutUserInterfaceCallback: (error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyAssertionWithoutUserInterfaceRequest) => void, nativeStatusCallback: (error: null | Error, clientId: number, sequenceNumber: number, message: NativeStatus) => void): Promise + static listen(name: string, registrationCallback: (error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyRegistrationRequest) => void, assertionCallback: (error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyAssertionRequest) => void, assertionWithoutUserInterfaceCallback: (error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyAssertionWithoutUserInterfaceRequest) => void, nativeStatusCallback: (error: null | Error, clientId: number, sequenceNumber: number, message: NativeStatus) => void, windowHandleQueryCallback: (error: null | Error, clientId: number, sequenceNumber: number, message: WindowHandleQueryRequest) => void): Promise /** Return the path to the IPC server. */ getPath(): string /** Stop the IPC server. */ stop(): void completeRegistration(clientId: number, sequenceNumber: number, response: PasskeyRegistrationResponse): number completeAssertion(clientId: number, sequenceNumber: number, response: PasskeyAssertionResponse): number + completeWindowHandleQuery(clientId: number, sequenceNumber: number, response: WindowHandleQueryResponse): number completeError(clientId: number, sequenceNumber: number, error: string): number } export interface NativeStatus { @@ -28,6 +29,8 @@ export declare namespace autofill { userVerification: UserVerification allowedCredentials: Array> windowXy: Position + clientWindowHandle?: Array + context?: string } export interface PasskeyAssertionResponse { rpId: string @@ -46,6 +49,8 @@ export declare namespace autofill { clientDataHash: Array userVerification: UserVerification windowXy: Position + clientWindowHandle?: Array + context?: string } export interface PasskeyRegistrationRequest { rpId: string @@ -56,6 +61,8 @@ export declare namespace autofill { supportedAlgorithms: Array windowXy: Position excludedCredentials: Array> + clientWindowHandle?: Array + context?: string } export interface PasskeyRegistrationResponse { rpId: string @@ -73,6 +80,14 @@ export declare namespace autofill { Required = 'required', Discouraged = 'discouraged' } + export interface WindowHandleQueryRequest { + windowHandle: string + } + export interface WindowHandleQueryResponse { + isVisible: boolean + isFocused: boolean + handle: string + } } export declare namespace autostart { diff --git a/apps/desktop/desktop_native/napi/src/lib.rs b/apps/desktop/desktop_native/napi/src/lib.rs index 588f757631c..e2ef395424f 100644 --- a/apps/desktop/desktop_native/napi/src/lib.rs +++ b/apps/desktop/desktop_native/napi/src/lib.rs @@ -667,6 +667,22 @@ pub mod autofill { Discouraged, } + #[napi(object)] + #[derive(Debug, Serialize, Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct WindowHandleQueryRequest { + pub window_handle: String, + } + + #[napi(object)] + #[derive(Debug, Serialize, Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct WindowHandleQueryResponse { + pub is_visible: bool, + pub is_focused: bool, + pub handle: String, + } + #[derive(Serialize, Deserialize)] #[serde(bound = "T: Serialize + DeserializeOwned")] pub struct PasskeyMessage { @@ -694,6 +710,8 @@ pub mod autofill { pub supported_algorithms: Vec, pub window_xy: Position, pub excluded_credentials: Vec>, + pub client_window_handle: Option>, + pub context: Option, } #[napi(object)] @@ -715,6 +733,8 @@ pub mod autofill { pub user_verification: UserVerification, pub allowed_credentials: Vec>, pub window_xy: Position, + pub client_window_handle: Option>, + pub context: Option, //extension_input: Vec, TODO: Implement support for extensions } @@ -730,6 +750,8 @@ pub mod autofill { pub client_data_hash: Vec, pub user_verification: UserVerification, pub window_xy: Position, + pub client_window_handle: Option>, + pub context: Option, } #[napi(object)] @@ -794,6 +816,12 @@ pub mod autofill { ts_arg_type = "(error: null | Error, clientId: number, sequenceNumber: number, message: NativeStatus) => void" )] native_status_callback: ThreadsafeFunction<(u32, u32, NativeStatus)>, + #[napi( + ts_arg_type = "(error: null | Error, clientId: number, sequenceNumber: number, message: WindowHandleQueryRequest) => void" + )] + window_handle_query_callback: ThreadsafeFunction< + FnArgs<(u32, u32, WindowHandleQueryRequest)>, + >, ) -> napi::Result { let (send, mut recv) = tokio::sync::mpsc::channel::(32); tokio::spawn(async move { @@ -812,6 +840,24 @@ pub mod autofill { continue; }; + match serde_json::from_str::>( + &message, + ) { + Ok(msg) => { + let value = msg + .value + .map(|value| (client_id, msg.sequence_number, value).into()) + .map_err(|e| napi::Error::from_reason(format!("{e:?}"))); + + window_handle_query_callback + .call(value, ThreadsafeFunctionCallMode::NonBlocking); + continue; + } + Err(e) => { + tracing::warn!(error = %e, "Could not deserialize request as WindowHandleQueryRequest. Trying other types..."); + } + } + match serde_json::from_str::>( &message, ) { @@ -826,7 +872,7 @@ pub mod autofill { continue; } Err(e) => { - error!(error = %e, "Error deserializing message1"); + error!(error = %e, "Error deserializing as PasskeyAssertionRequest"); } } @@ -845,7 +891,7 @@ pub mod autofill { continue; } Err(e) => { - error!(error = %e, "Error deserializing message1"); + error!(error = %e, "Error deserializing as PasskeyAssertionWithoutUserInterfaceRequest"); } } @@ -862,7 +908,7 @@ pub mod autofill { continue; } Err(e) => { - error!(error = %e, "Error deserializing message2"); + error!(error = %e, "Error deserializing PasskeyRegistrationRequest"); } } @@ -877,11 +923,11 @@ pub mod autofill { continue; } Err(error) => { - error!(%error, "Unable to deserialze native status."); + error!(%error, "Unable to deserialize as native status."); } } - error!(message, "Received an unknown message2"); + error!(message, "Received an unknown message"); } } } @@ -939,6 +985,20 @@ pub mod autofill { self.send(client_id, serde_json::to_string(&message).unwrap()) } + #[napi] + pub fn complete_window_handle_query( + &self, + client_id: u32, + sequence_number: u32, + response: WindowHandleQueryResponse, + ) -> napi::Result { + let message = PasskeyMessage { + sequence_number, + value: Ok(response), + }; + self.send(client_id, serde_json::to_string(&message).unwrap()) + } + #[napi] pub fn complete_error( &self, diff --git a/apps/desktop/src/autofill/modal/credentials/fido2-create.component.spec.ts b/apps/desktop/src/autofill/modal/credentials/fido2-create.component.spec.ts index dbef860aafe..002410ca459 100644 --- a/apps/desktop/src/autofill/modal/credentials/fido2-create.component.spec.ts +++ b/apps/desktop/src/autofill/modal/credentials/fido2-create.component.spec.ts @@ -149,6 +149,7 @@ describe("Fido2CreateComponent", () => { describe("addCredentialToCipher", () => { beforeEach(() => { component.session = mockSession; + mockSession.promptForUserVerification.mockResolvedValue(true) }); it("should add passkey to cipher", async () => { @@ -202,6 +203,7 @@ describe("Fido2CreateComponent", () => { describe("confirmPasskey", () => { beforeEach(() => { component.session = mockSession; + mockSession.promptForUserVerification.mockResolvedValue(true); }); it("should confirm passkey creation successfully", async () => { diff --git a/apps/desktop/src/autofill/modal/credentials/fido2-create.component.ts b/apps/desktop/src/autofill/modal/credentials/fido2-create.component.ts index 67237bedccd..c5217e783f1 100644 --- a/apps/desktop/src/autofill/modal/credentials/fido2-create.component.ts +++ b/apps/desktop/src/autofill/modal/credentials/fido2-create.component.ts @@ -127,7 +127,8 @@ export class Fido2CreateComponent implements OnInit, OnDestroy { return; } - await this.closeModal(); + // If we want to hide the UI while prompting for UV from the OS, we cannot call closeModal(). + // await this.closeModal(); } async confirmPasskey(): Promise { @@ -136,9 +137,12 @@ export class Fido2CreateComponent implements OnInit, OnDestroy { throw new Error("Missing session"); } - this.session.notifyConfirmCreateCredential(true); + const username = await this.session.getUserName(); + const isConfirmed = await this.session.promptForUserVerification(username, "Verify it's you to create a new credential") + this.session.notifyConfirmCreateCredential(isConfirmed); } catch { await this.showErrorDialog(this.DIALOG_MESSAGES.unableToSavePasskey); + return; } await this.closeModal(); @@ -209,7 +213,8 @@ export class Fido2CreateComponent implements OnInit, OnDestroy { return this.passwordRepromptService.showPasswordPrompt(); } - return true; + const username = cipher.login.username ?? cipher.name + return this.session.promptForUserVerification(username, "Verify it's you to overwrite a credential") } private async showErrorDialog(config: SimpleDialogOptions): Promise { diff --git a/apps/desktop/src/autofill/modal/credentials/fido2-vault.component.spec.ts b/apps/desktop/src/autofill/modal/credentials/fido2-vault.component.spec.ts index 70ef4461f6a..fe8ba1eccff 100644 --- a/apps/desktop/src/autofill/modal/credentials/fido2-vault.component.spec.ts +++ b/apps/desktop/src/autofill/modal/credentials/fido2-vault.component.spec.ts @@ -50,6 +50,7 @@ describe("Fido2VaultComponent", () => { mockAccountService.activeAccount$ = of(mockActiveAccount as Account); mockFido2UserInterfaceService.getCurrentSession.mockReturnValue(mockSession); mockSession.availableCipherIds$ = of(mockCipherIds); + mockSession.promptForUserVerification.mockResolvedValue(true); mockCipherService.cipherListViews$ = jest.fn().mockReturnValue(of([])); await TestBed.configureTestingModule({ diff --git a/apps/desktop/src/autofill/modal/credentials/fido2-vault.component.ts b/apps/desktop/src/autofill/modal/credentials/fido2-vault.component.ts index 897e825c53e..ba8e097df92 100644 --- a/apps/desktop/src/autofill/modal/credentials/fido2-vault.component.ts +++ b/apps/desktop/src/autofill/modal/credentials/fido2-vault.component.ts @@ -156,6 +156,7 @@ export class Fido2VaultComponent implements OnInit, OnDestroy { return this.passwordRepromptService.showPasswordPrompt(); } - return true; + const username = cipher.login.username ?? cipher.name; + return this.session.promptForUserVerification(username, "Verify it's you to log in"); } } diff --git a/apps/desktop/src/autofill/preload.ts b/apps/desktop/src/autofill/preload.ts index f4f5552944c..10c00fdbf16 100644 --- a/apps/desktop/src/autofill/preload.ts +++ b/apps/desktop/src/autofill/preload.ts @@ -191,4 +191,42 @@ export default { }, ); }, + listenGetWindowHandle: ( + fn: ( + clientId: number, + sequenceNumber: number, + request: autofill.WindowHandleQueryRequest, + completeCallback: (error: Error | null, response: autofill.WindowHandleQueryResponse) => void, + ) => void, + ) => { + ipcRenderer.on( + "autofill.windowHandleQuery", + ( + event, + data: { + clientId: number; + sequenceNumber: number; + request: autofill.WindowHandleQueryRequest; + }, + ) => { + const { clientId, sequenceNumber, request } = data; + fn(clientId, sequenceNumber, request, (error, response) => { + if (error) { + ipcRenderer.send("autofill.completeError", { + clientId, + sequenceNumber, + error: error.message, + }); + return; + } + + ipcRenderer.send("autofill.completeWindowHandleQuery", { + clientId, + sequenceNumber, + response, + }); + }); + }, + ); + }, }; diff --git a/apps/desktop/src/autofill/services/desktop-autofill.service.ts b/apps/desktop/src/autofill/services/desktop-autofill.service.ts index 953ae111397..ddd94dfae05 100644 --- a/apps/desktop/src/autofill/services/desktop-autofill.service.ts +++ b/apps/desktop/src/autofill/services/desktop-autofill.service.ts @@ -55,6 +55,7 @@ export class DesktopAutofillService implements OnDestroy { private registrationRequest: autofill.PasskeyRegistrationRequest; private featureFlag?: FeatureFlag; private isEnabled: boolean = false; + private inFlightRequests: Record = {}; constructor( private logService: LogService, @@ -145,8 +146,8 @@ export class DesktopAutofillService implements OnDestroy { return; } - let fido2Credentials: NativeAutofillFido2Credential[]; - let passwordCredentials: NativeAutofillPasswordCredential[]; + let fido2Credentials: NativeAutofillFido2Credential[] = []; + let passwordCredentials: NativeAutofillPasswordCredential[] = []; if (status.value.support.password) { passwordCredentials = cipherViews @@ -223,18 +224,27 @@ export class DesktopAutofillService implements OnDestroy { this.logService.debug("listenPasskeyRegistration2", this.convertRegistrationRequest(request)); const controller = new AbortController(); + if (request.context) { + this.inFlightRequests[request.context] = controller; + } + const clientHandle = request.clientWindowHandle ? new Uint8Array(request.clientWindowHandle) : null; try { const response = await this.fido2AuthenticatorService.makeCredential( this.convertRegistrationRequest(request), - { windowXy: request.windowXy }, + { windowXy: request.windowXy, handle: clientHandle }, controller, + request.context, ); callback(null, this.convertRegistrationResponse(request, response)); } catch (error) { this.logService.error("listenPasskeyRegistration error", error); callback(error, null); + } finally { + if (request.context) { + delete this.inFlightRequests[request.context]; + } } }); @@ -256,6 +266,11 @@ export class DesktopAutofillService implements OnDestroy { ); const controller = new AbortController(); + if (request.context) { + this.inFlightRequests[request.context] = controller; + } + + const clientHandle = request.clientWindowHandle ? new Uint8Array(request.clientWindowHandle) : null; try { // For some reason the credentialId is passed as an empty array in the request, so we need to @@ -293,8 +308,9 @@ export class DesktopAutofillService implements OnDestroy { const response = await this.fido2AuthenticatorService.getAssertion( this.convertAssertionRequest(request, true), - { windowXy: request.windowXy }, + { windowXy: request.windowXy, handle: clientHandle }, controller, + request.context, ); callback(null, this.convertAssertionResponse(request, response)); @@ -302,6 +318,10 @@ export class DesktopAutofillService implements OnDestroy { this.logService.error("listenPasskeyAssertion error", error); callback(error, null); return; + } finally { + if (request.context) { + delete this.inFlightRequests[request.context]; + } } }, ); @@ -318,10 +338,15 @@ export class DesktopAutofillService implements OnDestroy { this.logService.debug("listenPasskeyAssertion", clientId, sequenceNumber, request); const controller = new AbortController(); + if (request.context) { + this.inFlightRequests[request.context] = controller; + } + + const clientHandle = request.clientWindowHandle ? new Uint8Array(request.clientWindowHandle) : null; try { const response = await this.fido2AuthenticatorService.getAssertion( this.convertAssertionRequest(request), - { windowXy: request.windowXy }, + { windowXy: request.windowXy, handle: clientHandle }, controller, ); @@ -329,6 +354,10 @@ export class DesktopAutofillService implements OnDestroy { } catch (error) { this.logService.error("listenPasskeyAssertion error", error); callback(error, null); + } finally { + if (request.context) { + delete this.inFlightRequests[request.context]; + } } }); @@ -345,9 +374,35 @@ export class DesktopAutofillService implements OnDestroy { if (status.key === "request-sync") { // perform ad-hoc sync await this.adHocSync(); + } else if (status.key === "cancel-operation" && status.value) { + const requestId = status.value + const controller = this.inFlightRequests[requestId] + if (controller) { + this.logService.debug(`Cancelling request ${requestId}`); + controller.abort("Operation cancelled") + } + else { + this.logService.debug(`Unknown request: ${requestId}`); + } } }); + ipc.autofill.listenGetWindowHandle(async (clientId, sequenceNumber, request, callback) => { + if (!this.isEnabled) { + this.logService.debug( + `listenGetWindowHandle: Native credential sync feature flag is disabled`, + ); + return; + } + + this.logService.debug("listenGetWindowHandle", clientId, sequenceNumber, request); + const windowDetails = await ipc.platform.getNativeWindowDetails(); + const handle = Utils.fromBufferToB64(windowDetails.handle); + const response = { ...windowDetails, handle }; + this.logService.debug("listenGetWindowHandle: sending", response); + callback(null, response) + }) + ipc.autofill.listenerReady(); } 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 cf29370840d..778edc21f69 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 @@ -21,6 +21,7 @@ import { } from "@bitwarden/common/platform/abstractions/fido2/fido2-user-interface.service.abstraction"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherRepromptType, CipherType, SecureNoteType } from "@bitwarden/common/vault/enums"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; @@ -31,6 +32,7 @@ import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view import { LoginView } from "@bitwarden/common/vault/models/view/login.view"; import { SecureNoteView } from "@bitwarden/common/vault/models/view/secure-note.view"; +import { NativeAutofillUserVerificationCommand } from "../../platform/main/autofill/user-verification.command"; import { DesktopSettingsService } from "../../platform/services/desktop-settings.service"; /** @@ -41,6 +43,14 @@ export type NativeWindowObject = { * The position of the window, first entry is the x position, second is the y position */ windowXy?: { x: number; y: number }; + + /** + * A byte string representing a native window handle. + * Platform differences: + * - macOS: NSView* + * - Windows: HWND + */ + handle?: Uint8Array; }; export class DesktopFido2UserInterfaceService implements Fido2UserInterfaceServiceAbstraction { @@ -63,6 +73,7 @@ export class DesktopFido2UserInterfaceService implements Fido2UserInterfaceServi fallbackSupported: boolean, nativeWindowObject: NativeWindowObject, abortController?: AbortController, + transactionContext?: string, ): Promise { this.logService.debug("newSession", fallbackSupported, abortController, nativeWindowObject); const session = new DesktopFido2UserInterfaceSession( @@ -73,6 +84,8 @@ export class DesktopFido2UserInterfaceService implements Fido2UserInterfaceServi this.router, this.desktopSettingsService, nativeWindowObject, + abortController, + transactionContext, ); this.currentSession = session; @@ -89,6 +102,8 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi private router: Router, private desktopSettingsService: DesktopSettingsService, private windowObject: NativeWindowObject, + private abortController: AbortController, + private transactionContext: string, ) {} private confirmCredentialSubject = new Subject(); @@ -96,6 +111,7 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi private updatedCipher: CipherView; private rpId = new BehaviorSubject(null); + private userName = new BehaviorSubject(null); private availableCipherIdsSubject = new BehaviorSubject([""]); /** * Observable that emits available cipher IDs once they're confirmed by the UI @@ -125,15 +141,45 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi // Check if we can return the credential without user interaction await this.accountService.setShowHeader(false); if (assumeUserPresence && cipherIds.length === 1 && !masterPasswordRepromptRequired) { - this.logService.debug( - "shortcut - Assuming user presence and returning cipherId", - cipherIds[0], - ); - return { cipherId: cipherIds[0], userVerified: userVerification }; + const selectedCipherId = cipherIds[0]; + if (userVerification) { + // retrieve the cipher + const activeUserId = await firstValueFrom( + this.accountService.activeAccount$.pipe(map((a) => a?.id)), + ); + + if (!activeUserId) { + return; + } + const cipherView = await firstValueFrom(this.cipherService.cipherListViews$(activeUserId).pipe(map((ciphers) => { + return ciphers.find((cipher) => cipher.id == selectedCipherId && !cipher.deletedDate) as CipherView; + }))); + + const username = cipherView.login.username ?? cipherView.name + try { + // TODO: internationalization + const isConfirmed = await this.promptForUserVerification(username, "Verify it's you to log in with Bitwarden."); + return { cipherId: cipherIds[0], userVerified: isConfirmed }; + } + catch (e) { + this.logService.debug("Failed to prompt for user verification without showing UI", e) + } + } + else { + this.logService.warning( + "shortcut - Assuming user presence and returning cipherId", + cipherIds[0], + ); + return { cipherId: cipherIds[0], userVerified: userVerification }; + } } this.logService.debug("Could not shortcut, showing UI"); + // TODO: We need to pass context from the original request whether this + // should be a silent request or not. Then, we can fail here if it's + // supposed to be silent. + // make the cipherIds available to the UI. this.availableCipherIdsSubject.next(cipherIds); @@ -158,6 +204,10 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi return firstValueFrom(this.rpId.pipe(filter((id) => id != null))); } + async getUserName(): Promise { + return firstValueFrom(this.userName.pipe(filter((u) => u != null))); + } + confirmChosenCipher(cipherId: string, userVerified: boolean = false): void { this.chosenCipherSubject.next({ cipherId, userVerified }); this.chosenCipherSubject.complete(); @@ -166,14 +216,24 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi private async waitForUiChosenCipher( timeoutMs: number = 60000, ): Promise<{ cipherId?: string; userVerified: boolean } | undefined> { + const { promise: cancelPromise, listener: abortFn } = this.subscribeToCancellation(); try { - return await lastValueFrom(this.chosenCipherSubject.pipe(timeout(timeoutMs))); - } catch { - // If we hit a timeout, return undefined instead of throwing - this.logService.warning("Timeout: User did not select a cipher within the allowed time", { - timeoutMs, - }); + this.abortController.signal.throwIfAborted(); + const confirmPromise = lastValueFrom(this.chosenCipherSubject.pipe(timeout(timeoutMs))); + return await Promise.race([confirmPromise, cancelPromise]); + } catch (e) { + // If we hit a timeout or if the request is cancelled, return undefined instead of throwing + if (e.name === "AbortError") { + this.logService.warning("Request was cancelled before the user selected a cipher"); + } + else { + this.logService.warning("Timeout: User did not select a cipher within the allowed time", { + timeoutMs, + }); + } return { cipherId: undefined, userVerified: false }; + } finally { + this.unsusbscribeCancellation(abortFn); } } @@ -193,7 +253,19 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi * @returns */ private async waitForUiNewCredentialConfirmation(): Promise { - return lastValueFrom(this.confirmCredentialSubject); + const { promise: cancelPromise, listener: abortFn } = this.subscribeToCancellation(); + try { + this.abortController.signal.throwIfAborted(); + const confirmPromise = lastValueFrom(this.confirmCredentialSubject); + return await Promise.race([confirmPromise, cancelPromise]); + } catch (e) { + // If the request is cancelled, return undefined instead of throwing + this.logService.warning("Request was cancelled before the user confirmed the cipher"); + return undefined; + } + finally { + this.unsusbscribeCancellation(abortFn); + } } /** @@ -217,6 +289,7 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi rpId, ); this.rpId.next(rpId); + this.userName.next(userName); try { await this.showUi("/fido2-creation", this.windowObject.windowXy, false); @@ -324,6 +397,46 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi ); } + /** Called by the UI to prompt the user for verification. May be fulfilled by the OS. */ + async promptForUserVerification(username: string, displayHint: string): Promise { + this.logService.info("DesktopFido2UserInterfaceSession] Prompting for user verification") + // If the UI was showing before (to unlock the vault), then use our + // window for the handle; otherwise, use the WebAuthn client's + // handle. + // + // For Windows, if the selected window handle is not in the foreground, then the Windows + // Hello dialog will also be in the background. + const windowDetails = await ipc.platform.getNativeWindowDetails(); + this.logService.debug("Window details:", windowDetails); + let windowHandle; + if (windowDetails.isVisible && windowDetails.isFocused) { + windowHandle = windowDetails.handle + this.logService.debug("Window is visible, setting Electron window as parent of Windows Hello UV dialog", windowHandle.buffer) + } + else { + windowHandle = this.windowObject.handle; + this.logService.debug("Window is not visible: setting client window as parent of Windows Hello UV dialog", windowHandle.buffer) + } + + this.logService.debug("Prompting for user verification"); + + const uvResult = await ipc.autofill.runCommand({ + namespace: "autofill", + command: "user-verification", + params: { + windowHandle: Utils.fromBufferToB64(windowHandle), + transactionContext: this.transactionContext, + username, + displayHint, + }, + }); + if (uvResult.type === "error") { + this.logService.error("Error getting user verification", uvResult.error); + return false; + } + return uvResult.type === "success"; + } + async informExcludedCredential(existingCipherIds: string[]): Promise { this.logService.debug("informExcludedCredential", existingCipherIds); @@ -342,16 +455,20 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi await this.showUi("/lock", this.windowObject.windowXy, true, true); let status2: AuthenticationStatus; + const { promise: cancelPromise, listener: abortFn } = this.subscribeToCancellation(); try { - status2 = await lastValueFrom( + const lockStatusPromise = lastValueFrom( this.authService.activeAccountStatus$.pipe( filter((s) => s === AuthenticationStatus.Unlocked), take(1), timeout(1000 * 60 * 5), // 5 minutes ), ); + status2 = await Promise.race([lockStatusPromise, cancelPromise]); } catch (error) { this.logService.warning("Error while waiting for vault to unlock", error); + } finally { + this.unsusbscribeCancellation(abortFn); } if (status2 === AuthenticationStatus.Unlocked) { @@ -372,4 +489,25 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi async close() { this.logService.debug("close"); } + + /** Returns a promise that will be rejected if the session's abort signal is fired. */ + subscribeToCancellation() { + let cancelReject: (reason?: any) => void; + const cancelPromise: Promise = new Promise((_, reject) => { + cancelReject = reject + }); + const abortFn = (ev: Event) => { + if (ev.target instanceof AbortSignal) { + cancelReject(ev.target.reason) + } + }; + this.abortController.signal.addEventListener("abort", abortFn, { once: true }); + + return { promise: cancelPromise, listener: abortFn }; + } + + /** Cleans up event listeners for cancellation */ + unsusbscribeCancellation(listener: (ev: Event) => void): void { + this.abortController.signal.removeEventListener("abort", listener); + } } diff --git a/apps/desktop/src/main/window.main.ts b/apps/desktop/src/main/window.main.ts index b2008d57bcd..dd66a834558 100644 --- a/apps/desktop/src/main/window.main.ts +++ b/apps/desktop/src/main/window.main.ts @@ -407,6 +407,14 @@ export class WindowMain { if (this.createWindowCallback) { this.createWindowCallback(this.win); } + + ipcMain.handle("get-native-window-details", (_event) => { + return { + isVisible: this.win.isVisible(), + isFocused: this.win.isFocused(), + handle: this.win.getNativeWindowHandle().toString("base64"), + }; + }); } // Retrieve the background color @@ -548,3 +556,10 @@ export class WindowMain { ); } } + +export type WindowDetails = { + isVisible: boolean, + isFocused: boolean, + // Base64-encoded native handle + handle: Buffer, +} diff --git a/apps/desktop/src/platform/main/autofill/command.ts b/apps/desktop/src/platform/main/autofill/command.ts index a8b5548052b..2549e617679 100644 --- a/apps/desktop/src/platform/main/autofill/command.ts +++ b/apps/desktop/src/platform/main/autofill/command.ts @@ -1,5 +1,6 @@ import { NativeAutofillStatusCommand } from "./status.command"; import { NativeAutofillSyncCommand } from "./sync.command"; +import { NativeAutofillUserVerificationCommand } from "./user-verification.command"; export type CommandDefinition = { namespace: string; @@ -20,4 +21,4 @@ export type IpcCommandInvoker = ( ) => Promise>; /** A list of all available commands */ -export type Command = NativeAutofillSyncCommand | NativeAutofillStatusCommand; +export type Command = NativeAutofillSyncCommand | NativeAutofillStatusCommand | NativeAutofillUserVerificationCommand; diff --git a/apps/desktop/src/platform/main/autofill/native-autofill.main.ts b/apps/desktop/src/platform/main/autofill/native-autofill.main.ts index c0d860d74db..0ba062a18bc 100644 --- a/apps/desktop/src/platform/main/autofill/native-autofill.main.ts +++ b/apps/desktop/src/platform/main/autofill/native-autofill.main.ts @@ -124,6 +124,19 @@ export class NativeAutofillMain { status, }); }, + // WindowHandleQueryCallback + (error, clientId, sequenceNumber, request) => { + if (error) { + this.logService.error("autofill.IpcServer.windowHandleQuery", error); + this.ipcServer.completeError(clientId, sequenceNumber, String(error)); + return; + } + this.safeSend("autofill.windowHandleQuery", { + clientId, + sequenceNumber, + request, + }); + }, ); ipcMain.on("autofill.listenerReady", () => { @@ -146,6 +159,12 @@ export class NativeAutofillMain { this.ipcServer?.completeAssertion(clientId, sequenceNumber, response); }); + ipcMain.on("autofill.completeWindowHandleQuery", (event, data) => { + this.logService.debug("autofill.completeWindowHandleQuery", data); + const { clientId, sequenceNumber, response } = data; + this.ipcServer.completeWindowHandleQuery(clientId, sequenceNumber, response); + }); + ipcMain.on("autofill.completeError", (event, data) => { this.logService.debug("autofill.completeError", data); const { clientId, sequenceNumber, error } = data; diff --git a/apps/desktop/src/platform/main/autofill/user-verification.command.ts b/apps/desktop/src/platform/main/autofill/user-verification.command.ts new file mode 100644 index 00000000000..688e39f3bb8 --- /dev/null +++ b/apps/desktop/src/platform/main/autofill/user-verification.command.ts @@ -0,0 +1,19 @@ +import { CommandDefinition, CommandOutput } from "./command"; + +export interface NativeAutofillUserVerificationCommand extends CommandDefinition { + name: "user-verification"; + input: NativeAutofillUserVerificationParams; + output: NativeAutofillUserVerificationResult; +} + +export type NativeAutofillUserVerificationParams = { + /** base64 string representing native window handle */ + windowHandle: string; + /** base64 string representing native transaction context */ + transactionContext: string; + displayHint: string; + username: string; +}; + + +export type NativeAutofillUserVerificationResult = CommandOutput<{}>; \ No newline at end of file diff --git a/apps/desktop/src/platform/preload.ts b/apps/desktop/src/platform/preload.ts index 5f643242a9c..4bc9a511752 100644 --- a/apps/desktop/src/platform/preload.ts +++ b/apps/desktop/src/platform/preload.ts @@ -4,6 +4,8 @@ import { DeviceType } from "@bitwarden/common/enums"; import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { ThemeType, LogLevelType } from "@bitwarden/common/platform/enums"; +import { WindowDetails } from "../main/window.main"; + import { EncryptedMessageResponse, LegacyMessageWrapper, @@ -144,6 +146,11 @@ export default { hideWindow: () => ipcRenderer.send("window-hide"), log: (level: LogLevelType, message?: any, ...optionalParams: any[]) => ipcRenderer.invoke("ipc.log", { level, message, optionalParams }), + getNativeWindowDetails: async (): Promise => { + const windowDetails = await ipcRenderer.invoke("get-native-window-details") + const handle = Buffer.from(windowDetails.handle, "base64") + return { ...windowDetails, handle } + }, openContextMenu: ( menu: { diff --git a/libs/common/src/platform/abstractions/fido2/fido2-authenticator.service.abstraction.ts b/libs/common/src/platform/abstractions/fido2/fido2-authenticator.service.abstraction.ts index 427266522e9..1a3ad39e990 100644 --- a/libs/common/src/platform/abstractions/fido2/fido2-authenticator.service.abstraction.ts +++ b/libs/common/src/platform/abstractions/fido2/fido2-authenticator.service.abstraction.ts @@ -12,13 +12,16 @@ export abstract class Fido2AuthenticatorService { * https://www.w3.org/TR/webauthn-3/#sctn-op-make-cred * * @param params Parameters for creating a new credential + * @param window A reference to the window of the WebAuthn client. * @param abortController An AbortController that can be used to abort the operation. + * @param transactionContext Context from the original WebAuthn request used for callbacks back to the WebAuthn client for user verification. * @returns A promise that resolves with the new credential and an attestation signature. **/ abstract makeCredential( params: Fido2AuthenticatorMakeCredentialsParams, window: ParentWindowReference, abortController?: AbortController, + transactionContext?: string, ): Promise; /** @@ -26,13 +29,16 @@ export abstract class Fido2AuthenticatorService { * https://www.w3.org/TR/webauthn-3/#sctn-op-get-assertion * * @param params Parameters for generating an assertion + * @param window A reference to the window of the WebAuthn client. * @param abortController An AbortController that can be used to abort the operation. + * @param transactionContext Context from the original WebAuthn request used for callbacks back to the WebAuthn client for user verification. * @returns A promise that resolves with the asserted credential and an assertion signature. */ abstract getAssertion( params: Fido2AuthenticatorGetAssertionParams, window: ParentWindowReference, abortController?: AbortController, + transactionContext?: string, ): Promise; /** 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 b8be164c837..21581cfbe40 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 @@ -65,12 +65,15 @@ export abstract class Fido2UserInterfaceService { * Note: This will not necessarily open a window until it is needed to request something from the user. * * @param fallbackSupported Whether or not the browser natively supports WebAuthn. + * @param window A reference to the window of the WebAuthn client. * @param abortController An abort controller that can be used to cancel/close the session. + * @param transactionContext Context from the original WebAuthn request used for callbacks back to the WebAuthn client for user verification. */ abstract newSession( fallbackSupported: boolean, window: ParentWindowReference, abortController?: AbortController, + transactionContext?: string, ): Promise; } @@ -79,7 +82,6 @@ export abstract class Fido2UserInterfaceSession { * Ask the user to pick a credential from a list of existing credentials. * * @param params The parameters to use when asking the user to pick a credential. - * @param abortController An abort controller that can be used to cancel/close the session. * @returns The ID of the cipher that contains the credentials the user picked. If not cipher was picked, return cipherId = undefined to to let the authenticator throw the error. */ abstract pickCredential( @@ -90,7 +92,6 @@ export abstract class Fido2UserInterfaceSession { * Ask the user to confirm the creation of a new credential. * * @param params The parameters to use when asking the user to confirm the creation of a new credential. - * @param abortController An abort controller that can be used to cancel/close the session. * @returns The ID of the cipher where the new credential should be saved. */ abstract confirmNewCredential( diff --git a/libs/common/src/platform/services/fido2/fido2-authenticator.service.ts b/libs/common/src/platform/services/fido2/fido2-authenticator.service.ts index d1081e9f7b2..0edc1a7390f 100644 --- a/libs/common/src/platform/services/fido2/fido2-authenticator.service.ts +++ b/libs/common/src/platform/services/fido2/fido2-authenticator.service.ts @@ -61,11 +61,13 @@ export class Fido2AuthenticatorService< params: Fido2AuthenticatorMakeCredentialsParams, window: ParentWindowReference, abortController?: AbortController, + transactionContext?: string, ): Promise { const userInterfaceSession = await this.userInterface.newSession( params.fallbackSupported, window, abortController, + transactionContext, ); try { @@ -128,6 +130,7 @@ export class Fido2AuthenticatorService< let userVerified = false; let credentialId: string; let pubKeyDer: ArrayBuffer; + const response = await userInterfaceSession.confirmNewCredential({ credentialName: params.rpEntity.name, userName: params.userEntity.name, @@ -230,11 +233,13 @@ export class Fido2AuthenticatorService< params: Fido2AuthenticatorGetAssertionParams, window: ParentWindowReference, abortController?: AbortController, + transactionContext?: string, ): Promise { const userInterfaceSession = await this.userInterface.newSession( params.fallbackSupported, window, abortController, + transactionContext, ); try { if (