1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-01 09:13:54 +00:00

First pass at user verification

This commit is contained in:
Isaiah Inuwa
2025-11-10 21:55:28 -06:00
parent b7b381e63e
commit 441f6540fe
17 changed files with 198 additions and 177 deletions

View File

@@ -4,7 +4,7 @@
#[cfg_attr(target_os = "macos", path = "macos.rs")]
mod autofill;
pub use autofill::*;
use serde::{Deserialize, Serialize};
use serde::{de::Visitor, Deserialize, Deserializer, Serialize};
use serde_json::Value;
#[derive(Deserialize)]
@@ -23,6 +23,8 @@ enum RunCommand {
Status,
#[serde(rename = "sync")]
Sync,
#[serde(rename = "user-verification")]
UserVerification,
}
#[derive(Debug, Deserialize)]
@@ -87,6 +89,19 @@ struct SyncResponse {
added: u32,
}
#[derive(Debug, Deserialize)]
struct UserVerificationParameters {
#[serde(rename = "windowHandle", deserialize_with = "deserialize_b64")]
window_handle: Vec<u8>,
#[serde(rename = "transactionContext", deserialize_with = "deserialize_b64")]
pub(crate) transaction_context: Vec<u8>,
#[serde(rename = "displayHint")]
pub(crate) display_hint: String,
pub(crate) username: String,
}
#[derive(Serialize)]
struct UserVerificationResponse {}
#[derive(Serialize)]
#[serde(tag = "type")]
enum CommandResponse {
@@ -126,3 +141,34 @@ impl TryFrom<SyncResponse> for CommandResponse {
})
}
}
impl TryFrom<UserVerificationResponse> for CommandResponse {
type Error = anyhow::Error;
fn try_from(response: UserVerificationResponse) -> Result<Self, anyhow::Error> {
Ok(Self::Success {
value: serde_json::to_value(response)?,
})
}
}
fn deserialize_b64<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<u8>, D::Error> {
deserializer.deserialize_str(Base64Visitor {})
}
struct Base64Visitor;
impl<'de> Visitor<'de> for Base64Visitor {
type Value = Vec<u8>;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("A valid base64 string")
}
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
use base64::{engine::general_purpose::STANDARD, Engine as _};
STANDARD.decode(v).map_err(|err| E::custom(err))
}
}

View File

