From 646c0bc478e8fcfeae9dd65d865d30dec751d090 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Garci=CC=81a?= Date: Wed, 28 Aug 2024 19:27:40 +0200 Subject: [PATCH] Add messaging for macos provider --- .../fido2/background/fido2.background.spec.ts | 5 +- .../fido2/background/fido2.background.ts | 5 +- .../browser-fido2-user-interface.service.ts | 6 +- .../browser/src/background/main.background.ts | 15 +- apps/desktop/desktop_native/Cargo.lock | 2 + .../macos_provider/src/assertion.rs | 46 ++++ .../desktop_native/macos_provider/src/lib.rs | 67 +++-- .../macos_provider/src/registration.rs | 12 +- apps/desktop/desktop_native/napi/Cargo.toml | 2 + apps/desktop/desktop_native/napi/index.d.ts | 52 ++++ apps/desktop/desktop_native/napi/src/lib.rs | 244 ++++++++++++++++++ .../CredentialProviderViewController.swift | 158 +++++++++--- .../src/app/services/services.module.ts | 29 ++- apps/desktop/src/autofill/preload.ts | 83 ++++++ .../services/desktop-autofill.service.ts | 166 +++++++++++- apps/desktop/src/main.ts | 2 +- .../main/autofill/native-autofill.main.ts | 56 +++- .../desktop-fido2-user-interface.service.ts | 125 +++++++++ ...fido2-authenticator.service.abstraction.ts | 6 +- .../fido2/fido2-client.service.abstraction.ts | 6 +- ...ido2-user-interface.service.abstraction.ts | 4 +- .../fido2/fido2-authenticator.service.spec.ts | 75 +++--- .../fido2/fido2-authenticator.service.ts | 14 +- .../services/fido2/fido2-autofill-utils.ts | 9 +- .../fido2/fido2-client.service.spec.ts | 96 +++---- .../services/fido2/fido2-client.service.ts | 20 +- .../noop-fido2-user-interface.service.ts | 2 +- 27 files changed, 1117 insertions(+), 190 deletions(-) create mode 100644 apps/desktop/desktop_native/macos_provider/src/assertion.rs create mode 100644 apps/desktop/src/platform/services/desktop-fido2-user-interface.service.ts diff --git a/apps/browser/src/autofill/fido2/background/fido2.background.spec.ts b/apps/browser/src/autofill/fido2/background/fido2.background.spec.ts index 99ed4619954..144af0c0a35 100644 --- a/apps/browser/src/autofill/fido2/background/fido2.background.spec.ts +++ b/apps/browser/src/autofill/fido2/background/fido2.background.spec.ts @@ -25,6 +25,7 @@ import { BrowserScriptInjectorService } from "../../../platform/services/browser import { AbortManager } from "../../../vault/background/abort-manager"; import { Fido2ContentScript, Fido2ContentScriptId } from "../enums/fido2-content-script.enum"; import { Fido2PortName } from "../enums/fido2-port-name.enum"; +import { BrowserFido2ParentWindowReference } from "../services/browser-fido2-user-interface.service"; import { Fido2ExtensionMessage } from "./abstractions/fido2.background"; import { Fido2Background } from "./fido2.background"; @@ -56,7 +57,7 @@ describe("Fido2Background", () => { let senderMock!: MockProxy; let logService!: MockProxy; let fido2ActiveRequestManager: MockProxy; - let fido2ClientService!: MockProxy; + let fido2ClientService!: MockProxy>; let vaultSettingsService!: MockProxy; let scriptInjectorServiceMock!: MockProxy; let configServiceMock!: MockProxy; @@ -73,7 +74,7 @@ describe("Fido2Background", () => { }); senderMock = mock({ id: "1", tab: tabMock }); logService = mock(); - fido2ClientService = mock(); + fido2ClientService = mock>(); vaultSettingsService = mock(); abortManagerMock = mock(); abortController = mock(); diff --git a/apps/browser/src/autofill/fido2/background/fido2.background.ts b/apps/browser/src/autofill/fido2/background/fido2.background.ts index 810cdf74657..04a75fe0b4d 100644 --- a/apps/browser/src/autofill/fido2/background/fido2.background.ts +++ b/apps/browser/src/autofill/fido2/background/fido2.background.ts @@ -21,10 +21,11 @@ import { ScriptInjectorService } from "../../../platform/services/abstractions/s import { AbortManager } from "../../../vault/background/abort-manager"; import { Fido2ContentScript, Fido2ContentScriptId } from "../enums/fido2-content-script.enum"; import { Fido2PortName } from "../enums/fido2-port-name.enum"; +import { BrowserFido2ParentWindowReference } from "../services/browser-fido2-user-interface.service"; import { - Fido2Background as Fido2BackgroundInterface, Fido2BackgroundExtensionMessageHandlers, + Fido2Background as Fido2BackgroundInterface, Fido2ExtensionMessage, SharedFido2ScriptInjectionDetails, SharedFido2ScriptRegistrationOptions, @@ -54,7 +55,7 @@ export class Fido2Background implements Fido2BackgroundInterface { constructor( private logService: LogService, private fido2ActiveRequestManager: Fido2ActiveRequestManager, - private fido2ClientService: Fido2ClientService, + private fido2ClientService: Fido2ClientService, private vaultSettingsService: VaultSettingsService, private scriptInjectorService: ScriptInjectorService, private configService: ConfigService, diff --git a/apps/browser/src/autofill/fido2/services/browser-fido2-user-interface.service.ts b/apps/browser/src/autofill/fido2/services/browser-fido2-user-interface.service.ts index 3ab9edf60f9..568f0ea48f0 100644 --- a/apps/browser/src/autofill/fido2/services/browser-fido2-user-interface.service.ts +++ b/apps/browser/src/autofill/fido2/services/browser-fido2-user-interface.service.ts @@ -109,11 +109,15 @@ export type BrowserFido2Message = { sessionId: string } & ( } ); +export type BrowserFido2ParentWindowReference = chrome.tabs.Tab; + /** * Browser implementation of the {@link Fido2UserInterfaceService}. * The user interface is implemented as a popout and the service uses the browser's messaging API to communicate with it. */ -export class BrowserFido2UserInterfaceService implements Fido2UserInterfaceServiceAbstraction { +export class BrowserFido2UserInterfaceService + implements Fido2UserInterfaceServiceAbstraction +{ constructor(private authService: AuthService) {} async newSession( diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index b66f2457b26..7f614feff2c 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -199,11 +199,11 @@ import { ImportServiceAbstraction, } from "@bitwarden/importer/core"; import { - DefaultKdfConfigService, - KdfConfigService, BiometricStateService, BiometricsService, DefaultBiometricStateService, + DefaultKdfConfigService, + KdfConfigService, KeyService as KeyServiceAbstraction, } from "@bitwarden/key-management"; import { @@ -230,7 +230,10 @@ import { MainContextMenuHandler } from "../autofill/browser/main-context-menu-ha import LegacyOverlayBackground from "../autofill/deprecated/background/overlay.background.deprecated"; import { Fido2Background as Fido2BackgroundAbstraction } from "../autofill/fido2/background/abstractions/fido2.background"; import { Fido2Background } from "../autofill/fido2/background/fido2.background"; -import { BrowserFido2UserInterfaceService } from "../autofill/fido2/services/browser-fido2-user-interface.service"; +import { + BrowserFido2ParentWindowReference, + BrowserFido2UserInterfaceService, +} from "../autofill/fido2/services/browser-fido2-user-interface.service"; import { AutofillService as AutofillServiceAbstraction } from "../autofill/services/abstractions/autofill.service"; import AutofillService from "../autofill/services/autofill.service"; import { InlineMenuFieldQualificationService } from "../autofill/services/inline-menu-field-qualification.service"; @@ -335,10 +338,10 @@ export default class MainBackground { policyApiService: PolicyApiServiceAbstraction; sendApiService: SendApiServiceAbstraction; userVerificationApiService: UserVerificationApiServiceAbstraction; - fido2UserInterfaceService: Fido2UserInterfaceServiceAbstraction; - fido2AuthenticatorService: Fido2AuthenticatorServiceAbstraction; + fido2UserInterfaceService: Fido2UserInterfaceServiceAbstraction; + fido2AuthenticatorService: Fido2AuthenticatorServiceAbstraction; fido2ActiveRequestManager: Fido2ActiveRequestManagerAbstraction; - fido2ClientService: Fido2ClientServiceAbstraction; + fido2ClientService: Fido2ClientServiceAbstraction; avatarService: AvatarServiceAbstraction; mainContextMenuHandler: MainContextMenuHandler; cipherContextMenuHandler: CipherContextMenuHandler; diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock index 0d1d2dbd60b..8c38ad8d28c 100644 --- a/apps/desktop/desktop_native/Cargo.lock +++ b/apps/desktop/desktop_native/Cargo.lock @@ -962,6 +962,8 @@ dependencies = [ "napi", "napi-build", "napi-derive", + "serde", + "serde_json", "tokio", "tokio-stream", "tokio-util", diff --git a/apps/desktop/desktop_native/macos_provider/src/assertion.rs b/apps/desktop/desktop_native/macos_provider/src/assertion.rs new file mode 100644 index 00000000000..762ceaaed48 --- /dev/null +++ b/apps/desktop/desktop_native/macos_provider/src/assertion.rs @@ -0,0 +1,46 @@ +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; + +use crate::{BitwardenError, Callback, UserVerification}; + +#[derive(uniffi::Record, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PasskeyAssertionRequest { + rp_id: String, + credential_id: Vec, + user_name: String, + user_handle: Vec, + record_identifier: Option, + client_data_hash: Vec, + user_verification: UserVerification, +} + +#[derive(uniffi::Record, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PasskeyAssertionResponse { + rp_id: String, + user_handle: Vec, + signature: Vec, + client_data_hash: Vec, + authenticator_data: Vec, + credential_id: Vec, +} + +#[uniffi::export(with_foreign)] +pub trait PreparePasskeyAssertionCallback: Send + Sync { + fn on_complete(&self, credential: PasskeyAssertionResponse); + fn on_error(&self, error: BitwardenError); +} + +impl Callback for Arc { + fn complete(&self, credential: serde_json::Value) -> Result<(), serde_json::Error> { + let credential = serde_json::from_value(credential)?; + PreparePasskeyAssertionCallback::on_complete(self.as_ref(), credential); + Ok(()) + } + + fn error(&self, error: BitwardenError) { + PreparePasskeyAssertionCallback::on_error(self.as_ref(), error); + } +} diff --git a/apps/desktop/desktop_native/macos_provider/src/lib.rs b/apps/desktop/desktop_native/macos_provider/src/lib.rs index 4f09158b808..5623436d874 100644 --- a/apps/desktop/desktop_native/macos_provider/src/lib.rs +++ b/apps/desktop/desktop_native/macos_provider/src/lib.rs @@ -3,25 +3,30 @@ use std::{ collections::HashMap, sync::{atomic::AtomicU32, Arc, Mutex}, + time::Instant, }; use futures::FutureExt; -use log::{error, info, warn}; -use registration::{PasskeyRegistrationRequest, PreparePasskeyRegistrationCallback}; +use log::{error, info}; use serde::{de::DeserializeOwned, Deserialize, Serialize}; uniffi::setup_scaffolding!(); +mod assertion; mod registration; +use assertion::{PasskeyAssertionRequest, PreparePasskeyAssertionCallback}; +use registration::{PasskeyRegistrationRequest, PreparePasskeyRegistrationCallback}; + #[derive(uniffi::Enum, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub enum UserVerification { Preferred, Required, Discouraged, } -#[derive(uniffi::Error, Serialize, Deserialize)] +#[derive(Debug, uniffi::Error, Serialize, Deserialize)] pub enum BitwardenError { Internal(String), } @@ -40,7 +45,8 @@ pub struct MacOSProviderClient { // We need to keep track of the callbacks so we can call them when we receive a response response_callbacks_counter: AtomicU32, - response_callbacks_queue: Arc>>>, + #[allow(clippy::type_complexity)] + response_callbacks_queue: Arc, Instant)>>>, } #[uniffi::export] @@ -63,6 +69,7 @@ impl MacOSProviderClient { let path = desktop_core::ipc::path("autofill"); let queue = client.response_callbacks_queue.clone(); + std::thread::spawn(move || { let rt = tokio::runtime::Builder::new_current_thread() .enable_all() @@ -77,30 +84,39 @@ impl MacOSProviderClient { rt.block_on(async move { while let Some(message) = from_server_recv.recv().await { match serde_json::from_str::(&message) { - Ok(SerializedMessage::Connected) => { + Ok(SerializedMessage::Command(CommandMessage::Connected)) => { info!("Connected to server"); } - Ok(SerializedMessage::Disconnected) => { + Ok(SerializedMessage::Command(CommandMessage::Disconnected)) => { info!("Disconnected from server"); } Ok(SerializedMessage::Message { sequence_number, value, }) => match queue.lock().unwrap().remove(&sequence_number) { - Some(cb) => match value { - Ok(value) => { - if let Err(e) = cb.complete(value) { - error!("Error deserializing message: {}", e); + Some((cb, request_start_time)) => { + info!( + "Time to process request: {:?}", + request_start_time.elapsed() + ); + match value { + Ok(value) => { + if let Err(e) = cb.complete(value) { + error!("Error deserializing message: {e}"); + } + } + Err(e) => { + error!("Error processing message: {e:?}"); + cb.error(e) } } - Err(e) => cb.error(e), - }, + } None => { - error!("No callback found for sequence number: {}", sequence_number) + error!("No callback found for sequence number: {sequence_number}") } }, Err(e) => { - error!("Error deserializing message: {}", e); + error!("Error deserializing message: {e}"); } }; } @@ -115,16 +131,29 @@ impl MacOSProviderClient { request: PasskeyRegistrationRequest, callback: Arc, ) { - warn!("prepare_passkey_registration: {:?}", request); + self.send_message(request, Box::new(callback)); + } + pub fn prepare_passkey_assertion( + &self, + request: PasskeyAssertionRequest, + callback: Arc, + ) { self.send_message(request, Box::new(callback)); } } + #[derive(Serialize, Deserialize)] -#[serde(tag = "command")] -enum SerializedMessage { +#[serde(tag = "command", rename_all = "camelCase")] +enum CommandMessage { Connected, Disconnected, +} + +#[derive(Serialize, Deserialize)] +#[serde(untagged, rename_all = "camelCase")] +enum SerializedMessage { + Command(CommandMessage), Message { sequence_number: u32, value: Result, @@ -140,7 +169,7 @@ impl MacOSProviderClient { self.response_callbacks_queue .lock() .unwrap() - .insert(sequence_number, callback); + .insert(sequence_number, (callback, Instant::now())); sequence_number } @@ -160,7 +189,7 @@ impl MacOSProviderClient { if let Err(e) = self.to_server_send.blocking_send(message) { // Make sure we remove the callback from the queue if we can't send the message - if let Some(cb) = self + if let Some((cb, _)) = self .response_callbacks_queue .lock() .unwrap() diff --git a/apps/desktop/desktop_native/macos_provider/src/registration.rs b/apps/desktop/desktop_native/macos_provider/src/registration.rs index 3ee78fa42bc..d484af58b6c 100644 --- a/apps/desktop/desktop_native/macos_provider/src/registration.rs +++ b/apps/desktop/desktop_native/macos_provider/src/registration.rs @@ -5,18 +5,20 @@ use serde::{Deserialize, Serialize}; use crate::{BitwardenError, Callback, UserVerification}; #[derive(uniffi::Record, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct PasskeyRegistrationRequest { - relying_party_id: String, + rp_id: String, user_name: String, user_handle: Vec, - client_data_hash: Vec, user_verification: UserVerification, + supported_algorithms: Vec, } #[derive(uniffi::Record, Serialize, Deserialize)] -pub struct PasskeyRegistrationCredential { - relying_party: String, +#[serde(rename_all = "camelCase")] +pub struct PasskeyRegistrationResponse { + rp_id: String, client_data_hash: Vec, credential_id: Vec, attestation_object: Vec, @@ -24,7 +26,7 @@ pub struct PasskeyRegistrationCredential { #[uniffi::export(with_foreign)] pub trait PreparePasskeyRegistrationCallback: Send + Sync { - fn on_complete(&self, credential: PasskeyRegistrationCredential); + fn on_complete(&self, credential: PasskeyRegistrationResponse); fn on_error(&self, error: BitwardenError); } diff --git a/apps/desktop/desktop_native/napi/Cargo.toml b/apps/desktop/desktop_native/napi/Cargo.toml index 6efd662b351..882e27cf870 100644 --- a/apps/desktop/desktop_native/napi/Cargo.toml +++ b/apps/desktop/desktop_native/napi/Cargo.toml @@ -20,6 +20,8 @@ anyhow = "=1.0.93" desktop_core = { path = "../core" } napi = { version = "=2.16.13", features = ["async"] } napi-derive = "=2.16.13" +serde = { version = "1.0.209", features = ["derive"] } +serde_json = "1.0.127" tokio = { version = "=1.41.1" } tokio-util = "=0.7.12" tokio-stream = "=0.1.15" diff --git a/apps/desktop/desktop_native/napi/index.d.ts b/apps/desktop/desktop_native/napi/index.d.ts index 0eaba197919..32af0f4e957 100644 --- a/apps/desktop/desktop_native/napi/index.d.ts +++ b/apps/desktop/desktop_native/napi/index.d.ts @@ -124,6 +124,58 @@ export declare namespace ipc { } export declare namespace autofill { export function runCommand(value: string): Promise + export const enum UserVerification { + Preferred = 'preferred', + Required = 'required', + Discouraged = 'discouraged' + } + export interface PasskeyRegistrationRequest { + rpId: string + userName: string + userHandle: Array + clientDataHash: Array + userVerification: UserVerification + supportedAlgorithms: Array + } + export interface PasskeyRegistrationResponse { + rpId: string + clientDataHash: Array + credentialId: Array + attestationObject: Array + } + export interface PasskeyAssertionRequest { + rpId: string + credentialId: Array + userName: string + userHandle: Array + recordIdentifier?: string + clientDataHash: Array + userVerification: UserVerification + } + export interface PasskeyAssertionResponse { + rpId: string + userHandle: Array + signature: Array + clientDataHash: Array + authenticatorData: Array + credentialId: Array + } + export class IpcServer { + /** + * Create and start the IPC server without blocking. + * + * @param name The endpoint name to listen on. This name uniquely identifies the IPC 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): Promise + /** Return the path to the IPC server. */ + getPath(): string + /** Stop the IPC server. */ + stop(): void + completeRegistration(clientId: number, sequenceNumber: number, response: PasskeyRegistrationResponse): number + completeAssertion(clientId: number, sequenceNumber: number, response: PasskeyAssertionResponse): number + completeError(clientId: number, sequenceNumber: number, error: string): number + } } export declare namespace crypto { export function argon2(secret: Buffer, salt: Buffer, iterations: number, memory: number, parallelism: number): Promise diff --git a/apps/desktop/desktop_native/napi/src/lib.rs b/apps/desktop/desktop_native/napi/src/lib.rs index 5037108afd7..77d1455f603 100644 --- a/apps/desktop/desktop_native/napi/src/lib.rs +++ b/apps/desktop/desktop_native/napi/src/lib.rs @@ -547,12 +547,256 @@ pub mod ipc { #[napi] pub mod autofill { + use desktop_core::ipc::server::{Message, MessageType}; + use napi::threadsafe_function::{ + ErrorStrategy, ThreadsafeFunction, ThreadsafeFunctionCallMode, + }; + use serde::{de::DeserializeOwned, Deserialize, Serialize}; + #[napi] pub async fn run_command(value: String) -> napi::Result { desktop_core::autofill::run_command(value) .await .map_err(|e| napi::Error::from_reason(e.to_string())) } + + #[derive(Debug, serde::Serialize, serde:: Deserialize)] + pub enum BitwardenError { + Internal(String), + } + + #[napi(string_enum)] + #[derive(Debug, Serialize, Deserialize)] + #[serde(rename_all = "camelCase")] + pub enum UserVerification { + #[napi(value = "preferred")] + Preferred, + #[napi(value = "required")] + Required, + #[napi(value = "discouraged")] + Discouraged, + } + + #[derive(Serialize, Deserialize)] + #[serde(bound = "T: Serialize + DeserializeOwned")] + pub struct PasskeyMessage { + pub sequence_number: u32, + pub value: Result, + } + + #[napi(object)] + #[derive(Debug, Serialize, Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct PasskeyRegistrationRequest { + pub rp_id: String, + pub user_name: String, + pub user_handle: Vec, + pub client_data_hash: Vec, + pub user_verification: UserVerification, + pub supported_algorithms: Vec, + } + + #[napi(object)] + #[derive(Serialize, Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct PasskeyRegistrationResponse { + pub rp_id: String, + pub client_data_hash: Vec, + pub credential_id: Vec, + pub attestation_object: Vec, + } + + #[napi(object)] + #[derive(Debug, Serialize, Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct PasskeyAssertionRequest { + pub rp_id: String, + pub credential_id: Vec, + pub user_name: String, + pub user_handle: Vec, + pub record_identifier: Option, + pub client_data_hash: Vec, + pub user_verification: UserVerification, + } + + #[napi(object)] + #[derive(Serialize, Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct PasskeyAssertionResponse { + pub rp_id: String, + pub user_handle: Vec, + pub signature: Vec, + pub client_data_hash: Vec, + pub authenticator_data: Vec, + pub credential_id: Vec, + } + + #[napi] + pub struct IpcServer { + server: desktop_core::ipc::server::Server, + } + + #[napi] + impl IpcServer { + /// Create and start the IPC server without blocking. + /// + /// @param name The endpoint name to listen on. This name uniquely identifies the IPC 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. + #[napi(factory)] + pub async fn listen( + name: String, + // Ideally we'd have a single callback that has an enum containing the request values, + // but NAPI doesn't support that just yet + #[napi( + ts_arg_type = "(error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyRegistrationRequest) => void" + )] + registration_callback: ThreadsafeFunction< + (u32, u32, PasskeyRegistrationRequest), + ErrorStrategy::CalleeHandled, + >, + #[napi( + ts_arg_type = "(error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyAssertionRequest) => void" + )] + assertion_callback: ThreadsafeFunction< + (u32, u32, PasskeyAssertionRequest), + ErrorStrategy::CalleeHandled, + >, + ) -> napi::Result { + let (send, mut recv) = tokio::sync::mpsc::channel::(32); + tokio::spawn(async move { + while let Some(Message { + client_id, + kind, + message, + }) = recv.recv().await + { + match kind { + // TODO: We're ignoring the connection and disconnection messages for now + MessageType::Connected | MessageType::Disconnected => continue, + MessageType::Message => { + let Some(message) = message else { + println!("[ERROR] Message is empty"); + continue; + }; + + match serde_json::from_str::>( + &message, + ) { + Ok(msg) => { + let value = msg + .value + .map(|value| (client_id, msg.sequence_number, value)) + .map_err(|e| napi::Error::from_reason(format!("{e:?}"))); + + assertion_callback + .call(value, ThreadsafeFunctionCallMode::NonBlocking); + continue; + } + Err(e) => { + println!("[ERROR] Error deserializing message1: {e}"); + } + } + + match serde_json::from_str::>( + &message, + ) { + Ok(msg) => { + let value = msg + .value + .map(|value| (client_id, msg.sequence_number, value)) + .map_err(|e| napi::Error::from_reason(format!("{e:?}"))); + registration_callback + .call(value, ThreadsafeFunctionCallMode::NonBlocking); + continue; + } + Err(e) => { + println!("[ERROR] Error deserializing message2: {e}"); + } + } + + println!("[ERROR] Received an unknown message2: {message:?}"); + } + } + } + }); + + let path = desktop_core::ipc::path(&name); + + let server = desktop_core::ipc::server::Server::start(&path, send).map_err(|e| { + napi::Error::from_reason(format!( + "Error listening to server - Path: {path:?} - Error: {e} - {e:?}" + )) + })?; + + Ok(IpcServer { server }) + } + + /// Return the path to the IPC server. + #[napi] + pub fn get_path(&self) -> String { + self.server.path.to_string_lossy().to_string() + } + + /// Stop the IPC server. + #[napi] + pub fn stop(&self) -> napi::Result<()> { + self.server.stop(); + Ok(()) + } + + #[napi] + pub fn complete_registration( + &self, + client_id: u32, + sequence_number: u32, + response: PasskeyRegistrationResponse, + ) -> napi::Result { + let message = PasskeyMessage { + sequence_number, + value: Ok(response), + }; + self.send(client_id, serde_json::to_string(&message).unwrap()) + } + + #[napi] + pub fn complete_assertion( + &self, + client_id: u32, + sequence_number: u32, + response: PasskeyAssertionResponse, + ) -> napi::Result { + let message = PasskeyMessage { + sequence_number, + value: Ok(response), + }; + self.send(client_id, serde_json::to_string(&message).unwrap()) + } + + #[napi] + pub fn complete_error( + &self, + client_id: u32, + sequence_number: u32, + error: String, + ) -> napi::Result { + let message: PasskeyMessage<()> = PasskeyMessage { + sequence_number, + value: Err(BitwardenError::Internal(error)), + }; + self.send(client_id, serde_json::to_string(&message).unwrap()) + } + + // TODO: Add a way to send a message to a specific client? + fn send(&self, _client_id: u32, message: String) -> napi::Result { + self.server + .send(message) + .map_err(|e| { + napi::Error::from_reason(format!("Error sending message - Error: {e} - {e:?}")) + }) + // NAPI doesn't support u64 or usize, so we need to convert to u32 + .map(|u| u32::try_from(u).unwrap_or_default()) + } + } } #[napi] diff --git a/apps/desktop/macos/autofill-extension/CredentialProviderViewController.swift b/apps/desktop/macos/autofill-extension/CredentialProviderViewController.swift index de7f40d703f..dbaa8517086 100644 --- a/apps/desktop/macos/autofill-extension/CredentialProviderViewController.swift +++ b/apps/desktop/macos/autofill-extension/CredentialProviderViewController.swift @@ -9,9 +9,45 @@ import AuthenticationServices import os class CredentialProviderViewController: ASCredentialProviderViewController { - let logger = Logger(subsystem: "com.bitwarden.desktop.autofill-extension", category: "credential-provider") + let logger: Logger + + // There is something a bit strange about the initialization/deinitialization in this class. + // Sometimes deinit won't be called after a request has successfully finished, + // which would leave this class hanging in memory and the IPC connection open. + // + // If instead I make this a static, the deinit gets called correctly after each request. + // I think we still might want a static regardless, to be able to reuse the connection if possible. + static let client: MacOsProviderClient = { + let instance = MacOsProviderClient.connect() + // setup code + return instance + }() + + init() { + logger = Logger(subsystem: "com.bitwarden.desktop.autofill-extension", category: "credential-provider") + + logger.log("[autofill-extension] initializing extension") - let client = MacOsProviderClient.connect() + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + logger.log("[autofill-extension] deinitializing extension") + } + + + @IBAction func cancel(_ sender: AnyObject?) { + self.extensionContext.cancelRequest(withError: NSError(domain: ASExtensionErrorDomain, code: ASExtensionError.userCanceled.rawValue)) + } + + @IBAction func passwordSelected(_ sender: AnyObject?) { + let passwordCredential = ASPasswordCredential(user: "j_appleseed", password: "apple1234") + self.extensionContext.completeRequest(withSelectedCredential: passwordCredential, completionHandler: nil) + } /* Implement this method if your extension supports showing credentials in the QuickType bar. @@ -23,7 +59,14 @@ class CredentialProviderViewController: ASCredentialProviderViewController { */ + // Deprecated override func provideCredentialWithoutUserInteraction(for credentialIdentity: ASPasswordCredentialIdentity) { + logger.log("[autofill-extension] provideCredentialWithoutUserInteraction called \(credentialIdentity)") + logger.log("[autofill-extension] user \(credentialIdentity.user)") + logger.log("[autofill-extension] id \(credentialIdentity.recordIdentifier ?? "")") + logger.log("[autofill-extension] sid \(credentialIdentity.serviceIdentifier.identifier)") + logger.log("[autofill-extension] sidt \(credentialIdentity.serviceIdentifier.type.rawValue)") + // let databaseIsUnlocked = true // if (databaseIsUnlocked) { let passwordCredential = ASPasswordCredential(user: credentialIdentity.user, password: "example1234") @@ -32,6 +75,67 @@ class CredentialProviderViewController: ASCredentialProviderViewController { // self.extensionContext.cancelRequest(withError: NSError(domain: ASExtensionErrorDomain, code:ASExtensionError.userInteractionRequired.rawValue)) // } } + + override func provideCredentialWithoutUserInteraction(for credentialRequest: any ASCredentialRequest) { + if let request = credentialRequest as? ASPasskeyCredentialRequest { + if let passkeyIdentity = request.credentialIdentity as? ASPasskeyCredentialIdentity { + + logger.log("[autofill-extension] provideCredentialWithoutUserInteraction2(passkey) called \(request)") + + class CallbackImpl: PreparePasskeyAssertionCallback { + let ctx: ASCredentialProviderExtensionContext + required init(_ ctx: ASCredentialProviderExtensionContext) { + self.ctx = ctx + } + + func onComplete(credential: PasskeyAssertionResponse) { + ctx.completeAssertionRequest(using: ASPasskeyAssertionCredential( + userHandle: credential.userHandle, + relyingParty: credential.rpId, + signature: credential.signature, + clientDataHash: credential.clientDataHash, + authenticatorData: credential.authenticatorData, + credentialID: credential.credentialId + )) + } + + func onError(error: BitwardenError) { + ctx.cancelRequest(withError: error) + } + } + + let userVerification = switch request.userVerificationPreference { + case .preferred: + UserVerification.preferred + case .required: + UserVerification.required + default: + UserVerification.discouraged + } + + let req = PasskeyAssertionRequest( + rpId: passkeyIdentity.relyingPartyIdentifier, + credentialId: passkeyIdentity.credentialID, + userName: passkeyIdentity.userName, + userHandle: passkeyIdentity.userHandle, + recordIdentifier: passkeyIdentity.recordIdentifier, + clientDataHash: request.clientDataHash, + userVerification: userVerification + ) + + CredentialProviderViewController.client.preparePasskeyAssertion(request: req, callback: CallbackImpl(self.extensionContext)) + return + } + } + + if let request = credentialRequest as? ASPasswordCredentialRequest { + logger.log("[autofill-extension] provideCredentialWithoutUserInteraction2(password) called \(request)") + return; + } + + logger.log("[autofill-extension] provideCredentialWithoutUserInteraction2 called wrong") + self.extensionContext.cancelRequest(withError: BitwardenError.Internal("Invalid authentication request")) + } /* Implement this method if provideCredentialWithoutUserInteraction(for:) can fail with @@ -43,41 +147,23 @@ class CredentialProviderViewController: ASCredentialProviderViewController { } */ - @IBAction func cancel(_ sender: AnyObject?) { - self.extensionContext.cancelRequest(withError: NSError(domain: ASExtensionErrorDomain, code: ASExtensionError.userCanceled.rawValue)) - } - - @IBAction func passwordSelected(_ sender: AnyObject?) { - let passwordCredential = ASPasswordCredential(user: "j_appleseed", password: "apple1234") - self.extensionContext.completeRequest(withSelectedCredential: passwordCredential, completionHandler: nil) - } - - /* - Implement this method if provideCredentialWithoutUserInteraction(for:) can fail with - ASExtensionError.userInteractionRequired. In this case, the system may present your extension's - UI and call this method. Show appropriate UI for authenticating the user then provide the password - by completing the extension request with the associated ASPasswordCredential. - - override func prepareInterfaceToProvideCredential(for credentialIdentity: ASPasswordCredentialIdentity) { - } - */ override func prepareInterfaceForExtensionConfiguration() { logger.log("[autofill-extension] prepareInterfaceForExtensionConfiguration called") } override func prepareInterface(forPasskeyRegistration registrationRequest: ASCredentialRequest) { - if let passkeyIdentity = registrationRequest.credentialIdentity as? ASPasskeyCredentialIdentity { - if let passkeyRegistration = registrationRequest as? ASPasskeyCredentialRequest { + if let request = registrationRequest as? ASPasskeyCredentialRequest { + if let passkeyIdentity = registrationRequest.credentialIdentity as? ASPasskeyCredentialIdentity { class CallbackImpl: PreparePasskeyRegistrationCallback { let ctx: ASCredentialProviderExtensionContext required init(_ ctx: ASCredentialProviderExtensionContext) { self.ctx = ctx } - func onComplete(credential: PasskeyRegistrationCredential) { + func onComplete(credential: PasskeyRegistrationResponse) { ctx.completeRegistrationRequest(using: ASPasskeyRegistrationCredential( - relyingParty: credential.relyingParty, + relyingParty: credential.rpId, clientDataHash: credential.clientDataHash, credentialID: credential.credentialId, attestationObject: credential.attestationObject @@ -89,7 +175,7 @@ class CredentialProviderViewController: ASCredentialProviderViewController { } } - let userVerification = switch passkeyRegistration.userVerificationPreference { + let userVerification = switch request.userVerificationPreference { case .preferred: UserVerification.preferred case .required: @@ -99,13 +185,14 @@ class CredentialProviderViewController: ASCredentialProviderViewController { } let req = PasskeyRegistrationRequest( - relyingPartyId: passkeyIdentity.relyingPartyIdentifier, + rpId: passkeyIdentity.relyingPartyIdentifier, userName: passkeyIdentity.userName, userHandle: passkeyIdentity.userHandle, - clientDataHash: passkeyRegistration.clientDataHash, - userVerification: userVerification + clientDataHash: request.clientDataHash, + userVerification: userVerification, + supportedAlgorithms: request.supportedAlgorithms.map{ Int32($0.rawValue) } ) - client.preparePasskeyRegistration(request: req, callback: CallbackImpl(self.extensionContext)) + CredentialProviderViewController.client.preparePasskeyRegistration(request: req, callback: CallbackImpl(self.extensionContext)) return } } @@ -114,10 +201,6 @@ class CredentialProviderViewController: ASCredentialProviderViewController { self.extensionContext.cancelRequest(withError: BitwardenError.Internal("Invalid registration request")) } - override func prepareInterfaceToProvideCredential(for credentialRequest: ASCredentialRequest) { - logger.log("[autofill-extension] prepare interface for credential request \(credentialRequest.description)") - } - /* Prepare your UI to list available credentials for the user to choose from. The items in 'serviceIdentifiers' describe the service the user is logging in to, so your extension can @@ -131,18 +214,13 @@ class CredentialProviderViewController: ASCredentialProviderViewController { } } - override func prepareInterfaceToProvideCredential(for credentialIdentity: ASPasswordCredentialIdentity) { - logger.log("[autofill-extension] prepareInterfaceToProvideCredential for credentialIdentity: \(credentialIdentity.user)") - } - override func prepareCredentialList(for serviceIdentifiers: [ASCredentialServiceIdentifier], requestParameters: ASPasskeyCredentialRequestParameters) { logger.log("[autofill-extension] prepareCredentialList(passkey) for serviceIdentifiers: \(serviceIdentifiers.count)") - + logger.log("request parameters: \(requestParameters.relyingPartyIdentifier)") + for serviceIdentifier in serviceIdentifiers { logger.log(" service: \(serviceIdentifier.identifier)") } - - logger.log("request parameters: \(requestParameters.relyingPartyIdentifier)") } } diff --git a/apps/desktop/src/app/services/services.module.ts b/apps/desktop/src/app/services/services.module.ts index 8ca24c0040c..66ddd6842ac 100644 --- a/apps/desktop/src/app/services/services.module.ts +++ b/apps/desktop/src/app/services/services.module.ts @@ -52,6 +52,8 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co import { CryptoFunctionService as CryptoFunctionServiceAbstraction } from "@bitwarden/common/platform/abstractions/crypto-function.service"; import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { Fido2AuthenticatorService as Fido2AuthenticatorServiceAbstraction } from "@bitwarden/common/platform/abstractions/fido2/fido2-authenticator.service.abstraction"; +import { Fido2UserInterfaceService as Fido2UserInterfaceServiceAbstraction } from "@bitwarden/common/platform/abstractions/fido2/fido2-user-interface.service.abstraction"; import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service"; import { KeyGenerationService as KeyGenerationServiceAbstraction } from "@bitwarden/common/platform/abstractions/key-generation.service"; @@ -69,6 +71,7 @@ import { Message, MessageListener, MessageSender } from "@bitwarden/common/platf // eslint-disable-next-line no-restricted-imports -- Used for dependency injection import { SubjectMessageSender } from "@bitwarden/common/platform/messaging/internal"; import { TaskSchedulerService } from "@bitwarden/common/platform/scheduling"; +import { Fido2AuthenticatorService } from "@bitwarden/common/platform/services/fido2/fido2-authenticator.service"; import { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service"; import { DefaultSdkClientFactory } from "@bitwarden/common/platform/services/sdk/default-sdk-client-factory"; import { NoopSdkClientFactory } from "@bitwarden/common/platform/services/sdk/noop-sdk-client-factory"; @@ -76,6 +79,7 @@ import { SystemService } from "@bitwarden/common/platform/services/system.servic import { GlobalStateProvider, StateProvider } from "@bitwarden/common/platform/state"; // eslint-disable-next-line import/no-restricted-paths -- Implementation for memory storage import { MemoryStorageService as MemoryStorageServiceForStateProviders } from "@bitwarden/common/platform/state/storage/memory-storage.service"; +import { SyncService } from "@bitwarden/common/platform/sync"; import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service"; import { VaultTimeoutStringType } from "@bitwarden/common/types/vault-timeout.type"; import { CipherService as CipherServiceAbstraction } from "@bitwarden/common/vault/abstractions/cipher.service"; @@ -95,6 +99,7 @@ import { DesktopAutofillSettingsService } from "../../autofill/services/desktop- import { DesktopAutofillService } from "../../autofill/services/desktop-autofill.service"; import { ElectronBiometricsService } from "../../key-management/biometrics/electron-biometrics.service"; import { flagEnabled } from "../../platform/flags"; +import { DesktopFido2UserInterfaceService } from "../../platform/services/desktop-fido2-user-interface.service"; import { DesktopSettingsService } from "../../platform/services/desktop-settings.service"; import { ElectronKeyService } from "../../platform/services/electron-key.service"; import { ElectronLogRendererService } from "../../platform/services/electron-log.renderer.service"; @@ -305,7 +310,29 @@ const safeProviders: SafeProvider[] = [ }), safeProvider({ provide: DesktopAutofillService, - deps: [LogService, CipherServiceAbstraction, ConfigService], + deps: [ + LogService, + CipherServiceAbstraction, + ConfigService, + Fido2AuthenticatorServiceAbstraction, + AccountService, + ], + }), + safeProvider({ + provide: Fido2UserInterfaceServiceAbstraction, + useClass: DesktopFido2UserInterfaceService, + deps: [AuthServiceAbstraction, CipherServiceAbstraction, AccountService, LogService], + }), + safeProvider({ + provide: Fido2AuthenticatorServiceAbstraction, + useClass: Fido2AuthenticatorService, + deps: [ + CipherServiceAbstraction, + Fido2UserInterfaceServiceAbstraction, + SyncService, + AccountService, + LogService, + ], }), safeProvider({ provide: NativeMessagingManifestService, diff --git a/apps/desktop/src/autofill/preload.ts b/apps/desktop/src/autofill/preload.ts index 9ce5e1319fd..494544f5858 100644 --- a/apps/desktop/src/autofill/preload.ts +++ b/apps/desktop/src/autofill/preload.ts @@ -1,9 +1,92 @@ import { ipcRenderer } from "electron"; +import type { autofill } from "@bitwarden/desktop-napi"; + import { Command } from "../platform/main/autofill/command"; import { RunCommandParams, RunCommandResult } from "../platform/main/autofill/native-autofill.main"; export default { runCommand: (params: RunCommandParams): Promise> => ipcRenderer.invoke("autofill.runCommand", params), + + listenPasskeyRegistration: ( + fn: ( + clientId: number, + sequenceNumber: number, + request: autofill.PasskeyRegistrationRequest, + completeCallback: ( + error: Error | null, + response: autofill.PasskeyRegistrationResponse, + ) => void, + ) => void, + ) => { + ipcRenderer.on( + "autofill.passkeyRegistration", + ( + event, + data: { + clientId: number; + sequenceNumber: number; + request: autofill.PasskeyRegistrationRequest; + }, + ) => { + 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.completePasskeyRegistration", { + clientId, + sequenceNumber, + response, + }); + }); + }, + ); + }, + + listenPasskeyAssertion: ( + fn: ( + clientId: number, + sequenceNumber: number, + request: autofill.PasskeyAssertionRequest, + completeCallback: (error: Error | null, response: autofill.PasskeyAssertionResponse) => void, + ) => void, + ) => { + ipcRenderer.on( + "autofill.passkeyAssertion", + ( + event, + data: { + clientId: number; + sequenceNumber: number; + request: autofill.PasskeyAssertionRequest; + }, + ) => { + 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.completePasskeyAssertion", { + clientId, + sequenceNumber, + response, + }); + }); + }, + ); + }, }; diff --git a/apps/desktop/src/autofill/services/desktop-autofill.service.ts b/apps/desktop/src/autofill/services/desktop-autofill.service.ts index 8c5dd597850..1ce58596b34 100644 --- a/apps/desktop/src/autofill/services/desktop-autofill.service.ts +++ b/apps/desktop/src/autofill/services/desktop-autofill.service.ts @@ -1,12 +1,32 @@ import { Injectable, OnDestroy } from "@angular/core"; -import { EMPTY, Subject, distinctUntilChanged, mergeMap, switchMap, takeUntil } from "rxjs"; +import { autofill } from "desktop_native/napi"; +import { + EMPTY, + Subject, + distinctUntilChanged, + firstValueFrom, + map, + mergeMap, + switchMap, + takeUntil, +} from "rxjs"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { UriMatchStrategy } from "@bitwarden/common/models/domain/domain-service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { + Fido2AuthenticatorGetAssertionParams, + Fido2AuthenticatorGetAssertionResult, + Fido2AuthenticatorMakeCredentialResult, + Fido2AuthenticatorMakeCredentialsParams, + Fido2AuthenticatorService as Fido2AuthenticatorServiceAbstraction, +} from "@bitwarden/common/platform/abstractions/fido2/fido2-authenticator.service.abstraction"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { getCredentialsForAutofill } from "@bitwarden/common/platform/services/fido2/fido2-autofill-utils"; +import { Fido2Utils } from "@bitwarden/common/platform/services/fido2/fido2-utils"; +import { guidToRawFormat } from "@bitwarden/common/platform/services/fido2/guid-utils"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; @@ -26,6 +46,8 @@ export class DesktopAutofillService implements OnDestroy { private logService: LogService, private cipherService: CipherService, private configService: ConfigService, + private fido2AuthenticatorService: Fido2AuthenticatorServiceAbstraction, + private accountService: AccountService, ) {} async init() { @@ -47,6 +69,8 @@ export class DesktopAutofillService implements OnDestroy { takeUntil(this.destroy$), ) .subscribe(); + + this.listenIpc(); } /** Give metadata about all available credentials in the users vault */ @@ -114,6 +138,146 @@ export class DesktopAutofillService implements OnDestroy { }); } + listenIpc() { + ipc.autofill.listenPasskeyRegistration((clientId, sequenceNumber, request, callback) => { + this.logService.warning("listenPasskeyRegistration", clientId, sequenceNumber, request); + this.logService.warning( + "listenPasskeyRegistration2", + this.convertRegistrationRequest(request), + ); + + const controller = new AbortController(); + void this.fido2AuthenticatorService + .makeCredential(this.convertRegistrationRequest(request), null, controller) + .then((response) => { + callback(null, this.convertRegistrationResponse(request, response)); + }) + .catch((error) => { + this.logService.error("listenPasskeyRegistration error", error); + callback(error, null); + }); + }); + + ipc.autofill.listenPasskeyAssertion(async (clientId, sequenceNumber, request, callback) => { + this.logService.warning("listenPasskeyAssertion", clientId, sequenceNumber, request); + + // TODO: For some reason the credentialId is passed as an empty array in the request, so we need to + // get it from the cipher. For that we use the recordIdentifier, which is the cipherId. + if (request.recordIdentifier && request.credentialId.length === 0) { + const cipher = await this.cipherService.get(request.recordIdentifier); + if (!cipher) { + this.logService.error("listenPasskeyAssertion error", "Cipher not found"); + callback(new Error("Cipher not found"), null); + return; + } + + const activeUserId = await firstValueFrom( + this.accountService.activeAccount$.pipe(map((a) => a?.id)), + ); + + const decrypted = await cipher.decrypt( + await this.cipherService.getKeyForCipherKeyDecryption(cipher, activeUserId), + ); + + const fido2Credential = decrypted.login.fido2Credentials?.[0]; + if (!fido2Credential) { + this.logService.error("listenPasskeyAssertion error", "Fido2Credential not found"); + callback(new Error("Fido2Credential not found"), null); + return; + } + + request.credentialId = Array.from( + guidToRawFormat(decrypted.login.fido2Credentials?.[0].credentialId), + ); + } + + const controller = new AbortController(); + void this.fido2AuthenticatorService + .getAssertion(this.convertAssertionRequest(request), null, controller) + .then((response) => { + callback(null, this.convertAssertionResponse(request, response)); + }) + .catch((error) => { + this.logService.error("listenPasskeyAssertion error", error); + callback(error, null); + }); + }); + } + + private convertRegistrationRequest( + request: autofill.PasskeyRegistrationRequest, + ): Fido2AuthenticatorMakeCredentialsParams { + return { + hash: new Uint8Array(request.clientDataHash), + rpEntity: { + name: request.rpId, + id: request.rpId, + }, + userEntity: { + id: new Uint8Array(request.userHandle), + name: request.userName, + displayName: undefined, + icon: undefined, + }, + credTypesAndPubKeyAlgs: request.supportedAlgorithms.map((alg) => ({ + alg, + type: "public-key", + })), + excludeCredentialDescriptorList: [], + requireResidentKey: true, + requireUserVerification: + request.userVerification === "required" || request.userVerification === "preferred", + fallbackSupported: false, + }; + } + + private convertRegistrationResponse( + request: autofill.PasskeyRegistrationRequest, + response: Fido2AuthenticatorMakeCredentialResult, + ): autofill.PasskeyRegistrationResponse { + return { + rpId: request.rpId, + clientDataHash: request.clientDataHash, + credentialId: Array.from(Fido2Utils.bufferSourceToUint8Array(response.credentialId)), + attestationObject: Array.from( + Fido2Utils.bufferSourceToUint8Array(response.attestationObject), + ), + }; + } + + private convertAssertionRequest( + request: autofill.PasskeyAssertionRequest, + ): Fido2AuthenticatorGetAssertionParams { + return { + rpId: request.rpId, + hash: new Uint8Array(request.clientDataHash), + allowCredentialDescriptorList: [ + { + id: new Uint8Array(request.credentialId), + type: "public-key", + }, + ], + extensions: {}, + requireUserVerification: + request.userVerification === "required" || request.userVerification === "preferred", + fallbackSupported: false, + }; + } + + private convertAssertionResponse( + request: autofill.PasskeyAssertionRequest, + response: Fido2AuthenticatorGetAssertionResult, + ): autofill.PasskeyAssertionResponse { + return { + userHandle: Array.from(response.selectedCredential.userHandle), + rpId: request.rpId, + signature: Array.from(response.signature), + clientDataHash: request.clientDataHash, + authenticatorData: Array.from(response.authenticatorData), + credentialId: Array.from(response.selectedCredential.id), + }; + } + ngOnDestroy(): void { this.destroy$.next(); this.destroy$.complete(); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 5ae72150ff8..772bd0226e1 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -259,7 +259,7 @@ export class Main { new EphemeralValueStorageService(); new SSOLocalhostCallbackService(this.environmentService, this.messagingService); - this.nativeAutofillMain = new NativeAutofillMain(this.logService); + this.nativeAutofillMain = new NativeAutofillMain(this.logService, this.windowMain); void this.nativeAutofillMain.init(); } diff --git a/apps/desktop/src/platform/main/autofill/native-autofill.main.ts b/apps/desktop/src/platform/main/autofill/native-autofill.main.ts index 3a30aa08a08..1465831340f 100644 --- a/apps/desktop/src/platform/main/autofill/native-autofill.main.ts +++ b/apps/desktop/src/platform/main/autofill/native-autofill.main.ts @@ -1,7 +1,9 @@ import { ipcMain } from "electron"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { autofill, ipc } from "@bitwarden/desktop-napi"; +import { autofill } from "@bitwarden/desktop-napi"; + +import { WindowMain } from "../../../main/window.main"; import { CommandDefinition } from "./command"; @@ -14,9 +16,12 @@ export type RunCommandParams = { export type RunCommandResult = C["output"]; export class NativeAutofillMain { - private ipcServer: ipc.IpcServer | null; + private ipcServer: autofill.IpcServer | null; - constructor(private logService: LogService) {} + constructor( + private logService: LogService, + private windowMain: WindowMain, + ) {} async init() { ipcMain.handle( @@ -29,12 +34,51 @@ export class NativeAutofillMain { }, ); - this.ipcServer = await ipc.IpcServer.listen( + this.ipcServer = await autofill.IpcServer.listen( "autofill", - (error: Error | null, data: ipc.IpcMessage) => { - this.logService.warning("autofill.IpcServer.listen", error, data); + // RegistrationCallback + (error, clientId, sequenceNumber, request) => { + if (error) { + this.logService.error("autofill.IpcServer.registration", error); + return; + } + this.windowMain.win.webContents.send("autofill.passkeyRegistration", { + clientId, + sequenceNumber, + request, + }); + }, + // AssertionCallback + (error, clientId, sequenceNumber, request) => { + if (error) { + this.logService.error("autofill.IpcServer.assertion", error); + return; + } + this.windowMain.win.webContents.send("autofill.passkeyAssertion", { + clientId, + sequenceNumber, + request, + }); }, ); + + ipcMain.on("autofill.completePasskeyRegistration", (event, data) => { + this.logService.warning("autofill.completePasskeyRegistration", data); + const { clientId, sequenceNumber, response } = data; + this.ipcServer.completeRegistration(clientId, sequenceNumber, response); + }); + + ipcMain.on("autofill.completePasskeyAssertion", (event, data) => { + this.logService.warning("autofill.completePasskeyAssertion", data); + const { clientId, sequenceNumber, response } = data; + this.ipcServer.completeAssertion(clientId, sequenceNumber, response); + }); + + ipcMain.on("autofill.completeError", (event, data) => { + this.logService.warning("autofill.completeError", data); + const { clientId, sequenceNumber, error } = data; + this.ipcServer.completeAssertion(clientId, sequenceNumber, error); + }); } private async runCommand( diff --git a/apps/desktop/src/platform/services/desktop-fido2-user-interface.service.ts b/apps/desktop/src/platform/services/desktop-fido2-user-interface.service.ts new file mode 100644 index 00000000000..116e8989e02 --- /dev/null +++ b/apps/desktop/src/platform/services/desktop-fido2-user-interface.service.ts @@ -0,0 +1,125 @@ +import { firstValueFrom, map } from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; +import { + Fido2UserInterfaceService as Fido2UserInterfaceServiceAbstraction, + Fido2UserInterfaceSession, + NewCredentialParams, + PickCredentialParams, +} from "@bitwarden/common/platform/abstractions/fido2/fido2-user-interface.service.abstraction"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CipherRepromptType, CipherType, SecureNoteType } from "@bitwarden/common/vault/enums"; +import { CardView } from "@bitwarden/common/vault/models/view/card.view"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { IdentityView } from "@bitwarden/common/vault/models/view/identity.view"; +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"; + +// TODO: This should be moved to the directory of whatever team takes this on + +export class DesktopFido2UserInterfaceService + implements Fido2UserInterfaceServiceAbstraction +{ + constructor( + private authService: AuthService, + private cipherService: CipherService, + private accountService: AccountService, + private logService: LogService, + ) {} + + async newSession( + fallbackSupported: boolean, + _tab: void, + abortController?: AbortController, + ): Promise { + this.logService.warning("newSession", fallbackSupported, abortController); + return new DesktopFido2UserInterfaceSession( + this.authService, + this.cipherService, + this.accountService, + this.logService, + ); + } +} + +export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSession { + constructor( + private authService: AuthService, + private cipherService: CipherService, + private accountService: AccountService, + private logService: LogService, + ) {} + + async pickCredential({ + cipherIds, + userVerification, + }: PickCredentialParams): Promise<{ cipherId: string; userVerified: boolean }> { + this.logService.warning("pickCredential", cipherIds, userVerification); + + return { cipherId: cipherIds[0], userVerified: userVerification }; + } + + async confirmNewCredential({ + credentialName, + userName, + userVerification, + rpId, + }: NewCredentialParams): Promise<{ cipherId: string; userVerified: boolean }> { + this.logService.warning( + "confirmNewCredential", + credentialName, + userName, + userVerification, + rpId, + ); + + // Store the passkey on a new cipher to avoid replacing something important + const cipher = new CipherView(); + cipher.name = credentialName; + + cipher.type = CipherType.Login; + cipher.login = new LoginView(); + cipher.login.username = userName; + cipher.login.uris = [new LoginUriView()]; + cipher.login.uris[0].uri = "https://" + rpId; + cipher.card = new CardView(); + cipher.identity = new IdentityView(); + cipher.secureNote = new SecureNoteView(); + cipher.secureNote.type = SecureNoteType.Generic; + cipher.reprompt = CipherRepromptType.None; + + const activeUserId = await firstValueFrom( + this.accountService.activeAccount$.pipe(map((a) => a?.id)), + ); + + const encCipher = await this.cipherService.encrypt(cipher, activeUserId); + const createdCipher = await this.cipherService.createWithServer(encCipher); + + return { cipherId: createdCipher.id, userVerified: userVerification }; + } + + async informExcludedCredential(existingCipherIds: string[]): Promise { + this.logService.warning("informExcludedCredential", existingCipherIds); + } + + async ensureUnlockedVault(): Promise { + this.logService.warning("ensureUnlockedVault"); + + const status = await firstValueFrom(this.authService.activeAccountStatus$); + if (status !== AuthenticationStatus.Unlocked) { + throw new Error("Vault is not unlocked"); + } + } + + async informCredentialNotFound(): Promise { + this.logService.warning("informCredentialNotFound"); + } + + async close() { + this.logService.warning("close"); + } +} diff --git a/libs/common/src/platform/abstractions/fido2/fido2-authenticator.service.abstraction.ts b/libs/common/src/platform/abstractions/fido2/fido2-authenticator.service.abstraction.ts index d878d70b56e..c717658552b 100644 --- a/libs/common/src/platform/abstractions/fido2/fido2-authenticator.service.abstraction.ts +++ b/libs/common/src/platform/abstractions/fido2/fido2-authenticator.service.abstraction.ts @@ -6,7 +6,7 @@ import { Fido2CredentialView } from "../../../vault/models/view/fido2-credential * * The authenticator provides key management and cryptographic signatures. */ -export abstract class Fido2AuthenticatorService { +export abstract class Fido2AuthenticatorService { /** * Create and save a new credential as described in: * https://www.w3.org/TR/webauthn-3/#sctn-op-make-cred @@ -17,7 +17,7 @@ export abstract class Fido2AuthenticatorService { **/ makeCredential: ( params: Fido2AuthenticatorMakeCredentialsParams, - tab: chrome.tabs.Tab, + window: ParentWindowReference, abortController?: AbortController, ) => Promise; @@ -31,7 +31,7 @@ export abstract class Fido2AuthenticatorService { */ getAssertion: ( params: Fido2AuthenticatorGetAssertionParams, - tab: chrome.tabs.Tab, + window: ParentWindowReference, abortController?: AbortController, ) => Promise; diff --git a/libs/common/src/platform/abstractions/fido2/fido2-client.service.abstraction.ts b/libs/common/src/platform/abstractions/fido2/fido2-client.service.abstraction.ts index 6467af5fc1c..1c4b4259e58 100644 --- a/libs/common/src/platform/abstractions/fido2/fido2-client.service.abstraction.ts +++ b/libs/common/src/platform/abstractions/fido2/fido2-client.service.abstraction.ts @@ -13,7 +13,7 @@ export type UserVerification = "discouraged" | "preferred" | "required"; * It is responsible for both marshalling the inputs for the underlying authenticator operations, * and for returning the results of the latter operations to the Web Authentication API's callers. */ -export abstract class Fido2ClientService { +export abstract class Fido2ClientService { isFido2FeatureEnabled: (hostname: string, origin: string) => Promise; /** @@ -26,7 +26,7 @@ export abstract class Fido2ClientService { */ createCredential: ( params: CreateCredentialParams, - tab: chrome.tabs.Tab, + window: ParentWindowReference, abortController?: AbortController, ) => Promise; @@ -41,7 +41,7 @@ export abstract class Fido2ClientService { */ assertCredential: ( params: AssertCredentialParams, - tab: chrome.tabs.Tab, + window: ParentWindowReference, abortController?: AbortController, ) => Promise; } diff --git a/libs/common/src/platform/abstractions/fido2/fido2-user-interface.service.abstraction.ts b/libs/common/src/platform/abstractions/fido2/fido2-user-interface.service.abstraction.ts index 70fe0b092be..34a93b59b46 100644 --- a/libs/common/src/platform/abstractions/fido2/fido2-user-interface.service.abstraction.ts +++ b/libs/common/src/platform/abstractions/fido2/fido2-user-interface.service.abstraction.ts @@ -59,7 +59,7 @@ export interface PickCredentialParams { * The service is session based and is intended to be used by the FIDO2 authenticator to open a window, * and then use this window to ask the user for input and/or display messages to the user. */ -export abstract class Fido2UserInterfaceService { +export abstract class Fido2UserInterfaceService { /** * Creates a new session. * Note: This will not necessarily open a window until it is needed to request something from the user. @@ -69,7 +69,7 @@ export abstract class Fido2UserInterfaceService { */ newSession: ( fallbackSupported: boolean, - tab: chrome.tabs.Tab, + window: ParentWindowReference, abortController?: AbortController, ) => Promise; } diff --git a/libs/common/src/platform/services/fido2/fido2-authenticator.service.spec.ts b/libs/common/src/platform/services/fido2/fido2-authenticator.service.spec.ts index e3f79ff9d58..226f4c2cfe9 100644 --- a/libs/common/src/platform/services/fido2/fido2-authenticator.service.spec.ts +++ b/libs/common/src/platform/services/fido2/fido2-authenticator.service.spec.ts @@ -30,6 +30,8 @@ import { parseCredentialId } from "./credential-id-utils"; import { AAGUID, Fido2AuthenticatorService } from "./fido2-authenticator.service"; import { Fido2Utils } from "./fido2-utils"; +type ParentWindowReference = string; + const RpId = "bitwarden.com"; describe("FidoAuthenticatorService", () => { @@ -41,16 +43,16 @@ describe("FidoAuthenticatorService", () => { }); let cipherService!: MockProxy; - let userInterface!: MockProxy; + let userInterface!: MockProxy>; let userInterfaceSession!: MockProxy; let syncService!: MockProxy; let accountService!: MockProxy; - let authenticator!: Fido2AuthenticatorService; - let tab!: chrome.tabs.Tab; + let authenticator!: Fido2AuthenticatorService; + let windowReference!: ParentWindowReference; beforeEach(async () => { cipherService = mock(); - userInterface = mock(); + userInterface = mock>(); userInterfaceSession = mock(); userInterface.newSession.mockResolvedValue(userInterfaceSession); syncService = mock({ @@ -63,7 +65,7 @@ describe("FidoAuthenticatorService", () => { syncService, accountService, ); - tab = { id: 123, windowId: 456 } as chrome.tabs.Tab; + windowReference = Utils.newGuid(); accountService.activeAccount$ = activeAccountSubject; }); @@ -78,19 +80,21 @@ describe("FidoAuthenticatorService", () => { // Spec: Check if at least one of the specified combinations of PublicKeyCredentialType and cryptographic parameters in credTypesAndPubKeyAlgs is supported. If not, return an error code equivalent to "NotSupportedError" and terminate the operation. it("should throw error when input does not contain any supported algorithms", async () => { const result = async () => - await authenticator.makeCredential(invalidParams.unsupportedAlgorithm, tab); + await authenticator.makeCredential(invalidParams.unsupportedAlgorithm, windowReference); await expect(result).rejects.toThrowError(Fido2AuthenticatorErrorCode.NotSupported); }); it("should throw error when requireResidentKey has invalid value", async () => { - const result = async () => await authenticator.makeCredential(invalidParams.invalidRk, tab); + const result = async () => + await authenticator.makeCredential(invalidParams.invalidRk, windowReference); await expect(result).rejects.toThrowError(Fido2AuthenticatorErrorCode.Unknown); }); it("should throw error when requireUserVerification has invalid value", async () => { - const result = async () => await authenticator.makeCredential(invalidParams.invalidUv, tab); + const result = async () => + await authenticator.makeCredential(invalidParams.invalidUv, windowReference); await expect(result).rejects.toThrowError(Fido2AuthenticatorErrorCode.Unknown); }); @@ -103,7 +107,7 @@ describe("FidoAuthenticatorService", () => { it.skip("should throw error if requireUserVerification is set to true", async () => { const params = await createParams({ requireUserVerification: true }); - const result = async () => await authenticator.makeCredential(params, tab); + const result = async () => await authenticator.makeCredential(params, windowReference); await expect(result).rejects.toThrowError(Fido2AuthenticatorErrorCode.Constraint); }); @@ -117,7 +121,7 @@ describe("FidoAuthenticatorService", () => { for (const p of Object.values(invalidParams)) { try { - await authenticator.makeCredential(p, tab); + await authenticator.makeCredential(p, windowReference); // eslint-disable-next-line no-empty } catch {} } @@ -158,7 +162,7 @@ describe("FidoAuthenticatorService", () => { userInterfaceSession.informExcludedCredential.mockResolvedValue(); try { - await authenticator.makeCredential(params, tab); + await authenticator.makeCredential(params, windowReference); // eslint-disable-next-line no-empty } catch {} @@ -169,7 +173,7 @@ describe("FidoAuthenticatorService", () => { it("should throw error", async () => { userInterfaceSession.informExcludedCredential.mockResolvedValue(); - const result = async () => await authenticator.makeCredential(params, tab); + const result = async () => await authenticator.makeCredential(params, windowReference); await expect(result).rejects.toThrowError(Fido2AuthenticatorErrorCode.NotAllowed); }); @@ -180,7 +184,7 @@ describe("FidoAuthenticatorService", () => { excludedCipher.organizationId = "someOrganizationId"; try { - await authenticator.makeCredential(params, tab); + await authenticator.makeCredential(params, windowReference); // eslint-disable-next-line no-empty } catch {} @@ -193,7 +197,7 @@ describe("FidoAuthenticatorService", () => { for (const p of Object.values(invalidParams)) { try { - await authenticator.makeCredential(p, tab); + await authenticator.makeCredential(p, windowReference); // eslint-disable-next-line no-empty } catch {} } @@ -230,7 +234,7 @@ describe("FidoAuthenticatorService", () => { userVerified: userVerification, }); - await authenticator.makeCredential(params, tab); + await authenticator.makeCredential(params, windowReference); expect(userInterfaceSession.confirmNewCredential).toHaveBeenCalledWith({ credentialName: params.rpEntity.name, @@ -250,7 +254,7 @@ describe("FidoAuthenticatorService", () => { }); cipherService.encrypt.mockResolvedValue(encryptedCipher as unknown as Cipher); - await authenticator.makeCredential(params, tab); + await authenticator.makeCredential(params, windowReference); const saved = cipherService.encrypt.mock.lastCall?.[0]; expect(saved).toEqual( @@ -288,7 +292,7 @@ describe("FidoAuthenticatorService", () => { }); const params = await createParams(); - const result = async () => await authenticator.makeCredential(params, tab); + const result = async () => await authenticator.makeCredential(params, windowReference); await expect(result).rejects.toThrowError(Fido2AuthenticatorErrorCode.NotAllowed); }); @@ -302,7 +306,7 @@ describe("FidoAuthenticatorService", () => { const encryptedCipher = { ...existingCipher, reprompt: CipherRepromptType.Password }; cipherService.get.mockResolvedValue(encryptedCipher as unknown as Cipher); - const result = async () => await authenticator.makeCredential(params, tab); + const result = async () => await authenticator.makeCredential(params, windowReference); await expect(result).rejects.toThrowError(Fido2AuthenticatorErrorCode.Unknown); }); @@ -317,7 +321,7 @@ describe("FidoAuthenticatorService", () => { cipherService.encrypt.mockResolvedValue(encryptedCipher as unknown as Cipher); cipherService.updateWithServer.mockRejectedValue(new Error("Internal error")); - const result = async () => await authenticator.makeCredential(params, tab); + const result = async () => await authenticator.makeCredential(params, windowReference); await expect(result).rejects.toThrowError(Fido2AuthenticatorErrorCode.Unknown); }); @@ -358,7 +362,7 @@ describe("FidoAuthenticatorService", () => { }); it("should return attestation object", async () => { - const result = await authenticator.makeCredential(params, tab); + const result = await authenticator.makeCredential(params, windowReference); const attestationObject = CBOR.decode( Fido2Utils.bufferSourceToUint8Array(result.attestationObject).buffer, @@ -455,7 +459,8 @@ describe("FidoAuthenticatorService", () => { describe("invalid input parameters", () => { it("should throw error when requireUserVerification has invalid value", async () => { - const result = async () => await authenticator.getAssertion(invalidParams.invalidUv, tab); + const result = async () => + await authenticator.getAssertion(invalidParams.invalidUv, windowReference); await expect(result).rejects.toThrowError(Fido2AuthenticatorErrorCode.Unknown); }); @@ -468,7 +473,7 @@ describe("FidoAuthenticatorService", () => { it.skip("should throw error if requireUserVerification is set to true", async () => { const params = await createParams({ requireUserVerification: true }); - const result = async () => await authenticator.getAssertion(params, tab); + const result = async () => await authenticator.getAssertion(params, windowReference); await expect(result).rejects.toThrowError(Fido2AuthenticatorErrorCode.Constraint); }); @@ -498,7 +503,7 @@ describe("FidoAuthenticatorService", () => { userInterfaceSession.informCredentialNotFound.mockResolvedValue(); try { - await authenticator.getAssertion(params, tab); + await authenticator.getAssertion(params, windowReference); // eslint-disable-next-line no-empty } catch {} @@ -513,7 +518,7 @@ describe("FidoAuthenticatorService", () => { userInterfaceSession.informCredentialNotFound.mockResolvedValue(); try { - await authenticator.getAssertion(params, tab); + await authenticator.getAssertion(params, windowReference); // eslint-disable-next-line no-empty } catch {} @@ -534,7 +539,7 @@ describe("FidoAuthenticatorService", () => { /** Spec: If credentialOptions is now empty, return an error code equivalent to "NotAllowedError" and terminate the operation. */ it("should throw error", async () => { - const result = async () => await authenticator.getAssertion(params, tab); + const result = async () => await authenticator.getAssertion(params, windowReference); await expect(result).rejects.toThrowError(Fido2AuthenticatorErrorCode.NotAllowed); }); @@ -573,7 +578,7 @@ describe("FidoAuthenticatorService", () => { userVerified: false, }); - await authenticator.getAssertion(params, tab); + await authenticator.getAssertion(params, windowReference); expect(userInterfaceSession.pickCredential).toHaveBeenCalledWith({ cipherIds: ciphers.map((c) => c.id), @@ -590,7 +595,7 @@ describe("FidoAuthenticatorService", () => { userVerified: false, }); - await authenticator.getAssertion(params, tab); + await authenticator.getAssertion(params, windowReference); expect(userInterfaceSession.pickCredential).toHaveBeenCalledWith({ cipherIds: [discoverableCiphers[0].id], @@ -608,7 +613,7 @@ describe("FidoAuthenticatorService", () => { userVerified: userVerification, }); - await authenticator.getAssertion(params, tab); + await authenticator.getAssertion(params, windowReference); expect(userInterfaceSession.pickCredential).toHaveBeenCalledWith({ cipherIds: ciphers.map((c) => c.id), @@ -625,7 +630,7 @@ describe("FidoAuthenticatorService", () => { userVerified: false, }); - const result = async () => await authenticator.getAssertion(params, tab); + const result = async () => await authenticator.getAssertion(params, windowReference); await expect(result).rejects.toThrowError(Fido2AuthenticatorErrorCode.NotAllowed); }); @@ -637,7 +642,7 @@ describe("FidoAuthenticatorService", () => { userVerified: false, }); - const result = async () => await authenticator.getAssertion(params, tab); + const result = async () => await authenticator.getAssertion(params, windowReference); await expect(result).rejects.toThrowError(Fido2AuthenticatorErrorCode.NotAllowed); }); @@ -686,7 +691,7 @@ describe("FidoAuthenticatorService", () => { cipherService.encrypt.mockResolvedValue(encrypted as any); ciphers[0].login.fido2Credentials[0].counter = 9000; - await authenticator.getAssertion(params, tab); + await authenticator.getAssertion(params, windowReference); expect(cipherService.updateWithServer).toHaveBeenCalledWith(encrypted); expect(cipherService.encrypt).toHaveBeenCalledWith( @@ -710,13 +715,13 @@ describe("FidoAuthenticatorService", () => { cipherService.encrypt.mockResolvedValue(encrypted as any); ciphers[0].login.fido2Credentials[0].counter = 0; - await authenticator.getAssertion(params, tab); + await authenticator.getAssertion(params, windowReference); expect(cipherService.updateWithServer).not.toHaveBeenCalled(); }); it("should return an assertion result", async () => { - const result = await authenticator.getAssertion(params, tab); + const result = await authenticator.getAssertion(params, windowReference); const encAuthData = result.authenticatorData; const rpIdHash = encAuthData.slice(0, 32); @@ -757,7 +762,7 @@ describe("FidoAuthenticatorService", () => { for (let i = 0; i < 10; ++i) { await init(); // Reset inputs - const result = await authenticator.getAssertion(params, tab); + const result = await authenticator.getAssertion(params, windowReference); const counter = result.authenticatorData.slice(33, 37); expect(counter).toEqual(new Uint8Array([0, 0, 0x23, 0x29])); // double check that the counter doesn't change @@ -774,7 +779,7 @@ describe("FidoAuthenticatorService", () => { it("should throw unkown error if creation fails", async () => { cipherService.updateWithServer.mockRejectedValue(new Error("Internal error")); - const result = async () => await authenticator.getAssertion(params, tab); + const result = async () => await authenticator.getAssertion(params, windowReference); await expect(result).rejects.toThrowError(Fido2AuthenticatorErrorCode.Unknown); }); diff --git a/libs/common/src/platform/services/fido2/fido2-authenticator.service.ts b/libs/common/src/platform/services/fido2/fido2-authenticator.service.ts index e5d3685b9c2..4490388a514 100644 --- a/libs/common/src/platform/services/fido2/fido2-authenticator.service.ts +++ b/libs/common/src/platform/services/fido2/fido2-authenticator.service.ts @@ -41,10 +41,12 @@ const KeyUsages: KeyUsage[] = ["sign"]; * * It is highly recommended that the W3C specification is used a reference when reading this code. */ -export class Fido2AuthenticatorService implements Fido2AuthenticatorServiceAbstraction { +export class Fido2AuthenticatorService + implements Fido2AuthenticatorServiceAbstraction +{ constructor( private cipherService: CipherService, - private userInterface: Fido2UserInterfaceService, + private userInterface: Fido2UserInterfaceService, private syncService: SyncService, private accountService: AccountService, private logService?: LogService, @@ -52,12 +54,12 @@ export class Fido2AuthenticatorService implements Fido2AuthenticatorServiceAbstr async makeCredential( params: Fido2AuthenticatorMakeCredentialsParams, - tab: chrome.tabs.Tab, + window: ParentWindowReference, abortController?: AbortController, ): Promise { const userInterfaceSession = await this.userInterface.newSession( params.fallbackSupported, - tab, + window, abortController, ); @@ -207,12 +209,12 @@ export class Fido2AuthenticatorService implements Fido2AuthenticatorServiceAbstr async getAssertion( params: Fido2AuthenticatorGetAssertionParams, - tab: chrome.tabs.Tab, + window: ParentWindowReference, abortController?: AbortController, ): Promise { const userInterfaceSession = await this.userInterface.newSession( params.fallbackSupported, - tab, + window, abortController, ); try { diff --git a/libs/common/src/platform/services/fido2/fido2-autofill-utils.ts b/libs/common/src/platform/services/fido2/fido2-autofill-utils.ts index 7ef705b95f9..9e7055fece1 100644 --- a/libs/common/src/platform/services/fido2/fido2-autofill-utils.ts +++ b/libs/common/src/platform/services/fido2/fido2-autofill-utils.ts @@ -3,6 +3,9 @@ import { CipherType } from "../../../vault/enums"; import { CipherView } from "../../../vault/models/view/cipher.view"; import { Fido2CredentialAutofillView } from "../../../vault/models/view/fido2-credential-autofill.view"; +import { Utils } from "../../misc/utils"; + +import { guidToRawFormat } from "./guid-utils"; // TODO: Move into Fido2AuthenticatorService export async function getCredentialsForAutofill( @@ -15,9 +18,13 @@ export async function getCredentialsForAutofill( ) .map((cipher) => { const credential = cipher.login.fido2Credentials[0]; + + // TODO: Credentials are sent as a GUID, but we need to convert to a URL-safe base64 string + const credId = Utils.fromBufferToUrlB64(guidToRawFormat(credential.credentialId)); + return { cipherId: cipher.id, - credentialId: credential.credentialId, + credentialId: credId, rpId: credential.rpId, userHandle: credential.userHandle, userName: credential.userName, diff --git a/libs/common/src/platform/services/fido2/fido2-client.service.spec.ts b/libs/common/src/platform/services/fido2/fido2-client.service.spec.ts index 582849ebc12..51c3d8617ab 100644 --- a/libs/common/src/platform/services/fido2/fido2-client.service.spec.ts +++ b/libs/common/src/platform/services/fido2/fido2-client.service.spec.ts @@ -32,12 +32,14 @@ import { Fido2ClientService } from "./fido2-client.service"; import { Fido2Utils } from "./fido2-utils"; import { guidToRawFormat } from "./guid-utils"; +type ParentWindowReference = string; + const RpId = "bitwarden.com"; const Origin = "https://bitwarden.com"; const VaultUrl = "https://vault.bitwarden.com"; describe("FidoAuthenticatorService", () => { - let authenticator!: MockProxy; + let authenticator!: MockProxy>; let configService!: MockProxy; let authService!: MockProxy; let vaultSettingsService: MockProxy; @@ -45,12 +47,12 @@ describe("FidoAuthenticatorService", () => { let taskSchedulerService: MockProxy; let activeRequest!: MockProxy; let requestManager!: MockProxy; - let client!: Fido2ClientService; - let tab!: chrome.tabs.Tab; + let client!: Fido2ClientService; + let windowReference!: ParentWindowReference; let isValidRpId!: jest.SpyInstance; beforeEach(async () => { - authenticator = mock(); + authenticator = mock>(); configService = mock(); authService = mock(); vaultSettingsService = mock(); @@ -82,7 +84,7 @@ describe("FidoAuthenticatorService", () => { vaultSettingsService.enablePasskeys$ = of(true); domainSettingsService.neverDomains$ = of({}); authService.activeAccountStatus$ = of(AuthenticationStatus.Unlocked); - tab = { id: 123, windowId: 456 } as chrome.tabs.Tab; + windowReference = Utils.newGuid(); }); afterEach(() => { @@ -95,7 +97,7 @@ describe("FidoAuthenticatorService", () => { it("should throw error if sameOriginWithAncestors is false", async () => { const params = createParams({ sameOriginWithAncestors: false }); - const result = async () => await client.createCredential(params, tab); + const result = async () => await client.createCredential(params, windowReference); const rejects = expect(result).rejects; await rejects.toMatchObject({ name: "NotAllowedError" }); @@ -106,7 +108,7 @@ describe("FidoAuthenticatorService", () => { it("should throw error if user.id is too small", async () => { const params = createParams({ user: { id: "", displayName: "displayName", name: "name" } }); - const result = async () => await client.createCredential(params, tab); + const result = async () => await client.createCredential(params, windowReference); await expect(result).rejects.toBeInstanceOf(TypeError); }); @@ -121,7 +123,7 @@ describe("FidoAuthenticatorService", () => { }, }); - const result = async () => await client.createCredential(params, tab); + const result = async () => await client.createCredential(params, windowReference); await expect(result).rejects.toBeInstanceOf(TypeError); }); @@ -136,7 +138,7 @@ describe("FidoAuthenticatorService", () => { origin: "invalid-domain-name", }); - const result = async () => await client.createCredential(params, tab); + const result = async () => await client.createCredential(params, windowReference); const rejects = expect(result).rejects; await rejects.toMatchObject({ name: "SecurityError" }); @@ -151,7 +153,7 @@ describe("FidoAuthenticatorService", () => { rp: { id: "bitwarden.com", name: "Bitwarden" }, }); - const result = async () => await client.createCredential(params, tab); + const result = async () => await client.createCredential(params, windowReference); const rejects = expect(result).rejects; await rejects.toMatchObject({ name: "SecurityError" }); @@ -165,7 +167,7 @@ describe("FidoAuthenticatorService", () => { // `params` actually has a valid rp.id, but we're mocking the function to return false isValidRpId.mockReturnValue(false); - const result = async () => await client.createCredential(params, tab); + const result = async () => await client.createCredential(params, windowReference); const rejects = expect(result).rejects; await rejects.toMatchObject({ name: "SecurityError" }); @@ -179,7 +181,7 @@ describe("FidoAuthenticatorService", () => { }); domainSettingsService.neverDomains$ = of({ "bitwarden.com": null }); - const result = async () => await client.createCredential(params, tab); + const result = async () => await client.createCredential(params, windowReference); await expect(result).rejects.toThrow(FallbackRequestedError); }); @@ -190,7 +192,7 @@ describe("FidoAuthenticatorService", () => { rp: { id: "bitwarden.com", name: "Bitwarden" }, }); - const result = async () => await client.createCredential(params, tab); + const result = async () => await client.createCredential(params, windowReference); const rejects = expect(result).rejects; await rejects.toMatchObject({ name: "SecurityError" }); @@ -204,7 +206,7 @@ describe("FidoAuthenticatorService", () => { }); authenticator.makeCredential.mockResolvedValue(createAuthenticatorMakeResult()); - await client.createCredential(params, tab); + await client.createCredential(params, windowReference); }); // Spec: If credTypesAndPubKeyAlgs is empty, return a DOMException whose name is "NotSupportedError", and terminate this algorithm. @@ -216,7 +218,7 @@ describe("FidoAuthenticatorService", () => { ], }); - const result = async () => await client.createCredential(params, tab); + const result = async () => await client.createCredential(params, windowReference); const rejects = expect(result).rejects; await rejects.toMatchObject({ name: "NotSupportedError" }); @@ -231,7 +233,8 @@ describe("FidoAuthenticatorService", () => { const abortController = new AbortController(); abortController.abort(); - const result = async () => await client.createCredential(params, tab, abortController); + const result = async () => + await client.createCredential(params, windowReference, abortController); const rejects = expect(result).rejects; await rejects.toMatchObject({ name: "AbortError" }); @@ -246,7 +249,7 @@ describe("FidoAuthenticatorService", () => { }); authenticator.makeCredential.mockResolvedValue(createAuthenticatorMakeResult()); - await client.createCredential(params, tab); + await client.createCredential(params, windowReference); expect(authenticator.makeCredential).toHaveBeenCalledWith( expect.objectContaining({ @@ -259,7 +262,7 @@ describe("FidoAuthenticatorService", () => { displayName: params.user.displayName, }), }), - tab, + windowReference, expect.anything(), ); }); @@ -271,7 +274,7 @@ describe("FidoAuthenticatorService", () => { }); authenticator.makeCredential.mockResolvedValue(createAuthenticatorMakeResult()); - const result = await client.createCredential(params, tab); + const result = await client.createCredential(params, windowReference); expect(result.extensions.credProps?.rk).toBe(true); }); @@ -283,7 +286,7 @@ describe("FidoAuthenticatorService", () => { }); authenticator.makeCredential.mockResolvedValue(createAuthenticatorMakeResult()); - const result = await client.createCredential(params, tab); + const result = await client.createCredential(params, windowReference); expect(result.extensions.credProps?.rk).toBe(false); }); @@ -295,7 +298,7 @@ describe("FidoAuthenticatorService", () => { }); authenticator.makeCredential.mockResolvedValue(createAuthenticatorMakeResult()); - const result = await client.createCredential(params, tab); + const result = await client.createCredential(params, windowReference); expect(result.extensions.credProps).toBeUndefined(); }); @@ -307,7 +310,7 @@ describe("FidoAuthenticatorService", () => { new Fido2AuthenticatorError(Fido2AuthenticatorErrorCode.InvalidState), ); - const result = async () => await client.createCredential(params, tab); + const result = async () => await client.createCredential(params, windowReference); const rejects = expect(result).rejects; await rejects.toMatchObject({ name: "InvalidStateError" }); @@ -319,7 +322,7 @@ describe("FidoAuthenticatorService", () => { const params = createParams(); authenticator.makeCredential.mockRejectedValue(new Error("unknown error")); - const result = async () => await client.createCredential(params, tab); + const result = async () => await client.createCredential(params, windowReference); const rejects = expect(result).rejects; await rejects.toMatchObject({ name: "NotAllowedError" }); @@ -330,7 +333,7 @@ describe("FidoAuthenticatorService", () => { const params = createParams(); vaultSettingsService.enablePasskeys$ = of(false); - const result = async () => await client.createCredential(params, tab); + const result = async () => await client.createCredential(params, windowReference); const rejects = expect(result).rejects; await rejects.toThrow(FallbackRequestedError); @@ -340,7 +343,7 @@ describe("FidoAuthenticatorService", () => { const params = createParams(); authService.getAuthStatus.mockResolvedValue(AuthenticationStatus.LoggedOut); - const result = async () => await client.createCredential(params, tab); + const result = async () => await client.createCredential(params, windowReference); const rejects = expect(result).rejects; await rejects.toThrow(FallbackRequestedError); @@ -349,7 +352,7 @@ describe("FidoAuthenticatorService", () => { it("should throw FallbackRequestedError if origin equals the bitwarden vault", async () => { const params = createParams({ origin: VaultUrl }); - const result = async () => await client.createCredential(params, tab); + const result = async () => await client.createCredential(params, windowReference); const rejects = expect(result).rejects; await rejects.toThrow(FallbackRequestedError); @@ -408,7 +411,7 @@ describe("FidoAuthenticatorService", () => { origin: "invalid-domain-name", }); - const result = async () => await client.assertCredential(params, tab); + const result = async () => await client.assertCredential(params, windowReference); const rejects = expect(result).rejects; await rejects.toMatchObject({ name: "SecurityError" }); @@ -423,7 +426,7 @@ describe("FidoAuthenticatorService", () => { rpId: "bitwarden.com", }); - const result = async () => await client.assertCredential(params, tab); + const result = async () => await client.assertCredential(params, windowReference); const rejects = expect(result).rejects; await rejects.toMatchObject({ name: "SecurityError" }); @@ -437,7 +440,7 @@ describe("FidoAuthenticatorService", () => { // `params` actually has a valid rp.id, but we're mocking the function to return false isValidRpId.mockReturnValue(false); - const result = async () => await client.assertCredential(params, tab); + const result = async () => await client.assertCredential(params, windowReference); const rejects = expect(result).rejects; await rejects.toMatchObject({ name: "SecurityError" }); @@ -451,7 +454,7 @@ describe("FidoAuthenticatorService", () => { domainSettingsService.neverDomains$ = of({ "bitwarden.com": null }); - const result = async () => await client.assertCredential(params, tab); + const result = async () => await client.assertCredential(params, windowReference); await expect(result).rejects.toThrow(FallbackRequestedError); }); @@ -462,7 +465,7 @@ describe("FidoAuthenticatorService", () => { rpId: "bitwarden.com", }); - const result = async () => await client.assertCredential(params, tab); + const result = async () => await client.assertCredential(params, windowReference); const rejects = expect(result).rejects; await rejects.toMatchObject({ name: "SecurityError" }); @@ -477,7 +480,8 @@ describe("FidoAuthenticatorService", () => { const abortController = new AbortController(); abortController.abort(); - const result = async () => await client.assertCredential(params, tab, abortController); + const result = async () => + await client.assertCredential(params, windowReference, abortController); const rejects = expect(result).rejects; await rejects.toMatchObject({ name: "AbortError" }); @@ -493,7 +497,7 @@ describe("FidoAuthenticatorService", () => { new Fido2AuthenticatorError(Fido2AuthenticatorErrorCode.InvalidState), ); - const result = async () => await client.assertCredential(params, tab); + const result = async () => await client.assertCredential(params, windowReference); const rejects = expect(result).rejects; await rejects.toMatchObject({ name: "InvalidStateError" }); @@ -505,7 +509,7 @@ describe("FidoAuthenticatorService", () => { const params = createParams(); authenticator.getAssertion.mockRejectedValue(new Error("unknown error")); - const result = async () => await client.assertCredential(params, tab); + const result = async () => await client.assertCredential(params, windowReference); const rejects = expect(result).rejects; await rejects.toMatchObject({ name: "NotAllowedError" }); @@ -516,7 +520,7 @@ describe("FidoAuthenticatorService", () => { const params = createParams(); vaultSettingsService.enablePasskeys$ = of(false); - const result = async () => await client.assertCredential(params, tab); + const result = async () => await client.assertCredential(params, windowReference); const rejects = expect(result).rejects; await rejects.toThrow(FallbackRequestedError); @@ -526,7 +530,7 @@ describe("FidoAuthenticatorService", () => { const params = createParams(); authService.getAuthStatus.mockResolvedValue(AuthenticationStatus.LoggedOut); - const result = async () => await client.assertCredential(params, tab); + const result = async () => await client.assertCredential(params, windowReference); const rejects = expect(result).rejects; await rejects.toThrow(FallbackRequestedError); @@ -535,7 +539,7 @@ describe("FidoAuthenticatorService", () => { it("should throw FallbackRequestedError if origin equals the bitwarden vault", async () => { const params = createParams({ origin: VaultUrl }); - const result = async () => await client.assertCredential(params, tab); + const result = async () => await client.assertCredential(params, windowReference); const rejects = expect(result).rejects; await rejects.toThrow(FallbackRequestedError); @@ -555,7 +559,7 @@ describe("FidoAuthenticatorService", () => { }); authenticator.getAssertion.mockResolvedValue(createAuthenticatorAssertResult()); - await client.assertCredential(params, tab); + await client.assertCredential(params, windowReference); expect(authenticator.getAssertion).toHaveBeenCalledWith( expect.objectContaining({ @@ -573,7 +577,7 @@ describe("FidoAuthenticatorService", () => { }), ], }), - tab, + windowReference, expect.anything(), ); }); @@ -585,7 +589,7 @@ describe("FidoAuthenticatorService", () => { params.rpId = undefined; authenticator.getAssertion.mockResolvedValue(createAuthenticatorAssertResult()); - await client.assertCredential(params, tab); + await client.assertCredential(params, windowReference); }); }); @@ -597,7 +601,7 @@ describe("FidoAuthenticatorService", () => { }); authenticator.getAssertion.mockResolvedValue(createAuthenticatorAssertResult()); - await client.assertCredential(params, tab); + await client.assertCredential(params, windowReference); expect(authenticator.getAssertion).toHaveBeenCalledWith( expect.objectContaining({ @@ -605,7 +609,7 @@ describe("FidoAuthenticatorService", () => { rpId: RpId, allowCredentialDescriptorList: [], }), - tab, + windowReference, expect.anything(), ); }); @@ -627,7 +631,7 @@ describe("FidoAuthenticatorService", () => { }); it("creates an active mediated conditional request", async () => { - await client.assertCredential(params, tab); + await client.assertCredential(params, windowReference); expect(requestManager.newActiveRequest).toHaveBeenCalled(); expect(authenticator.getAssertion).toHaveBeenCalledWith( @@ -635,14 +639,14 @@ describe("FidoAuthenticatorService", () => { assumeUserPresence: true, rpId: RpId, }), - tab, + windowReference, ); }); it("restarts the mediated conditional request if a user aborts the request", async () => { authenticator.getAssertion.mockRejectedValueOnce(new Error()); - await client.assertCredential(params, tab); + await client.assertCredential(params, windowReference); expect(authenticator.getAssertion).toHaveBeenCalledTimes(2); }); @@ -652,7 +656,7 @@ describe("FidoAuthenticatorService", () => { abortController.abort(); authenticator.getAssertion.mockRejectedValueOnce(new DOMException("AbortError")); - await client.assertCredential(params, tab); + await client.assertCredential(params, windowReference); expect(authenticator.getAssertion).toHaveBeenCalledTimes(2); }); diff --git a/libs/common/src/platform/services/fido2/fido2-client.service.ts b/libs/common/src/platform/services/fido2/fido2-client.service.ts index 849e0c72565..6248abbcdea 100644 --- a/libs/common/src/platform/services/fido2/fido2-client.service.ts +++ b/libs/common/src/platform/services/fido2/fido2-client.service.ts @@ -45,7 +45,9 @@ import { guidToRawFormat } from "./guid-utils"; * * It is highly recommended that the W3C specification is used a reference when reading this code. */ -export class Fido2ClientService implements Fido2ClientServiceAbstraction { +export class Fido2ClientService + implements Fido2ClientServiceAbstraction +{ private timeoutAbortController: AbortController; private readonly TIMEOUTS = { NO_VERIFICATION: { @@ -61,7 +63,7 @@ export class Fido2ClientService implements Fido2ClientServiceAbstraction { }; constructor( - private authenticator: Fido2AuthenticatorService, + private authenticator: Fido2AuthenticatorService, private configService: ConfigService, private authService: AuthService, private vaultSettingsService: VaultSettingsService, @@ -100,7 +102,7 @@ export class Fido2ClientService implements Fido2ClientServiceAbstraction { async createCredential( params: CreateCredentialParams, - tab: chrome.tabs.Tab, + window: ParentWindowReference, abortController = new AbortController(), ): Promise { const parsedOrigin = parse(params.origin, { allowPrivateDomains: true }); @@ -199,7 +201,7 @@ export class Fido2ClientService implements Fido2ClientServiceAbstraction { try { makeCredentialResult = await this.authenticator.makeCredential( makeCredentialParams, - tab, + window, abortController, ); } catch (error) { @@ -254,7 +256,7 @@ export class Fido2ClientService implements Fido2ClientServiceAbstraction { async assertCredential( params: AssertCredentialParams, - tab: chrome.tabs.Tab, + window: ParentWindowReference, abortController = new AbortController(), ): Promise { const parsedOrigin = parse(params.origin, { allowPrivateDomains: true }); @@ -298,7 +300,7 @@ export class Fido2ClientService implements Fido2ClientServiceAbstraction { if (params.mediation === "conditional") { return this.handleMediatedConditionalRequest( params, - tab, + window, abortController, clientDataJSONBytes, ); @@ -322,7 +324,7 @@ export class Fido2ClientService implements Fido2ClientServiceAbstraction { try { getAssertionResult = await this.authenticator.getAssertion( getAssertionParams, - tab, + window, abortController, ); } catch (error) { @@ -361,7 +363,7 @@ export class Fido2ClientService implements Fido2ClientServiceAbstraction { private async handleMediatedConditionalRequest( params: AssertCredentialParams, - tab: chrome.tabs.Tab, + tab: ParentWindowReference, abortController: AbortController, clientDataJSONBytes: Uint8Array, ): Promise { @@ -377,7 +379,7 @@ export class Fido2ClientService implements Fido2ClientServiceAbstraction { `[Fido2Client] started mediated request, available credentials: ${availableCredentials.length}`, ); const requestResult = await this.requestManager.newActiveRequest( - tab.id, + (tab as any).id, availableCredentials, abortController, ); diff --git a/libs/common/src/platform/services/fido2/noop-fido2-user-interface.service.ts b/libs/common/src/platform/services/fido2/noop-fido2-user-interface.service.ts index 440bd519002..14b4da0ef1b 100644 --- a/libs/common/src/platform/services/fido2/noop-fido2-user-interface.service.ts +++ b/libs/common/src/platform/services/fido2/noop-fido2-user-interface.service.ts @@ -7,7 +7,7 @@ import { * Noop implementation of the {@link Fido2UserInterfaceService}. * This implementation does not provide any user interface. */ -export class Fido2UserInterfaceService implements Fido2UserInterfaceServiceAbstraction { +export class Fido2UserInterfaceService implements Fido2UserInterfaceServiceAbstraction { newSession(): Promise { throw new Error("Not implemented exception"); }