1
0
mirror of https://github.com/bitwarden/browser synced 2026-01-30 00:03:30 +00:00

Scaffold OS user verification

This commit is contained in:
Isaiah Inuwa
2026-01-07 10:20:09 -06:00
parent bcaefeac47
commit 2442e6048e
17 changed files with 419 additions and 31 deletions

View File

@@ -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<AutofillIpcServer>
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<AutofillIpcServer>
/** 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<Array<number>>
windowXy: Position
clientWindowHandle?: Array<number>
context?: string
}
export interface PasskeyAssertionResponse {
rpId: string
@@ -46,6 +49,8 @@ export declare namespace autofill {
clientDataHash: Array<number>
userVerification: UserVerification
windowXy: Position
clientWindowHandle?: Array<number>
context?: string
}
export interface PasskeyRegistrationRequest {
rpId: string
@@ -56,6 +61,8 @@ export declare namespace autofill {
supportedAlgorithms: Array<number>
windowXy: Position
excludedCredentials: Array<Array<number>>
clientWindowHandle?: Array<number>
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 {

View File

@@ -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<T: Serialize + DeserializeOwned> {
@@ -694,6 +710,8 @@ pub mod autofill {
pub supported_algorithms: Vec<i32>,
pub window_xy: Position,
pub excluded_credentials: Vec<Vec<u8>>,
pub client_window_handle: Option<Vec<u8>>,
pub context: Option<String>,
}
#[napi(object)]
@@ -715,6 +733,8 @@ pub mod autofill {
pub user_verification: UserVerification,
pub allowed_credentials: Vec<Vec<u8>>,
pub window_xy: Position,
pub client_window_handle: Option<Vec<u8>>,
pub context: Option<String>,
//extension_input: Vec<u8>, TODO: Implement support for extensions
}
@@ -730,6 +750,8 @@ pub mod autofill {
pub client_data_hash: Vec<u8>,
pub user_verification: UserVerification,
pub window_xy: Position,
pub client_window_handle: Option<Vec<u8>>,
pub context: Option<String>,
}
#[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<Self> {
let (send, mut recv) = tokio::sync::mpsc::channel::<Message>(32);
tokio::spawn(async move {
@@ -812,6 +840,24 @@ pub mod autofill {
continue;
};
match serde_json::from_str::<PasskeyMessage<WindowHandleQueryRequest>>(
&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::<PasskeyMessage<PasskeyAssertionRequest>>(
&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<u32> {
let message = PasskeyMessage {
sequence_number,
value: Ok(response),
};
self.send(client_id, serde_json::to_string(&message).unwrap())
}
#[napi]
pub fn complete_error(
&self,

View File

@@ -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 () => {

View File

@@ -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<void> {
@@ -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<void> {

View File

@@ -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({

View File

@@ -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");
}
}

View File

@@ -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,
});
});
},
);
},
};

View File

@@ -55,6 +55,7 @@ export class DesktopAutofillService implements OnDestroy {
private registrationRequest: autofill.PasskeyRegistrationRequest;
private featureFlag?: FeatureFlag;
private isEnabled: boolean = false;
private inFlightRequests: Record<string, AbortController> = {};
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();
}

View File

@@ -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<NativeWindowObject> {
@@ -63,6 +73,7 @@ export class DesktopFido2UserInterfaceService implements Fido2UserInterfaceServi
fallbackSupported: boolean,
nativeWindowObject: NativeWindowObject,
abortController?: AbortController,
transactionContext?: string,
): Promise<DesktopFido2UserInterfaceSession> {
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<boolean>();
@@ -96,6 +111,7 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi
private updatedCipher: CipherView;
private rpId = new BehaviorSubject<string>(null);
private userName = new BehaviorSubject<string>(null);
private availableCipherIdsSubject = new BehaviorSubject<string[]>([""]);
/**
* 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<string> {
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<boolean> {
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<boolean> {
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<NativeAutofillUserVerificationCommand>({
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<void> {
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<never> = 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);
}
}

View File

@@ -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,
}

View File

@@ -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<C extends CommandDefinition> = (
) => Promise<CommandOutput<C["output"]>>;
/** A list of all available commands */
export type Command = NativeAutofillSyncCommand | NativeAutofillStatusCommand;
export type Command = NativeAutofillSyncCommand | NativeAutofillStatusCommand | NativeAutofillUserVerificationCommand;

View File

@@ -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;

View File

@@ -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<{}>;

View File

@@ -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<WindowDetails> => {
const windowDetails = await ipcRenderer.invoke("get-native-window-details")
const handle = Buffer.from(windowDetails.handle, "base64")
return { ...windowDetails, handle }
},
openContextMenu: (
menu: {

View File

@@ -12,13 +12,16 @@ export abstract class Fido2AuthenticatorService<ParentWindowReference> {
* 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<Fido2AuthenticatorMakeCredentialResult>;
/**
@@ -26,13 +29,16 @@ export abstract class Fido2AuthenticatorService<ParentWindowReference> {
* 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<Fido2AuthenticatorGetAssertionResult>;
/**

View File

@@ -65,12 +65,15 @@ export abstract class Fido2UserInterfaceService<ParentWindowReference> {
* 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<Fido2UserInterfaceSession>;
}
@@ -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(

View File

@@ -61,11 +61,13 @@ export class Fido2AuthenticatorService<
params: Fido2AuthenticatorMakeCredentialsParams,
window: ParentWindowReference,
abortController?: AbortController,
transactionContext?: string,
): Promise<Fido2AuthenticatorMakeCredentialResult> {
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<Fido2AuthenticatorGetAssertionResult> {
const userInterfaceSession = await this.userInterface.newSession(
params.fallbackSupported,
window,
abortController,
transactionContext,
);
try {
if (