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:
17
apps/desktop/desktop_native/napi/index.d.ts
vendored
17
apps/desktop/desktop_native/napi/index.d.ts
vendored
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<{}>;
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user