From 733f031d93b678f0e24827dbda9aa271b89afb26 Mon Sep 17 00:00:00 2001 From: Vincent Michel Date: Wed, 12 Feb 2025 16:45:46 +0100 Subject: [PATCH 1/3] Add change-authentication CLI command --- .../commands/device/change_authentication.rs | 57 +++++++++++++++++++ cli/src/commands/device/mod.rs | 4 ++ cli/src/utils.rs | 12 +++- 3 files changed, 71 insertions(+), 2 deletions(-) create mode 100644 cli/src/commands/device/change_authentication.rs diff --git a/cli/src/commands/device/change_authentication.rs b/cli/src/commands/device/change_authentication.rs new file mode 100644 index 00000000000..eabe253b344 --- /dev/null +++ b/cli/src/commands/device/change_authentication.rs @@ -0,0 +1,57 @@ +// Parsec Cloud (https://parsec.cloud) Copyright (c) BUSL-1.1 2016-present Scille SAS + +use libparsec::{client_change_authentication, ClientConfig, DeviceSaveStrategy}; + +use crate::utils::*; + +#[derive(Debug, Clone, Copy, clap::ValueEnum)] +enum AuthenticationMethod { + Keyring, + Password, +} + +crate::clap_parser_with_shared_opts_builder!( + #[with = config_dir, device, password_stdin] + pub struct Args { + /// Use keyring to store the password for the device. + #[arg(long)] + method: AuthenticationMethod, + } +); + +pub async fn main(args: Args) -> anyhow::Result<()> { + let Args { + config_dir, + password_stdin, + method, + device, + .. + } = args; + + // Get current device access strategy + let current_auth = get_device_access_strategy(&config_dir, device, password_stdin).await?; + + // Get new device access strategy + let new_auth = match method { + AuthenticationMethod::Keyring => DeviceSaveStrategy::Keyring, + AuthenticationMethod::Password => { + let password = choose_password(ReadPasswordFrom::Tty { + prompt: "Enter the new password for the device:", + })?; + DeviceSaveStrategy::Password { password } + } + }; + + // Update device access strategy + let mut handle = start_spinner("Updating authentication".into()); + + let config = ClientConfig { + config_dir, + ..Default::default() + }; + client_change_authentication(config, current_auth, new_auth).await?; + + handle.stop_with_message(format!("Authentication updated to {:?}", method,)); + + Ok(()) +} diff --git a/cli/src/commands/device/mod.rs b/cli/src/commands/device/mod.rs index dde8895d554..f960faba104 100644 --- a/cli/src/commands/device/mod.rs +++ b/cli/src/commands/device/mod.rs @@ -1,3 +1,4 @@ +pub mod change_authentication; pub mod export_recovery_device; pub mod import_recovery_device; pub mod list; @@ -13,6 +14,8 @@ pub enum Group { ExportRecoveryDevice(export_recovery_device::Args), /// Import recovery device ImportRecoveryDevice(import_recovery_device::Args), + /// Change authentication + ChangeAuthentication(change_authentication::Args), } pub async fn dispatch_command(command: Group) -> anyhow::Result<()> { @@ -21,5 +24,6 @@ pub async fn dispatch_command(command: Group) -> anyhow::Result<()> { Group::List(args) => list::main(args).await, Group::ExportRecoveryDevice(args) => export_recovery_device::main(args).await, Group::ImportRecoveryDevice(args) => import_recovery_device::main(args).await, + Group::ChangeAuthentication(args) => change_authentication::main(args).await, } } diff --git a/cli/src/utils.rs b/cli/src/utils.rs index df087a4c369..9736a08c1b7 100644 --- a/cli/src/utils.rs +++ b/cli/src/utils.rs @@ -204,11 +204,11 @@ impl From for LoadAndUnlockDeviceError { } } -pub async fn load_and_unlock_device( +pub async fn get_device_access_strategy( config_dir: &Path, device: Option, password_stdin: bool, -) -> Result, LoadAndUnlockDeviceError> { +) -> Result { log::trace!( "Loading device {device} from {dir}", dir = config_dir.display(), @@ -245,7 +245,15 @@ pub async fn load_and_unlock_device( )); } }; + Ok(access_strategy) +} +pub async fn load_and_unlock_device( + config_dir: &Path, + device: Option, + password_stdin: bool, +) -> Result, LoadAndUnlockDeviceError> { + let access_strategy = get_device_access_strategy(config_dir, device, password_stdin).await?; let device = libparsec::load_device(config_dir, &access_strategy).await?; Ok(device) From a3b2d3ef86412ed134bbd16ebaad5cdd9712a8a7 Mon Sep 17 00:00:00 2001 From: Vincent Michel Date: Mon, 10 Feb 2025 21:06:07 +0100 Subject: [PATCH 2/3] [PoC] Add biometrics authentication method --- Cargo.lock | 41 +++++- bindings/electron/src/index.d.ts | 12 ++ bindings/electron/src/meths.rs | 49 +++++++ bindings/generator/api/client.py | 7 + bindings/generator/api/device.py | 4 + bindings/web/src/meths.rs | 58 ++++++++ .../commands/device/change_authentication.rs | 2 + cli/src/utils.rs | 3 + client/src/parsec/login.ts | 7 + client/src/plugins/libparsec/definitions.ts | 14 ++ client/src/views/home/HomePage.vue | 2 + .../crates/platform_device_loader/Cargo.toml | 11 ++ .../crates/platform_device_loader/src/lib.rs | 8 ++ .../src/native/biometrics.rs | 126 ++++++++++++++++++ .../platform_device_loader/src/native/mod.rs | 98 ++++++++++++++ .../platform_device_loader/src/testbed.rs | 1 + .../local_device/device_file_biometrics.json5 | 51 +++++++ .../crates/types/src/local_device_file.rs | 41 ++++++ libparsec/src/lib.rs | 3 +- 19 files changed, 532 insertions(+), 6 deletions(-) create mode 100644 libparsec/crates/platform_device_loader/src/native/biometrics.rs create mode 100644 libparsec/crates/types/schema/local_device/device_file_biometrics.json5 diff --git a/Cargo.lock b/Cargo.lock index bf9ff334349..ffe706ea3d9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1512,7 +1512,7 @@ checksum = "f9c7c7c8ac16c798734b8a24560c1362120597c40d5e1459f09498f8f6c8f2ba" dependencies = [ "cfg-if 1.0.0", "libc", - "windows", + "windows 0.52.0", ] [[package]] @@ -1632,7 +1632,7 @@ dependencies = [ "iana-time-zone-haiku", "js-sys", "wasm-bindgen", - "windows-core", + "windows-core 0.52.0", ] [[package]] @@ -2164,9 +2164,11 @@ dependencies = [ "libparsec_types", "log", "serde_json", + "sha2", "tokio", "uuid", "web-sys", + "windows 0.54.0", "zeroize", ] @@ -5023,7 +5025,17 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" dependencies = [ - "windows-core", + "windows-core 0.52.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49" +dependencies = [ + "windows-core 0.54.0", "windows-targets 0.52.6", ] @@ -5036,17 +5048,36 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-core" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65" +dependencies = [ + "windows-result 0.1.2", + "windows-targets 0.52.6", +] + [[package]] name = "windows-registry" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e400001bb720a623c1c69032f8e3e4cf09984deec740f007dd2b03ec864804b0" dependencies = [ - "windows-result", + "windows-result 0.2.0", "windows-strings", "windows-targets 0.52.6", ] +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-result" version = "0.2.0" @@ -5062,7 +5093,7 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" dependencies = [ - "windows-result", + "windows-result 0.2.0", "windows-targets 0.52.6", ] diff --git a/bindings/electron/src/index.d.ts b/bindings/electron/src/index.d.ts index b28417933f7..3f3d5b2ccb7 100644 --- a/bindings/electron/src/index.d.ts +++ b/bindings/electron/src/index.d.ts @@ -20,6 +20,7 @@ export enum CancelledGreetingAttemptReason { } export enum DeviceFileType { + Biometrics = 'DeviceFileTypeBiometrics', Keyring = 'DeviceFileTypeKeyring', Password = 'DeviceFileTypePassword', Recovery = 'DeviceFileTypeRecovery', @@ -1562,6 +1563,10 @@ export type ClientUserUpdateProfileError = // DeviceAccessStrategy +export interface DeviceAccessStrategyBiometrics { + tag: "Biometrics" + key_file: string +} export interface DeviceAccessStrategyKeyring { tag: "Keyring" key_file: string @@ -1576,12 +1581,16 @@ export interface DeviceAccessStrategySmartcard { key_file: string } export type DeviceAccessStrategy = + | DeviceAccessStrategyBiometrics | DeviceAccessStrategyKeyring | DeviceAccessStrategyPassword | DeviceAccessStrategySmartcard // DeviceSaveStrategy +export interface DeviceSaveStrategyBiometrics { + tag: "Biometrics" +} export interface DeviceSaveStrategyKeyring { tag: "Keyring" } @@ -1593,6 +1602,7 @@ export interface DeviceSaveStrategySmartcard { tag: "Smartcard" } export type DeviceSaveStrategy = + | DeviceSaveStrategyBiometrics | DeviceSaveStrategyKeyring | DeviceSaveStrategyPassword | DeviceSaveStrategySmartcard @@ -3890,6 +3900,8 @@ export function importRecoveryDevice( export function initLibparsec( config: ClientConfig ): Promise +export function isBiometricsAvailable( +): Promise export function isKeyringAvailable( ): Promise export function listAvailableDevices( diff --git a/bindings/electron/src/meths.rs b/bindings/electron/src/meths.rs index 37bac0bef27..ed16e0e1079 100644 --- a/bindings/electron/src/meths.rs +++ b/bindings/electron/src/meths.rs @@ -79,6 +79,7 @@ fn enum_device_file_type_js_to_rs<'a>( raw_value: &str, ) -> NeonResult { match raw_value { + "DeviceFileTypeBiometrics" => Ok(libparsec::DeviceFileType::Biometrics), "DeviceFileTypeKeyring" => Ok(libparsec::DeviceFileType::Keyring), "DeviceFileTypePassword" => Ok(libparsec::DeviceFileType::Password), "DeviceFileTypeRecovery" => Ok(libparsec::DeviceFileType::Recovery), @@ -92,6 +93,7 @@ fn enum_device_file_type_js_to_rs<'a>( #[allow(dead_code)] fn enum_device_file_type_rs_to_js(value: libparsec::DeviceFileType) -> &'static str { match value { + libparsec::DeviceFileType::Biometrics => "DeviceFileTypeBiometrics", libparsec::DeviceFileType::Keyring => "DeviceFileTypeKeyring", libparsec::DeviceFileType::Password => "DeviceFileTypePassword", libparsec::DeviceFileType::Recovery => "DeviceFileTypeRecovery", @@ -6629,6 +6631,20 @@ fn variant_device_access_strategy_js_to_rs<'a>( ) -> NeonResult { let tag = obj.get::(cx, "tag")?.value(cx); match tag.as_str() { + "DeviceAccessStrategyBiometrics" => { + let key_file = { + let js_val: Handle = obj.get(cx, "keyFile")?; + { + let custom_from_rs_string = + |s: String| -> Result<_, &'static str> { Ok(std::path::PathBuf::from(s)) }; + match custom_from_rs_string(js_val.value(cx)) { + Ok(val) => val, + Err(err) => return cx.throw_type_error(err), + } + } + }; + Ok(libparsec::DeviceAccessStrategy::Biometrics { key_file }) + } "DeviceAccessStrategyKeyring" => { let key_file = { let js_val: Handle = obj.get(cx, "keyFile")?; @@ -6692,6 +6708,23 @@ fn variant_device_access_strategy_rs_to_js<'a>( ) -> NeonResult> { let js_obj = cx.empty_object(); match rs_obj { + libparsec::DeviceAccessStrategy::Biometrics { key_file, .. } => { + let js_tag = JsString::try_new(cx, "DeviceAccessStrategyBiometrics").or_throw(cx)?; + js_obj.set(cx, "tag", js_tag)?; + let js_key_file = JsString::try_new(cx, { + let custom_to_rs_string = |path: std::path::PathBuf| -> Result<_, _> { + path.into_os_string() + .into_string() + .map_err(|_| "Path contains non-utf8 characters") + }; + match custom_to_rs_string(key_file) { + Ok(ok) => ok, + Err(err) => return cx.throw_type_error(err), + } + }) + .or_throw(cx)?; + js_obj.set(cx, "keyFile", js_key_file)?; + } libparsec::DeviceAccessStrategy::Keyring { key_file, .. } => { let js_tag = JsString::try_new(cx, "DeviceAccessStrategyKeyring").or_throw(cx)?; js_obj.set(cx, "tag", js_tag)?; @@ -6760,6 +6793,7 @@ fn variant_device_save_strategy_js_to_rs<'a>( ) -> NeonResult { let tag = obj.get::(cx, "tag")?.value(cx); match tag.as_str() { + "DeviceSaveStrategyBiometrics" => Ok(libparsec::DeviceSaveStrategy::Biometrics {}), "DeviceSaveStrategyKeyring" => Ok(libparsec::DeviceSaveStrategy::Keyring {}), "DeviceSaveStrategyPassword" => { let password = { @@ -6786,6 +6820,10 @@ fn variant_device_save_strategy_rs_to_js<'a>( ) -> NeonResult> { let js_obj = cx.empty_object(); match rs_obj { + libparsec::DeviceSaveStrategy::Biometrics { .. } => { + let js_tag = JsString::try_new(cx, "DeviceSaveStrategyBiometrics").or_throw(cx)?; + js_obj.set(cx, "tag", js_tag)?; + } libparsec::DeviceSaveStrategy::Keyring { .. } => { let js_tag = JsString::try_new(cx, "DeviceSaveStrategyKeyring").or_throw(cx)?; js_obj.set(cx, "tag", js_tag)?; @@ -18981,6 +19019,16 @@ fn init_libparsec(mut cx: FunctionContext) -> JsResult { Ok(promise) } +// is_biometrics_available +fn is_biometrics_available(mut cx: FunctionContext) -> JsResult { + crate::init_sentry(); + let ret = libparsec::is_biometrics_available(); + let js_ret = JsBoolean::new(&mut cx, ret); + let (deferred, promise) = cx.promise(); + deferred.resolve(&mut cx, js_ret); + Ok(promise) +} + // is_keyring_available fn is_keyring_available(mut cx: FunctionContext) -> JsResult { crate::init_sentry(); @@ -24049,6 +24097,7 @@ pub fn register_meths(cx: &mut ModuleContext) -> NeonResult<()> { )?; cx.export_function("importRecoveryDevice", import_recovery_device)?; cx.export_function("initLibparsec", init_libparsec)?; + cx.export_function("isBiometricsAvailable", is_biometrics_available)?; cx.export_function("isKeyringAvailable", is_keyring_available)?; cx.export_function("listAvailableDevices", list_available_devices)?; cx.export_function("mountpointToOsPath", mountpoint_to_os_path)?; diff --git a/bindings/generator/api/client.py b/bindings/generator/api/client.py index 2b3aab0e336..019085e8a74 100644 --- a/bindings/generator/api/client.py +++ b/bindings/generator/api/client.py @@ -57,6 +57,9 @@ class Password: class Smartcard: key_file: Path + class Biometrics: + key_file: Path + class ClientStartError(ErrorVariant): class DeviceUsedByAnotherProcess: @@ -459,6 +462,10 @@ def is_keyring_available() -> bool: raise NotImplementedError +def is_biometrics_available() -> bool: + raise NotImplementedError + + class ImportRecoveryDeviceError(ErrorVariant): class Internal: pass diff --git a/bindings/generator/api/device.py b/bindings/generator/api/device.py index d66d08fceab..4486afa41ab 100644 --- a/bindings/generator/api/device.py +++ b/bindings/generator/api/device.py @@ -24,6 +24,7 @@ class DeviceFileType(Enum): Password = EnumItemUnit Recovery = EnumItemUnit Smartcard = EnumItemUnit + Biometrics = EnumItemUnit class DeviceSaveStrategy(Variant): @@ -36,6 +37,9 @@ class Password: class Smartcard: pass + class Biometrics: + pass + class AvailableDevice(Structure): key_file_path: Path diff --git a/bindings/web/src/meths.rs b/bindings/web/src/meths.rs index 31b7e9815af..4094df906db 100644 --- a/bindings/web/src/meths.rs +++ b/bindings/web/src/meths.rs @@ -83,6 +83,7 @@ fn enum_cancelled_greeting_attempt_reason_rs_to_js( #[allow(dead_code)] fn enum_device_file_type_js_to_rs(raw_value: &str) -> Result { match raw_value { + "DeviceFileTypeBiometrics" => Ok(libparsec::DeviceFileType::Biometrics), "DeviceFileTypeKeyring" => Ok(libparsec::DeviceFileType::Keyring), "DeviceFileTypePassword" => Ok(libparsec::DeviceFileType::Password), "DeviceFileTypeRecovery" => Ok(libparsec::DeviceFileType::Recovery), @@ -98,6 +99,7 @@ fn enum_device_file_type_js_to_rs(raw_value: &str) -> Result &'static str { match value { + libparsec::DeviceFileType::Biometrics => "DeviceFileTypeBiometrics", libparsec::DeviceFileType::Keyring => "DeviceFileTypeKeyring", libparsec::DeviceFileType::Password => "DeviceFileTypePassword", libparsec::DeviceFileType::Recovery => "DeviceFileTypeRecovery", @@ -7322,6 +7324,24 @@ fn variant_device_access_strategy_js_to_rs( .as_string() .ok_or_else(|| JsValue::from(TypeError::new("tag isn't a string")))?; match tag.as_str() { + "DeviceAccessStrategyBiometrics" => { + let key_file = { + let js_val = Reflect::get(&obj, &"keyFile".into())?; + js_val + .dyn_into::() + .ok() + .and_then(|s| s.as_string()) + .ok_or_else(|| TypeError::new("Not a string")) + .and_then(|x| { + let custom_from_rs_string = |s: String| -> Result<_, &'static str> { + Ok(std::path::PathBuf::from(s)) + }; + custom_from_rs_string(x).map_err(|e| TypeError::new(e.as_ref())) + }) + .map_err(|_| TypeError::new("Not a valid Path"))? + }; + Ok(libparsec::DeviceAccessStrategy::Biometrics { key_file }) + } "DeviceAccessStrategyKeyring" => { let key_file = { let js_val = Reflect::get(&obj, &"keyFile".into())?; @@ -7402,6 +7422,26 @@ fn variant_device_access_strategy_rs_to_js( ) -> Result { let js_obj = Object::new().into(); match rs_obj { + libparsec::DeviceAccessStrategy::Biometrics { key_file, .. } => { + Reflect::set( + &js_obj, + &"tag".into(), + &"DeviceAccessStrategyBiometrics".into(), + )?; + let js_key_file = JsValue::from_str({ + let custom_to_rs_string = |path: std::path::PathBuf| -> Result<_, _> { + path.into_os_string() + .into_string() + .map_err(|_| "Path contains non-utf8 characters") + }; + match custom_to_rs_string(key_file) { + Ok(ok) => ok, + Err(err) => return Err(JsValue::from(TypeError::new(err.as_ref()))), + } + .as_ref() + }); + Reflect::set(&js_obj, &"keyFile".into(), &js_key_file)?; + } libparsec::DeviceAccessStrategy::Keyring { key_file, .. } => { Reflect::set( &js_obj, @@ -7481,6 +7521,7 @@ fn variant_device_save_strategy_js_to_rs( .as_string() .ok_or_else(|| JsValue::from(TypeError::new("tag isn't a string")))?; match tag.as_str() { + "DeviceSaveStrategyBiometrics" => Ok(libparsec::DeviceSaveStrategy::Biometrics {}), "DeviceSaveStrategyKeyring" => Ok(libparsec::DeviceSaveStrategy::Keyring {}), "DeviceSaveStrategyPassword" => { let password = { @@ -7512,6 +7553,13 @@ fn variant_device_save_strategy_rs_to_js( ) -> Result { let js_obj = Object::new().into(); match rs_obj { + libparsec::DeviceSaveStrategy::Biometrics { .. } => { + Reflect::set( + &js_obj, + &"tag".into(), + &"DeviceSaveStrategyBiometrics".into(), + )?; + } libparsec::DeviceSaveStrategy::Keyring { .. } => { Reflect::set(&js_obj, &"tag".into(), &"DeviceSaveStrategyKeyring".into())?; } @@ -17947,6 +17995,16 @@ pub fn initLibparsec(config: Object) -> Promise { }) } +// is_biometrics_available +#[allow(non_snake_case)] +#[wasm_bindgen] +pub fn isBiometricsAvailable() -> Promise { + future_to_promise(async move { + let ret = libparsec::is_biometrics_available(); + Ok(ret.into()) + }) +} + // is_keyring_available #[allow(non_snake_case)] #[wasm_bindgen] diff --git a/cli/src/commands/device/change_authentication.rs b/cli/src/commands/device/change_authentication.rs index eabe253b344..96dd6a524e5 100644 --- a/cli/src/commands/device/change_authentication.rs +++ b/cli/src/commands/device/change_authentication.rs @@ -8,6 +8,7 @@ use crate::utils::*; enum AuthenticationMethod { Keyring, Password, + Biometrics, } crate::clap_parser_with_shared_opts_builder!( @@ -40,6 +41,7 @@ pub async fn main(args: Args) -> anyhow::Result<()> { })?; DeviceSaveStrategy::Password { password } } + AuthenticationMethod::Biometrics => DeviceSaveStrategy::Biometrics, }; // Update device access strategy diff --git a/cli/src/utils.rs b/cli/src/utils.rs index 9736a08c1b7..244826545e8 100644 --- a/cli/src/utils.rs +++ b/cli/src/utils.rs @@ -233,6 +233,9 @@ pub async fn get_device_access_strategy( password, } } + DeviceFileType::Biometrics => DeviceAccessStrategy::Biometrics { + key_file: device.key_file_path.clone(), + }, DeviceFileType::Smartcard => DeviceAccessStrategy::Smartcard { key_file: device.key_file_path.clone(), }, diff --git a/client/src/parsec/login.ts b/client/src/parsec/login.ts index d54ff5429b8..aa6f5b3b530 100644 --- a/client/src/parsec/login.ts +++ b/client/src/parsec/login.ts @@ -2,6 +2,7 @@ import { ActiveUsersLimitTag, + DeviceAccessStrategyBiometrics, DeviceAccessStrategyKeyring, DeviceFileType, DeviceSaveStrategyKeyring, @@ -433,6 +434,12 @@ export const AccessStrategy = { keyFile: device.keyFilePath, }; }, + useBiometrics(device: AvailableDevice): DeviceAccessStrategyBiometrics { + return { + tag: DeviceAccessStrategyTag.Biometrics, + keyFile: device.keyFilePath, + }; + }, }; export const SaveStrategy = { diff --git a/client/src/plugins/libparsec/definitions.ts b/client/src/plugins/libparsec/definitions.ts index 50d76c53a62..aad09cda577 100644 --- a/client/src/plugins/libparsec/definitions.ts +++ b/client/src/plugins/libparsec/definitions.ts @@ -19,6 +19,7 @@ export enum CancelledGreetingAttemptReason { } export enum DeviceFileType { + Biometrics = 'DeviceFileTypeBiometrics', Keyring = 'DeviceFileTypeKeyring', Password = 'DeviceFileTypePassword', Recovery = 'DeviceFileTypeRecovery', @@ -1814,11 +1815,16 @@ export type ClientUserUpdateProfileError = // DeviceAccessStrategy export enum DeviceAccessStrategyTag { + Biometrics = 'DeviceAccessStrategyBiometrics', Keyring = 'DeviceAccessStrategyKeyring', Password = 'DeviceAccessStrategyPassword', Smartcard = 'DeviceAccessStrategySmartcard', } +export interface DeviceAccessStrategyBiometrics { + tag: DeviceAccessStrategyTag.Biometrics + keyFile: Path +} export interface DeviceAccessStrategyKeyring { tag: DeviceAccessStrategyTag.Keyring keyFile: Path @@ -1833,17 +1839,22 @@ export interface DeviceAccessStrategySmartcard { keyFile: Path } export type DeviceAccessStrategy = + | DeviceAccessStrategyBiometrics | DeviceAccessStrategyKeyring | DeviceAccessStrategyPassword | DeviceAccessStrategySmartcard // DeviceSaveStrategy export enum DeviceSaveStrategyTag { + Biometrics = 'DeviceSaveStrategyBiometrics', Keyring = 'DeviceSaveStrategyKeyring', Password = 'DeviceSaveStrategyPassword', Smartcard = 'DeviceSaveStrategySmartcard', } +export interface DeviceSaveStrategyBiometrics { + tag: DeviceSaveStrategyTag.Biometrics +} export interface DeviceSaveStrategyKeyring { tag: DeviceSaveStrategyTag.Keyring } @@ -1855,6 +1866,7 @@ export interface DeviceSaveStrategySmartcard { tag: DeviceSaveStrategyTag.Smartcard } export type DeviceSaveStrategy = + | DeviceSaveStrategyBiometrics | DeviceSaveStrategyKeyring | DeviceSaveStrategyPassword | DeviceSaveStrategySmartcard @@ -4589,6 +4601,8 @@ export interface LibParsecPlugin { initLibparsec( config: ClientConfig ): Promise + isBiometricsAvailable( + ): Promise isKeyringAvailable( ): Promise listAvailableDevices( diff --git a/client/src/views/home/HomePage.vue b/client/src/views/home/HomePage.vue index f6522d471cd..6a575947d91 100644 --- a/client/src/views/home/HomePage.vue +++ b/client/src/views/home/HomePage.vue @@ -326,6 +326,8 @@ async function onOrganizationSelected(device: AvailableDevice): Promise { } if (device.ty === DeviceFileType.Keyring) { await login(device, AccessStrategy.useKeyring(device)); + } else if (device.ty === DeviceFileType.Biometrics) { + await login(device, AccessStrategy.useBiometrics(device)); } else { selectedDevice.value = device; state.value = HomePageState.Login; diff --git a/libparsec/crates/platform_device_loader/Cargo.toml b/libparsec/crates/platform_device_loader/Cargo.toml index cbca0dcd50e..19d043caf05 100644 --- a/libparsec/crates/platform_device_loader/Cargo.toml +++ b/libparsec/crates/platform_device_loader/Cargo.toml @@ -39,6 +39,17 @@ tokio = { workspace = true, features = ["fs"] } web-sys = { workspace = true, features = ["Window", "Storage"] } serde_json = { workspace = true, features = ["std"] } +[target.'cfg(target_os = "windows")'.dependencies] +sha2 = { workspace = true, features = ["std"] } +windows = { version = "=0.54.0", features = [ + "Security_Credentials_UI", + "Security_Cryptography", + "Storage_Streams", + "Win32_Security_Credentials", + "Win32_UI_Input_KeyboardAndMouse", + "Win32_UI_WindowsAndMessaging", +] } + [dev-dependencies] libparsec_tests_lite = { workspace = true } # Note `libparsec_tests_fixtures` enables our `test-with-testbed` feature diff --git a/libparsec/crates/platform_device_loader/src/lib.rs b/libparsec/crates/platform_device_loader/src/lib.rs index 759bee9d8d6..aae071e2570 100644 --- a/libparsec/crates/platform_device_loader/src/lib.rs +++ b/libparsec/crates/platform_device_loader/src/lib.rs @@ -217,6 +217,14 @@ pub fn is_keyring_available() -> bool { native::is_keyring_available() } +pub fn is_biometrics_available() -> bool { + #[cfg(target_arch = "wasm32")] + return false; + + #[cfg(not(target_arch = "wasm32"))] + native::is_biometrics_available() +} + #[derive(Debug, thiserror::Error)] pub enum ArchiveDeviceError { #[error(transparent)] diff --git a/libparsec/crates/platform_device_loader/src/native/biometrics.rs b/libparsec/crates/platform_device_loader/src/native/biometrics.rs new file mode 100644 index 00000000000..69cd8ab9203 --- /dev/null +++ b/libparsec/crates/platform_device_loader/src/native/biometrics.rs @@ -0,0 +1,126 @@ +// Parsec Cloud (https://parsec.cloud) Copyright (c) BUSL-1.1 2016-present Scille SAS + +use crate::anyhow::{anyhow, Result}; +use libparsec_types::{DeviceID, SecretKey}; +use sha2::{Digest, Sha256}; + +use windows::{ + core::{s, HSTRING}, + Security::{ + Credentials::{ + KeyCredential, KeyCredentialCreationOption, KeyCredentialManager, KeyCredentialStatus, + }, + Cryptography::CryptographicBuffer, + }, + Win32::{ + Foundation::HWND, + UI::{ + Input::KeyboardAndMouse::{ + keybd_event, GetAsyncKeyState, SetFocus, KEYEVENTF_EXTENDEDKEY, KEYEVENTF_KEYUP, + VK_MENU, + }, + WindowsAndMessaging::{FindWindowA, SetForegroundWindow}, + }, + }, +}; + +fn set_focus(window: HWND) { + let mut pressed = false; + // SAFETY: + // Many calls to windows unsafe API + unsafe { + // Simulate holding down Alt key to bypass windows limitations + // https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getasynckeystate#return-value + // The most significant bit indicates if the key is currently being pressed. This means the + // value will be negative if the key is pressed. + if GetAsyncKeyState(VK_MENU.0 as i32) >= 0 { + pressed = true; + keybd_event(VK_MENU.0 as u8, 0, KEYEVENTF_EXTENDEDKEY, 0); + } + SetForegroundWindow(window); + SetFocus(window); + if pressed { + keybd_event( + VK_MENU.0 as u8, + 0, + KEYEVENTF_EXTENDEDKEY | KEYEVENTF_KEYUP, + 0, + ); + } + } +} + +fn focus_security_prompt() -> Result<(), ()> { + fn try_find_and_set_focus(class_name: windows::core::PCSTR) -> Result<(), ()> { + // SAFETY: + // Call to unsafe windows API + let hwnd = unsafe { FindWindowA(class_name, None) }; + if hwnd.0 != 0 { + set_focus(hwnd); + return Ok(()); + } + Err(()) + } + let class_name = s!("Credential Dialog Xaml Host"); + // Try 30 times, waiting 50 ms (1.5 seconds total) + for _ in 0..30 { + if try_find_and_set_focus(class_name).is_ok() { + return Ok(()); + } + std::thread::sleep(std::time::Duration::from_millis(50)); + } + Err(()) +} + +pub fn is_biometrics_available() -> bool { + let result = KeyCredentialManager::IsSupportedAsync().and_then(|x| x.get()); + match result { + Ok(x) => x, + Err(e) => { + log::warn!("Unexpected error while accessing the KeyCredentialManager API: {e}"); + false + } + } +} + +pub fn get_key_credential(service: &str) -> Result { + let service_name = HSTRING::from(service); + + let result = KeyCredentialManager::RequestCreateAsync( + &service_name, + KeyCredentialCreationOption::FailIfExists, + )? + .get()?; + + let result = match result.Status()? { + KeyCredentialStatus::CredentialAlreadyExists => { + KeyCredentialManager::OpenAsync(&service_name)?.get()? + } + KeyCredentialStatus::Success => result, + unknown => return Err(anyhow!("Failed to create key credential: {unknown:?}")), + }; + let credential = result.Credential()?; + Ok(credential) +} + +pub fn derive_key_from_biometrics(service: &str, device_id: DeviceID) -> Result { + let challenge = format!("BIOMETRICS_CHALLENGE:{}", device_id.hex()); + let challenge_bytes = challenge.as_bytes(); + let challenge_buffer = CryptographicBuffer::CreateFromByteArray(challenge_bytes)?; + + let credential = get_key_credential(service)?; + let async_operation = credential.RequestSignAsync(&challenge_buffer)?; + focus_security_prompt().ok(); + let signature = async_operation.get()?; + if signature.Status()? != KeyCredentialStatus::Success { + return Err(anyhow!("Failed to sign data")); + } + + let signature_buffer = signature.Result()?; + let mut signature_value = + windows::core::Array::::with_len(signature_buffer.Length()? as usize); + CryptographicBuffer::CopyToByteArray(&signature_buffer, &mut signature_value)?; + let raw_key: [u8; SecretKey::SIZE] = Sha256::digest(&*signature_value).into(); + let key = SecretKey::try_from(raw_key)?; + Ok(key) +} diff --git a/libparsec/crates/platform_device_loader/src/native/mod.rs b/libparsec/crates/platform_device_loader/src/native/mod.rs index b4db84b2a25..e900f7bc676 100644 --- a/libparsec/crates/platform_device_loader/src/native/mod.rs +++ b/libparsec/crates/platform_device_loader/src/native/mod.rs @@ -16,8 +16,14 @@ use crate::{ ARGON2ID_DEFAULT_OPSLIMIT, ARGON2ID_DEFAULT_PARALLELISM, DEVICE_FILE_EXT, }; +#[cfg(target_os = "windows")] +mod biometrics; + const KEYRING_SERVICE: &str = "parsec"; +#[cfg(target_os = "windows")] +const BIOMETRICS_SERVICE: &str = "parsec"; + impl From for LoadDeviceError { fn from(value: keyring::Error) -> Self { Self::Internal(anyhow::anyhow!(value)) @@ -150,6 +156,17 @@ fn load_available_device( device.human_handle, device.device_label, ), + DeviceFile::Biometrics(device) => ( + DeviceFileType::Biometrics, + device.created_on, + device.protected_on, + device.server_url, + device.organization_id, + device.user_id, + device.device_id, + device.human_handle, + device.device_label, + ), DeviceFile::Smartcard(device) => ( DeviceFileType::Smartcard, device.created_on, @@ -268,6 +285,44 @@ pub async fn load_device( } } + DeviceAccessStrategy::Biometrics { key_file } => { + #[cfg(not(target_os = "windows"))] + { + return Err(LoadDeviceError::Internal(anyhow::anyhow!( + "Biometrics are not available on this platform, cannot load path {key_file:?}" + ))); + } + #[cfg(target_os = "windows")] + { + use biometrics::derive_key_from_biometrics; + + // TODO: make file access on a worker thread ! + let content = + std::fs::read(key_file).map_err(|e| LoadDeviceError::InvalidPath(e.into()))?; + + // Regular load + let device_file = + DeviceFile::load(&content).map_err(|_| LoadDeviceError::InvalidData)?; + + if let DeviceFile::Biometrics(x) = device_file { + let key = derive_key_from_biometrics(&x.biometrics_service, x.device_id) + .map_err(LoadDeviceError::Internal)?; + let mut cleartext = key + .decrypt(&x.ciphertext) + .map_err(|_| LoadDeviceError::DecryptionFailed)?; + + let device = + LocalDevice::load(&cleartext).map_err(|_| LoadDeviceError::InvalidData)?; + + cleartext.zeroize(); + + (device, x.created_on) + } else { + return Err(LoadDeviceError::InvalidData); + } + } + } + DeviceAccessStrategy::Smartcard { .. } => { // TODO ! todo!() @@ -432,6 +487,40 @@ pub async fn save_device( save_content(key_file, &file_content)?; } + DeviceAccessStrategy::Biometrics { key_file } => { + #[cfg(not(target_os = "windows"))] + { + return Err(SaveDeviceError::Internal(anyhow::anyhow!( + "Biometrics are not available on this platform, cannot save to path {key_file:?}" + ))); + } + #[cfg(target_os = "windows")] + { + use biometrics::derive_key_from_biometrics; + let key = derive_key_from_biometrics(BIOMETRICS_SERVICE, device.device_id) + .map_err(SaveDeviceError::Internal)?; + let cleartext = device.dump(); + let ciphertext = key.encrypt(&cleartext); + + let file_content = DeviceFile::Biometrics(DeviceFileBiometrics { + created_on, + protected_on, + server_url: server_url.clone(), + organization_id: device.organization_id().clone(), + user_id: device.user_id, + device_id: device.device_id, + human_handle: device.human_handle.clone(), + device_label: device.device_label.clone(), + biometrics_service: BIOMETRICS_SERVICE.into(), + ciphertext: ciphertext.into(), + }); + + let file_content = file_content.dump(); + + save_content(key_file, &file_content)?; + } + } + DeviceAccessStrategy::Smartcard { .. } => { // TODO todo!() @@ -526,3 +615,12 @@ pub fn is_keyring_available() -> bool { } } } + +#[cfg(not(target_os = "windows"))] +#[allow(unused)] +pub fn is_biometrics_available() -> bool { + false +} + +#[cfg(target_os = "windows")] +pub use biometrics::is_biometrics_available; diff --git a/libparsec/crates/platform_device_loader/src/testbed.rs b/libparsec/crates/platform_device_loader/src/testbed.rs index 16a24c49059..bd82ca72644 100644 --- a/libparsec/crates/platform_device_loader/src/testbed.rs +++ b/libparsec/crates/platform_device_loader/src/testbed.rs @@ -270,6 +270,7 @@ pub(crate) fn maybe_load_device( let decryption_success = password.as_str() == KEY_FILE_PASSWORD; decryption_success } + DeviceAccessStrategy::Biometrics { .. } => true, DeviceAccessStrategy::Smartcard { .. } => true, }; // We don't try to resolve the path of `key_file` into an absolute one here ! diff --git a/libparsec/crates/types/schema/local_device/device_file_biometrics.json5 b/libparsec/crates/types/schema/local_device/device_file_biometrics.json5 new file mode 100644 index 00000000000..ed83c0138b0 --- /dev/null +++ b/libparsec/crates/types/schema/local_device/device_file_biometrics.json5 @@ -0,0 +1,51 @@ +{ + "label": "DeviceFileBiometrics", + "type": "biometrics", + "other_fields": [ + { + // This refers to when the device file has been originally created. + "name": "created_on", + "type": "DateTime" + }, + { + // This field gets updated every time the device file changes its protection. + "name": "protected_on", + "type": "DateTime" + }, + { + // Url to the server in the format `https://parsec.example.com:443`. + // Note we don't use the `parsec3://` scheme here to avoid compatibility + // issue if we later decide to change the scheme. + "name": "server_url", + "type": "String" + }, + { + "name": "organization_id", + "type": "OrganizationID" + }, + { + "name": "user_id", + "type": "UserID" + }, + { + "name": "device_id", + "type": "DeviceID" + }, + { + "name": "human_handle", + "type": "HumanHandle" + }, + { + "name": "device_label", + "type": "DeviceLabel" + }, + { + "name": "biometrics_service", + "type": "String" + }, + { + "name": "ciphertext", + "type": "Bytes" + } + ] +} diff --git a/libparsec/crates/types/src/local_device_file.rs b/libparsec/crates/types/src/local_device_file.rs index 20bea4ad817..aa82b871b01 100644 --- a/libparsec/crates/types/src/local_device_file.rs +++ b/libparsec/crates/types/src/local_device_file.rs @@ -109,6 +109,38 @@ impl_transparent_data_format_conversion!( ciphertext, ); +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(into = "DeviceFileBiometricsData", from = "DeviceFileBiometricsData")] +pub struct DeviceFileBiometrics { + pub created_on: DateTime, + pub protected_on: DateTime, + pub server_url: String, + pub organization_id: OrganizationID, + pub user_id: UserID, + pub device_id: DeviceID, + pub human_handle: HumanHandle, + pub device_label: DeviceLabel, + pub biometrics_service: String, + pub ciphertext: Bytes, +} + +parsec_data!("schema/local_device/device_file_biometrics.json5"); + +impl_transparent_data_format_conversion!( + DeviceFileBiometrics, + DeviceFileBiometricsData, + created_on, + protected_on, + server_url, + organization_id, + user_id, + device_id, + human_handle, + device_label, + biometrics_service, + ciphertext, +); + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(into = "DeviceFileSmartcardData", from = "DeviceFileSmartcardData")] pub struct DeviceFileSmartcard { @@ -151,6 +183,7 @@ pub enum DeviceFile { Keyring(DeviceFileKeyring), Password(DeviceFilePassword), Recovery(DeviceFileRecovery), + Biometrics(DeviceFileBiometrics), Smartcard(DeviceFileSmartcard), } @@ -171,6 +204,7 @@ pub enum DeviceFileType { Password, Recovery, Smartcard, + Biometrics, } impl DeviceFileType { @@ -188,6 +222,7 @@ pub enum DeviceSaveStrategy { Keyring, Password { password: Password }, Smartcard, + Biometrics, } impl DeviceSaveStrategy { @@ -198,6 +233,7 @@ impl DeviceSaveStrategy { DeviceAccessStrategy::Password { key_file, password } } DeviceSaveStrategy::Smartcard => DeviceAccessStrategy::Smartcard { key_file }, + DeviceSaveStrategy::Biometrics => DeviceAccessStrategy::Biometrics { key_file }, } } } @@ -215,6 +251,9 @@ pub enum DeviceAccessStrategy { Smartcard { key_file: PathBuf, }, + Biometrics { + key_file: PathBuf, + }, // Future API that will be use for parsec-web // ServerSide{ // url: ParsecOrganizationAddr, @@ -229,6 +268,7 @@ impl DeviceAccessStrategy { Self::Keyring { key_file } => key_file, Self::Password { key_file, .. } => key_file, Self::Smartcard { key_file } => key_file, + Self::Biometrics { key_file } => key_file, } } @@ -237,6 +277,7 @@ impl DeviceAccessStrategy { Self::Keyring { .. } => DeviceFileType::Keyring, Self::Password { .. } => DeviceFileType::Password, Self::Smartcard { .. } => DeviceFileType::Smartcard, + Self::Biometrics { .. } => DeviceFileType::Biometrics, } } } diff --git a/libparsec/src/lib.rs b/libparsec/src/lib.rs index 1dbc1545473..2ce4dfe67a8 100644 --- a/libparsec/src/lib.rs +++ b/libparsec/src/lib.rs @@ -30,7 +30,8 @@ pub use invite::*; pub use libparsec_client::{ClientExportRecoveryDeviceError, ImportRecoveryDeviceError}; pub use libparsec_client_connection::*; pub use libparsec_platform_device_loader::{ - get_default_key_file, is_keyring_available, load_device, save_device, LoadDeviceError, + get_default_key_file, is_biometrics_available, is_keyring_available, load_device, save_device, + LoadDeviceError, }; pub use libparsec_platform_storage as storage; pub use libparsec_protocol::*; From b8db7a2a220fdd3211efa22949f09cfe8d0bc1a1 Mon Sep 17 00:00:00 2001 From: Vincent Michel Date: Sun, 16 Feb 2025 16:25:47 +0100 Subject: [PATCH 3/3] Add 4 TODOs for the reviewers --- .../src/native/biometrics.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/libparsec/crates/platform_device_loader/src/native/biometrics.rs b/libparsec/crates/platform_device_loader/src/native/biometrics.rs index 69cd8ab9203..63988f9d0b5 100644 --- a/libparsec/crates/platform_device_loader/src/native/biometrics.rs +++ b/libparsec/crates/platform_device_loader/src/native/biometrics.rs @@ -29,6 +29,10 @@ fn set_focus(window: HWND) { // SAFETY: // Many calls to windows unsafe API unsafe { + // TODO: This Alt key hack has been recently removed from bitwarden: + // https://github.com/bitwarden/clients/pull/12255 + // We should investigate if it's still necessary, or if the approach can be improved. + // Simulate holding down Alt key to bypass windows limitations // https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getasynckeystate#return-value // The most significant bit indicates if the key is currently being pressed. This means the @@ -86,6 +90,15 @@ pub fn is_biometrics_available() -> bool { pub fn get_key_credential(service: &str) -> Result { let service_name = HSTRING::from(service); + // TODO: The approach here is to create a new key credential if it doesn't exist. + // This allows us to expose only a single `derive_key_from_biometrics` API for + // both loading and saving. + // However, this means we cannot detect when an existing key credential has been + // removed from the system (e.g when the pin code has been removed and recreated). + // Another approach would be to retrieve the corresponding public key when saving + // a new device using `KeyCredential::RetrievePublicKey()`. This way, this information + // could be stored in the device key file and use it to detect when the key credential + // has been removed or changed. let result = KeyCredentialManager::RequestCreateAsync( &service_name, KeyCredentialCreationOption::FailIfExists, @@ -103,7 +116,12 @@ pub fn get_key_credential(service: &str) -> Result { Ok(credential) } +// TODO: this function is synchronous but will block the thread while waiting for the +// user to enter this pin code. Either this function should be async or we make sure +// the caller uses `tokio::task::spawn_blocking` pub fn derive_key_from_biometrics(service: &str, device_id: DeviceID) -> Result { + // TODO: Is this the formatting we want for the challenge? + // Is the device ID the right thing to use here? let challenge = format!("BIOMETRICS_CHALLENGE:{}", device_id.hex()); let challenge_bytes = challenge.as_bytes(); let challenge_buffer = CryptographicBuffer::CreateFromByteArray(challenge_bytes)?;