@@ -5,7 +5,7 @@ use std::ptr::NonNull;
use anyhow::{anyhow, Result};
use base64::engine::{general_purpose::URL_SAFE_NO_PAD, Engine};
use windows::core::s;
use windows::Win32::Foundation::FreeLibrary;
use windows::Win32::Foundation::{FreeLibrary, HWND};
use windows::{
core::{GUID, HRESULT, PCSTR},
Win32::System::{Com::CoTaskMemAlloc, LibraryLoader::*},
@@ -13,15 +13,15 @@ use windows::{
use crate::autofill::{
CommandResponse, RunCommand, RunCommandRequest, StatusResponse, StatusState, StatusSupport,
SyncCredential, SyncParameters, SyncResponse,
SyncCredential, SyncParameters, SyncResponse, UserVerificationParameters,
UserVerificationResponse,
};
const PLUGIN_CLSID: &str = "0f7dc5d9-69ce-4652-8572-6877fd695062";
#[allow(clippy::unused_async)]
pub async fn run_command(value: String) -> Result<String> {
// this.logService.info("Passkey request received:", { error, event });
tracing::debug!("Received command request: {value}");
let request: RunCommandRequest = serde_json::from_str(&value)
.map_err(|e| anyhow!("Failed to deserialize passkey request: {e}"))?;
@@ -35,37 +35,13 @@ pub async fn run_command(value: String) -> Result<String> {
.map_err(|e| anyhow!("Could not parse sync parameters: {e}"))?;
handle_sync_request(params)?.try_into()?
}
RunCommand::UserVerification => {
let params: UserVerificationParameters = serde_json::from_value(request.params)
.map_err(|e| anyhow!("Could not parse user verification parameters: {e}"))?;
handle_user_verification_request(params)?.try_into()?
}
};
serde_json::to_string(&response).map_err(|e| anyhow!("Failed to serialize response: {e}"))
/*
try {
const request = JSON.parse(event.requestJson);
this.logService.info("Parsed passkey request:", { type: event.requestType, request });
// Handle different request types based on the requestType field
switch (event.requestType) {
case "assertion":
return await this.handleAssertionRequest(request);
case "registration":
return await this.handleRegistrationRequest(request);
case "sync":
return await this.handleSyncRequest(request);
default:
this.logService.error("Unknown passkey request type:", event.requestType);
return JSON.stringify({
type: "error",
message: `Unknown request type: ${event.requestType}`,
});
}
} catch (parseError) {
this.logService.error("Failed to parse passkey request:", parseError);
return JSON.stringify({
type: "error",
message: "Failed to parse request JSON",
});
}
*/
}
fn handle_sync_request(params: SyncParameters) -> Result<SyncResponse> {
@@ -78,13 +54,6 @@ fn handle_sync_request(params: SyncParameters) -> Result<SyncResponse> {
sync_credentials_to_windows(credentials, PLUGIN_CLSID)
.map_err(|e| anyhow!("Failed to sync credentials to Windows: {e}"))?;
Ok(SyncResponse { added: num_creds })
/*
let mut log_file = std::fs::File::options()
.append(true)
.open("C:\\temp\\bitwarden_windows_core.log")
.unwrap();
log_file.write_all(b"Made it to sync!");
*/
}
fn handle_status_request() -> Result<StatusResponse> {
@@ -98,136 +67,44 @@ fn handle_status_request() -> Result<StatusResponse> {
})
}
/*
async fn handleAssertionRequest(request: autofill.PasskeyAssertionRequest): Promise<string> {
this.logService.info("Handling assertion request for rpId:", request.rpId);
fn handle_user_verification_request(
request: UserVerificationParameters,
) -> Result<UserVerificationResponse> {
tracing::debug!(?request, "Handling user verification request");
unsafe {
let hwnd: HWND = *request.window_handle.as_ptr().cast();
try {
// Generate unique identifiers for tracking this request
const clientId = Date.now();
const sequenceNumber = Math.floor(Math.random() * 1000000);
let (buf, _) = request.transaction_context[..16].split_at(16);
let guid_u128 = buf
.try_into()
.map_err(|e| anyhow!("Failed to parse transaction ID as u128: {e}"))?;
let transaction_id = GUID::from_u128(u128::from_le_bytes(guid_u128));
// Send request and wait for response
const response = await this.sendAndOptionallyWait<autofill.PasskeyAssertionResponse>(
"autofill.passkeyAssertion",
{
clientId,
sequenceNumber,
request: request,
},
{ waitForResponse: true, timeout: 60000 },
);
if (response) {
// Convert the response to the format expected by the NAPI bridge
return JSON.stringify({
type: "assertion_response",
...response,
});
} else {
return JSON.stringify({
type: "error",
message: "No response received from renderer",
});
}
} catch (error) {
this.logService.error("Error in assertion request:", error);
return JSON.stringify({
type: "error",
message: `Assertion request failed: ${error.message}`,
});
let uv_request = WebAuthNPluginUserVerificationRequest {
hwnd,
rguidTransactionId: (&transaction_id) as *const GUID,
pwszUsername: request.username.to_com_utf16().0,
pwszDisplayHint: request.display_hint.to_com_utf16().0,
};
let uv_fn = delay_load::<WebAuthNPluginPerformUserVerification>(
s!("webauthn.dll"),
s!("WebAuthNPluginPerformUserVerification"),
)
.ok_or(anyhow!(
"Could not load WebAuthNPluginPerformUserVerification"
))?;
let mut uv_response_len: u32 = 0;
let mut uv_response: *mut u8 = std::ptr::null_mut();
uv_fn(
std::ptr::from_ref(&uv_request),
&mut uv_response_len as *mut u32,
&mut uv_response as *mut *mut u8,
)
.ok()
.map_err(|err| anyhow!("User Verification request failed: {err}"))?;
}
}
private async handleRegistrationRequest(
request: autofill.PasskeyRegistrationRequest,
): Promise<string> {
this.logService.info("Handling registration request for rpId:", request.rpId);
try {
// Generate unique identifiers for tracking this request
const clientId = Date.now();
const sequenceNumber = Math.floor(Math.random() * 1000000);
// Send request and wait for response
const response = await this.sendAndOptionallyWait<autofill.PasskeyRegistrationResponse>(
"autofill.passkeyRegistration",
{
clientId,
sequenceNumber,
request: request,
},
{ waitForResponse: true, timeout: 60000 },
);
this.logService.info("Received response for registration request:", response);
if (response) {
// Convert the response to the format expected by the NAPI bridge
return JSON.stringify({
type: "registration_response",
...response,
});
} else {
return JSON.stringify({
type: "error",
message: "No response received from renderer",
});
}
} catch (error) {
this.logService.error("Error in registration request:", error);
return JSON.stringify({
type: "error",
message: `Registration request failed: ${error.message}`,
});
}
}
private async handleSyncRequest(
request: passkey_authenticator.PasskeySyncRequest,
): Promise<string> {
this.logService.info("Handling sync request for rpId:", request.rpId);
try {
// Generate unique identifiers for tracking this request
const clientId = Date.now();
const sequenceNumber = Math.floor(Math.random() * 1000000);
// Send sync request and wait for response
const response = await this.sendAndOptionallyWait<passkey_authenticator.PasskeySyncResponse>(
"autofill.passkeySync",
{
clientId,
sequenceNumber,
request: { rpId: request.rpId },
},
{ waitForResponse: true, timeout: 60000 },
);
this.logService.info("Received response for sync request:", response);
if (response && response.credentials) {
// Convert the response to the format expected by the NAPI bridge
return JSON.stringify({
type: "sync_response",
credentials: response.credentials,
});
} else {
return JSON.stringify({
type: "error",
message: "No credentials received from renderer",
});
}
} catch (error) {
this.logService.error("Error in sync request:", error);
return JSON.stringify({
type: "error",
message: `Sync request failed: ${error.message}`,
});
}
}
*/
return Ok(UserVerificationResponse {});
}
impl TryFrom<SyncCredential> for SyncedCredential {
type Error = anyhow::Error;
@@ -623,3 +500,27 @@ fn add_credentials(
type WebAuthNPluginAuthenticatorRemoveAllCredentialsFnDeclaration =
unsafe extern "cdecl" fn(rclsid: *const GUID) -> HRESULT;
#[repr(C)]
#[derive(Debug)]
struct WebAuthNPluginUserVerificationRequest {
/// Windows handle of the top-level window displayed by the plugin and currently is in foreground as part of the ongoing webauthn operation.
hwnd: HWND,
/// The webauthn transaction id from the WEBAUTHN_PLUGIN_OPERATION_REQUEST
rguidTransactionId: *const GUID,
/// The username attached to the credential that is in use for this webauthn operation
pwszUsername: *const u16,
/// A text hint displayed on the windows hello prompt
pwszDisplayHint: *const u16,
}
type WebAuthNPluginPerformUserVerification = unsafe extern "cdecl" fn(
pPluginUserVerification: *const WebAuthNPluginUserVerificationRequest,
pcbResponse: *mut u32,
ppbResponse: *mut *mut u8,
) -> HRESULT;
type WebAuthNPluginFreeUserVerificationResponse = unsafe extern "cdecl" fn(ppbResponse: *mut u8);

View File

@@ -178,6 +178,7 @@ export declare namespace autofill {
userVerification: UserVerification
allowedCredentials: Array<Array<number>>
windowXy: Position
context?: Array<number>
}
export interface PasskeyAssertionWithoutUserInterfaceRequest {
rpId: string
@@ -188,6 +189,7 @@ export declare namespace autofill {
clientDataHash: Array<number>
userVerification: UserVerification
windowXy: Position
context?: Array<number>
}
export interface NativeStatus {
key: string

View File

@@ -716,6 +716,7 @@ pub mod autofill {
pub user_verification: UserVerification,
pub allowed_credentials: Vec<Vec<u8>>,
pub window_xy: Position,
pub context: Option<Vec<u8>>,
//extension_input: Vec<u8>, TODO: Implement support for extensions
}
@@ -731,6 +732,7 @@ pub mod autofill {
pub client_data_hash: Vec<u8>,
pub user_verification: UserVerification,
pub window_xy: Position,
pub context: Option<Vec<u8>>,
}
#[napi(object)]

View File

@@ -148,6 +148,7 @@ fn send_assertion_request(
client_data_hash: request.client_data_hash,
user_verification: request.user_verification,
window_xy: request.window_xy,
context: request.context,
};
ipc_client.prepare_passkey_assertion_without_user_interface(request, callback.clone());
} else {
@@ -349,15 +350,17 @@ pub unsafe fn plugin_get_assertion(
let allowed_credentials = parse_credential_list(&decoded_request.CredentialList);
// Create Windows assertion request
let transaction_id = req.transaction_id.to_u128().to_le_bytes().to_vec();
let assertion_request = PasskeyAssertionRequest {
rp_id: rpid.clone(),
client_data_hash,
allowed_credentials: allowed_credentials.clone(),
user_verification,
window_xy: Position {
x: coords.0,
y: coords.1,
},
user_verification,
context: transaction_id,
};
tracing::debug!(

View File

@@ -12,6 +12,7 @@ pub struct PasskeyAssertionRequest {
pub user_verification: UserVerification,
pub allowed_credentials: Vec<Vec<u8>>,
pub window_xy: Position,
pub context: Vec<u8>,
// pub extension_input: Vec<u8>, TODO: Implement support for extensions
}
@@ -26,6 +27,7 @@ pub struct PasskeyAssertionWithoutUserInterfaceRequest {
pub client_data_hash: Vec<u8>,
pub user_verification: UserVerification,
pub window_xy: Position,
pub context: Vec<u8>,
}
#[derive(Debug, Serialize, Deserialize)]

View File

@@ -18,6 +18,7 @@ mod assertion;
mod lock_status;
mod registration;
use crate::ipc2::lock_status::{GetLockStatusCallback, LockStatusRequest};
pub use assertion::{
PasskeyAssertionRequest, PasskeyAssertionResponse, PasskeyAssertionWithoutUserInterfaceRequest,
PreparePasskeyAssertionCallback,
@@ -26,8 +27,6 @@ pub use registration::{
PasskeyRegistrationRequest, PasskeyRegistrationResponse, PreparePasskeyRegistrationCallback,
};
use crate::ipc2::lock_status::{GetLockStatusCallback, LockStatusRequest};
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum UserVerification {

View File

@@ -153,8 +153,8 @@ export class Fido2VaultComponent implements OnInit, OnDestroy {
private async validateCipherAccess(cipher: CipherView): Promise<boolean> {
if (cipher.reprompt !== CipherRepromptType.None) {
return this.passwordRepromptService.showPasswordPrompt();
} else {
return this.session.promptForUserVerification(cipher)
}
return true;
}
}

View File

@@ -43,6 +43,7 @@ import {
NativeAutofillPasswordCredential,
NativeAutofillSyncCommand,
} from "../../platform/main/autofill/sync.command";
import { NativeAutofillUserVerificationCommand } from "../../platform/main/autofill/user-verification.command";
import type { NativeWindowObject } from "./desktop-fido2-user-interface.service";
import { DeviceType } from "@bitwarden/common/enums";
@@ -278,11 +279,13 @@ export class DesktopAutofillService implements OnDestroy {
new Uint8Array(parseCredentialId(decrypted.login.fido2Credentials?.[0].credentialId)),
);
}
const ctx = request.context ? new Uint8Array(request.context).buffer : null;
const response = await this.fido2AuthenticatorService.getAssertion(
this.convertAssertionRequest(request, true),
{ windowXy: request.windowXy },
controller,
ctx
);
callback(null, this.convertAssertionResponse(request, response));
@@ -304,13 +307,14 @@ export class DesktopAutofillService implements OnDestroy {
}
this.logService.debug("listenPasskeyAssertion", clientId, sequenceNumber, request);
const ctx = request.context ? new Uint8Array(request.context).buffer : null;
const controller = new AbortController();
try {
const response = await this.fido2AuthenticatorService.getAssertion(
this.convertAssertionRequest(request),
{ windowXy: request.windowXy },
controller,
ctx
);
callback(null, this.convertAssertionResponse(request, response));

View File

@@ -32,6 +32,8 @@ import { LoginView } from "@bitwarden/common/vault/models/view/login.view";
import { SecureNoteView } from "@bitwarden/common/vault/models/view/secure-note.view";
import { DesktopSettingsService } from "../../platform/services/desktop-settings.service";
import { NativeAutofillUserVerificationCommand } from "../../platform/main/autofill/user-verification.command";
import { Utils } from "@bitwarden/common/platform/misc/utils";
/**
* This type is used to pass the window position from the native UI
@@ -65,8 +67,9 @@ export class DesktopFido2UserInterfaceService
fallbackSupported: boolean,
nativeWindowObject: NativeWindowObject,
abortController?: AbortController,
transactionContext?: ArrayBuffer,
): Promise<DesktopFido2UserInterfaceSession> {
this.logService.debug("newSession", fallbackSupported, abortController, nativeWindowObject);
this.logService.debug("newSession", fallbackSupported, abortController, nativeWindowObject, transactionContext);
const session = new DesktopFido2UserInterfaceSession(
this.authService,
this.cipherService,
@@ -75,6 +78,7 @@ export class DesktopFido2UserInterfaceService
this.router,
this.desktopSettingsService,
nativeWindowObject,
transactionContext,
);
this.currentSession = session;
@@ -91,6 +95,7 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi
private router: Router,
private desktopSettingsService: DesktopSettingsService,
private windowObject: NativeWindowObject,
private transactionContext: ArrayBuffer,
) {}
private confirmCredentialSubject = new Subject<boolean>();
@@ -126,7 +131,7 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi
try {
// Check if we can return the credential without user interaction
await this.accountService.setShowHeader(false);
if (assumeUserPresence && cipherIds.length === 1 && !masterPasswordRepromptRequired) {
if (assumeUserPresence && cipherIds.length === 1 && !masterPasswordRepromptRequired && !userVerification) {
this.logService.debug(
"shortcut - Assuming user presence and returning cipherId",
cipherIds[0],
@@ -136,6 +141,10 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi
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);
@@ -312,6 +321,30 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi
}
}
/** Called by the UI to prompt the user for verification. May be fulfilled by the OS. */
async promptForUserVerification(cipher: CipherView): Promise<boolean> {
this.logService.info("DesktopFido2UserInterfaceSession] Prompting for user verification")
let cred = cipher.login.fido2Credentials[0];
const username = cred.userName ?? cred.userDisplayName
let windowHandle = await ipc.platform.getNativeWindowHandle();
const uvResult = await ipc.autofill.runCommand<NativeAutofillUserVerificationCommand>({
namespace: "autofill",
command: "user-verification",
params: {
windowHandle: Utils.fromBufferToB64(windowHandle),
transactionContext: Utils.fromBufferToB64(this.transactionContext),
username,
displayHint: `Logging in as ${cipher.name}`,
},
});
if (uvResult.type === "error") {
this.logService.error("Error getting user verification", uvResult.error)
return false
}
return uvResult.type === "success";
}
async updateCredential(cipher: CipherView): Promise<void> {
this.logService.info("updateCredential");
await firstValueFrom(

View File

@@ -403,6 +403,10 @@ export class WindowMain {
if (this.createWindowCallback) {
this.createWindowCallback(this.win);
}
ipcMain.handle("get-native-window-handle", (_event) => {
return this.win.getNativeWindowHandle().toString("base64");
});
}
// Retrieve the background color

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

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

@@ -137,6 +137,7 @@ export default {
hideWindow: () => ipcRenderer.send("window-hide"),
log: (level: LogLevelType, message?: any, ...optionalParams: any[]) =>
ipcRenderer.invoke("ipc.log", { level, message, optionalParams }),
getNativeWindowHandle: async () => Buffer.from(await ipcRenderer.invoke("get-native-window-handle"), "base64"),
openContextMenu: (
menu: {

View File

@@ -33,6 +33,7 @@ export abstract class Fido2AuthenticatorService<ParentWindowReference> {
params: Fido2AuthenticatorGetAssertionParams,
window: ParentWindowReference,
abortController?: AbortController,
transactionContext?: ArrayBuffer,
): Promise<Fido2AuthenticatorGetAssertionResult>;
/**

View File

@@ -71,6 +71,7 @@ export abstract class Fido2UserInterfaceService<ParentWindowReference> {
fallbackSupported: boolean,
window: ParentWindowReference,
abortController?: AbortController,
transactionContext?: ArrayBuffer,
): Promise<Fido2UserInterfaceSession>;
}

View File

@@ -230,11 +230,13 @@ export class Fido2AuthenticatorService<ParentWindowReference>
params: Fido2AuthenticatorGetAssertionParams,
window: ParentWindowReference,
abortController?: AbortController,
transactionContext?: ArrayBuffer,
): Promise<Fido2AuthenticatorGetAssertionResult> {
const userInterfaceSession = await this.userInterface.newSession(
params.fallbackSupported,
window,
abortController,
transactionContext,
);
try {
if (