diff --git a/Cargo.lock b/Cargo.lock index 3c867c9ed..6864413c1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -376,9 +376,11 @@ dependencies = [ "bitwarden-exporters", "bitwarden-generators", "chrono", + "coset", "getrandom", "hmac", "log", + "p256", "passkey", "rand", "rand_chacha", @@ -558,6 +560,7 @@ dependencies = [ "env_logger 0.11.3", "log", "schemars", + "thiserror", "uniffi", "uuid", ] @@ -2550,7 +2553,7 @@ dependencies = [ [[package]] name = "passkey" version = "0.3.1" -source = "git+https://github.com/bitwarden/passkey-rs?rev=12da886102707f87ad97e499c857c0857ece0b85#12da886102707f87ad97e499c857c0857ece0b85" +source = "git+https://github.com/bitwarden/passkey-rs?rev=c48c2ddfd6b884b2d754432576c66cb2b1985a3a#c48c2ddfd6b884b2d754432576c66cb2b1985a3a" dependencies = [ "passkey-authenticator", "passkey-client", @@ -2561,7 +2564,7 @@ dependencies = [ [[package]] name = "passkey-authenticator" version = "0.3.0" -source = "git+https://github.com/bitwarden/passkey-rs?rev=12da886102707f87ad97e499c857c0857ece0b85#12da886102707f87ad97e499c857c0857ece0b85" +source = "git+https://github.com/bitwarden/passkey-rs?rev=c48c2ddfd6b884b2d754432576c66cb2b1985a3a#c48c2ddfd6b884b2d754432576c66cb2b1985a3a" dependencies = [ "async-trait", "coset", @@ -2574,7 +2577,7 @@ dependencies = [ [[package]] name = "passkey-client" version = "0.3.1" -source = "git+https://github.com/bitwarden/passkey-rs?rev=12da886102707f87ad97e499c857c0857ece0b85#12da886102707f87ad97e499c857c0857ece0b85" +source = "git+https://github.com/bitwarden/passkey-rs?rev=c48c2ddfd6b884b2d754432576c66cb2b1985a3a#c48c2ddfd6b884b2d754432576c66cb2b1985a3a" dependencies = [ "ciborium", "coset", @@ -2591,12 +2594,12 @@ dependencies = [ [[package]] name = "passkey-transports" version = "0.1.0" -source = "git+https://github.com/bitwarden/passkey-rs?rev=12da886102707f87ad97e499c857c0857ece0b85#12da886102707f87ad97e499c857c0857ece0b85" +source = "git+https://github.com/bitwarden/passkey-rs?rev=c48c2ddfd6b884b2d754432576c66cb2b1985a3a#c48c2ddfd6b884b2d754432576c66cb2b1985a3a" [[package]] name = "passkey-types" version = "0.2.1" -source = "git+https://github.com/bitwarden/passkey-rs?rev=12da886102707f87ad97e499c857c0857ece0b85#12da886102707f87ad97e499c857c0857ece0b85" +source = "git+https://github.com/bitwarden/passkey-rs?rev=c48c2ddfd6b884b2d754432576c66cb2b1985a3a#c48c2ddfd6b884b2d754432576c66cb2b1985a3a" dependencies = [ "bitflags 2.5.0", "ciborium", @@ -2808,7 +2811,7 @@ dependencies = [ [[package]] name = "public-suffix" version = "0.1.1" -source = "git+https://github.com/bitwarden/passkey-rs?rev=12da886102707f87ad97e499c857c0857ece0b85#12da886102707f87ad97e499c857c0857ece0b85" +source = "git+https://github.com/bitwarden/passkey-rs?rev=c48c2ddfd6b884b2d754432576c66cb2b1985a3a#c48c2ddfd6b884b2d754432576c66cb2b1985a3a" [[package]] name = "pyo3" diff --git a/crates/bitwarden-uniffi/Cargo.toml b/crates/bitwarden-uniffi/Cargo.toml index f20336dbe..62f9e5806 100644 --- a/crates/bitwarden-uniffi/Cargo.toml +++ b/crates/bitwarden-uniffi/Cargo.toml @@ -30,6 +30,7 @@ chrono = { version = ">=0.4.26, <0.5", features = [ log = "0.4.20" env_logger = "0.11.1" schemars = { version = ">=0.8, <0.9", optional = true } +thiserror = ">=1.0.40, <2.0" uniffi = "=0.27.2" uuid = ">=1.3.3, <2" diff --git a/crates/bitwarden-uniffi/src/error.rs b/crates/bitwarden-uniffi/src/error.rs index cf07a07a1..5eef9bbd5 100644 --- a/crates/bitwarden-uniffi/src/error.rs +++ b/crates/bitwarden-uniffi/src/error.rs @@ -14,23 +14,6 @@ impl From for BitwardenError { } } -impl From for bitwarden::error::Error { - fn from(val: BitwardenError) -> Self { - match val { - BitwardenError::E(e) => e, - } - } -} - -// Need to implement this From<> impl in order to handle unexpected callback errors. See the -// following page in the Uniffi user guide: -// -impl From for BitwardenError { - fn from(e: uniffi::UnexpectedUniFFICallbackError) -> Self { - Self::E(bitwarden::error::Error::UniffiCallback(e)) - } -} - impl Display for BitwardenError { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { diff --git a/crates/bitwarden-uniffi/src/platform/fido2.rs b/crates/bitwarden-uniffi/src/platform/fido2.rs index 25756bdab..8a1c10e64 100644 --- a/crates/bitwarden-uniffi/src/platform/fido2.rs +++ b/crates/bitwarden-uniffi/src/platform/fido2.rs @@ -1,20 +1,18 @@ use std::sync::Arc; use bitwarden::{ - error::Result as BitResult, platform::fido2::{ - CheckUserOptions, ClientData, GetAssertionRequest, GetAssertionResult, - MakeCredentialRequest, MakeCredentialResult, + CheckUserOptions, ClientData, Fido2CallbackError as BitFido2CallbackError, + GetAssertionRequest, GetAssertionResult, MakeCredentialRequest, MakeCredentialResult, PublicKeyCredentialAuthenticatorAssertionResponse, - PublicKeyCredentialAuthenticatorAttestationResponse, + PublicKeyCredentialAuthenticatorAttestationResponse, PublicKeyCredentialRpEntity, + PublicKeyCredentialUserEntity, }, - vault::{Cipher, CipherView, Fido2Credential, Fido2CredentialView}, + vault::{Cipher, CipherView, Fido2CredentialNewView, Fido2CredentialView}, }; use crate::{error::Result, Client}; -/// At the moment this is just a stub implementation that doesn't do anything. It's here to make -/// it possible to check the usability API on the native clients. #[derive(uniffi::Object)] pub struct ClientFido2(pub(crate) Arc); @@ -22,8 +20,8 @@ pub struct ClientFido2(pub(crate) Arc); impl ClientFido2 { pub fn authenticator( self: Arc, - user_interface: Arc, - credential_store: Arc, + user_interface: Arc, + credential_store: Arc, ) -> Arc { Arc::new(ClientFido2Authenticator( self.0.clone(), @@ -34,8 +32,8 @@ impl ClientFido2 { pub fn client( self: Arc, - user_interface: Arc, - credential_store: Arc, + user_interface: Arc, + credential_store: Arc, ) -> Arc { Arc::new(ClientFido2Client(ClientFido2Authenticator( self.0.clone(), @@ -48,8 +46,8 @@ impl ClientFido2 { #[derive(uniffi::Object)] pub struct ClientFido2Authenticator( pub(crate) Arc, - pub(crate) Arc, - pub(crate) Arc, + pub(crate) Arc, + pub(crate) Arc, ); #[uniffi::export] @@ -152,35 +150,67 @@ pub struct CheckUserResult { user_verified: bool, } +#[derive(Debug, thiserror::Error, uniffi::Error)] +pub enum Fido2CallbackError { + #[error("The operation requires user interaction")] + UserInterfaceRequired, + + #[error("The operation was cancelled by the user")] + OperationCancelled, + + #[error("Unknown error: {reason}")] + Unknown { reason: String }, +} + +// Need to implement this From<> impl in order to handle unexpected callback errors. See the +// following page in the Uniffi user guide: +// +impl From for Fido2CallbackError { + fn from(e: uniffi::UnexpectedUniFFICallbackError) -> Self { + Self::Unknown { reason: e.reason } + } +} + +impl From for BitFido2CallbackError { + fn from(val: Fido2CallbackError) -> Self { + match val { + Fido2CallbackError::UserInterfaceRequired => Self::UserInterfaceRequired, + Fido2CallbackError::OperationCancelled => Self::OperationCancelled, + Fido2CallbackError::Unknown { reason } => Self::Unknown(reason), + } + } +} + #[uniffi::export(with_foreign)] #[async_trait::async_trait] -pub trait UserInterface: Send + Sync { +pub trait Fido2UserInterface: Send + Sync { async fn check_user( &self, options: CheckUserOptions, - credential: Option, - ) -> Result; + hint: UIHint, + ) -> Result; async fn pick_credential_for_authentication( &self, - available_credentials: Vec, - ) -> Result; - async fn pick_credential_for_creation( + available_credentials: Vec, + ) -> Result; + async fn check_user_and_pick_credential_for_creation( &self, - available_credentials: Vec, - new_credential: Fido2Credential, - ) -> Result; + options: CheckUserOptions, + new_credential: Fido2CredentialNewView, + ) -> Result; + async fn is_verification_enabled(&self) -> bool; } #[uniffi::export(with_foreign)] #[async_trait::async_trait] -pub trait CredentialStore: Send + Sync { +pub trait Fido2CredentialStore: Send + Sync { async fn find_credentials( &self, ids: Option>>, rip_id: String, - ) -> Result>; + ) -> Result, Fido2CallbackError>; - async fn save_credential(&self, cred: Cipher) -> Result<()>; + async fn save_credential(&self, cred: Cipher) -> Result<(), Fido2CallbackError>; } // Because uniffi doesn't support external traits, we have to make a copy of the trait here. @@ -190,19 +220,21 @@ pub trait CredentialStore: Send + Sync { struct UniffiTraitBridge(T); #[async_trait::async_trait] -impl bitwarden::platform::fido2::CredentialStore for UniffiTraitBridge<&dyn CredentialStore> { +impl bitwarden::platform::fido2::Fido2CredentialStore + for UniffiTraitBridge<&dyn Fido2CredentialStore> +{ async fn find_credentials( &self, ids: Option>>, rip_id: String, - ) -> BitResult> { + ) -> Result, BitFido2CallbackError> { self.0 .find_credentials(ids, rip_id) .await .map_err(Into::into) } - async fn save_credential(&self, cred: Cipher) -> BitResult<()> { + async fn save_credential(&self, cred: Cipher) -> Result<(), BitFido2CallbackError> { self.0.save_credential(cred).await.map_err(Into::into) } } @@ -216,15 +248,49 @@ pub struct CipherViewWrapper { cipher: CipherView, } +#[derive(uniffi::Enum)] +pub enum UIHint { + InformExcludedCredentialFound(CipherView), + InformNoCredentialsFound, + RequestNewCredential(PublicKeyCredentialUserEntity, PublicKeyCredentialRpEntity), + RequestExistingCredential(CipherView), +} + +impl From> for UIHint { + fn from(hint: bitwarden::platform::fido2::UIHint<'_, CipherView>) -> Self { + use bitwarden::platform::fido2::UIHint as BWUIHint; + match hint { + BWUIHint::InformExcludedCredentialFound(cipher) => { + UIHint::InformExcludedCredentialFound(cipher.clone()) + } + BWUIHint::InformNoCredentialsFound => UIHint::InformNoCredentialsFound, + BWUIHint::RequestNewCredential(user, rp) => UIHint::RequestNewCredential( + PublicKeyCredentialUserEntity { + id: user.id.clone().into(), + name: user.name.clone().unwrap_or_default(), + display_name: user.display_name.clone().unwrap_or_default(), + }, + PublicKeyCredentialRpEntity { + id: rp.id.clone(), + name: rp.name.clone(), + }, + ), + BWUIHint::RequestExistingCredential(cipher) => { + UIHint::RequestExistingCredential(cipher.clone()) + } + } + } +} + #[async_trait::async_trait] -impl bitwarden::platform::fido2::UserInterface for UniffiTraitBridge<&dyn UserInterface> { - async fn check_user( +impl bitwarden::platform::fido2::Fido2UserInterface for UniffiTraitBridge<&dyn Fido2UserInterface> { + async fn check_user<'a>( &self, options: CheckUserOptions, - credential: Option, - ) -> BitResult { + hint: bitwarden::platform::fido2::UIHint<'a, CipherView>, + ) -> Result { self.0 - .check_user(options, credential) + .check_user(options.clone(), hint.into()) .await .map(|r| bitwarden::platform::fido2::CheckUserResult { user_present: r.user_present, @@ -234,23 +300,26 @@ impl bitwarden::platform::fido2::UserInterface for UniffiTraitBridge<&dyn UserIn } async fn pick_credential_for_authentication( &self, - available_credentials: Vec, - ) -> BitResult { + available_credentials: Vec, + ) -> Result { self.0 .pick_credential_for_authentication(available_credentials) .await .map(|v| v.cipher) .map_err(Into::into) } - async fn pick_credential_for_creation( + async fn check_user_and_pick_credential_for_creation( &self, - available_credentials: Vec, - new_credential: Fido2Credential, - ) -> BitResult { + options: CheckUserOptions, + new_credential: Fido2CredentialNewView, + ) -> Result { self.0 - .pick_credential_for_creation(available_credentials, new_credential) + .check_user_and_pick_credential_for_creation(options, new_credential) .await .map(|v| v.cipher) .map_err(Into::into) } + async fn is_verification_enabled(&self) -> bool { + self.0.is_verification_enabled().await + } } diff --git a/crates/bitwarden/Cargo.toml b/crates/bitwarden/Cargo.toml index 178fca99a..8bfafc462 100644 --- a/crates/bitwarden/Cargo.toml +++ b/crates/bitwarden/Cargo.toml @@ -27,6 +27,9 @@ uniffi = [ "bitwarden-crypto/uniffi", "bitwarden-generators/uniffi", "dep:uniffi", + "dep:passkey", + "dep:coset", + "dep:p256", ] # Uniffi bindings secrets = [] # Secrets manager API wasm-bindgen = ["chrono/wasmbind"] @@ -44,11 +47,13 @@ chrono = { version = ">=0.4.26, <0.5", features = [ "serde", "std", ], default-features = false } +coset = { version = "0.3.7", optional = true } # We don't use this directly (it's used by rand), but we need it here to enable WASM support getrandom = { version = ">=0.2.9, <0.3", features = ["js"] } hmac = ">=0.12.1, <0.13" log = ">=0.4.18, <0.5" -passkey = { git = "https://github.com/bitwarden/passkey-rs", rev = "12da886102707f87ad97e499c857c0857ece0b85" } +p256 = { version = ">=0.13.2, <0.14", optional = true } +passkey = { git = "https://github.com/bitwarden/passkey-rs", rev = "c48c2ddfd6b884b2d754432576c66cb2b1985a3a", optional = true } rand = ">=0.8.5, <0.9" reqwest = { version = ">=0.12, <0.13", features = [ "http2", diff --git a/crates/bitwarden/src/error.rs b/crates/bitwarden/src/error.rs index 0fb4a1fc5..bc06a7591 100644 --- a/crates/bitwarden/src/error.rs +++ b/crates/bitwarden/src/error.rs @@ -8,6 +8,7 @@ use bitwarden_api_identity::apis::Error as IdentityError; use bitwarden_exporters::ExportError; #[cfg(feature = "internal")] use bitwarden_generators::{PassphraseError, PasswordError, UsernameError}; +#[cfg(feature = "uniffi")] use passkey::client::WebauthnError; use reqwest::StatusCode; use thiserror::Error; @@ -69,17 +70,23 @@ pub enum Error { #[error(transparent)] ExportError(#[from] ExportError), + #[cfg(feature = "uniffi")] #[error("Webauthn error: {0:?}")] - WebauthnError(passkey::client::WebauthnError), + WebauthnError(WebauthnError), #[cfg(feature = "uniffi")] #[error("Uniffi callback error: {0}")] - UniffiCallback(#[from] uniffi::UnexpectedUniFFICallbackError), + UniffiCallbackError(#[from] uniffi::UnexpectedUniFFICallbackError), + + #[cfg(feature = "uniffi")] + #[error("Fido2 Callback error: {0:?}")] + Fido2CallbackError(#[from] crate::platform::fido2::Fido2CallbackError), #[error("Internal error: {0}")] Internal(Cow<'static, str>), } +#[cfg(feature = "uniffi")] impl From for Error { fn from(e: WebauthnError) -> Self { Self::WebauthnError(e) diff --git a/crates/bitwarden/src/platform/client_platform.rs b/crates/bitwarden/src/platform/client_platform.rs index c294e3337..733a86e72 100644 --- a/crates/bitwarden/src/platform/client_platform.rs +++ b/crates/bitwarden/src/platform/client_platform.rs @@ -1,7 +1,9 @@ +#[cfg(feature = "uniffi")] +use super::ClientFido2; use super::{ generate_fingerprint::{generate_fingerprint, generate_user_fingerprint}, - get_user_api_key, ClientFido2, FingerprintRequest, FingerprintResponse, - SecretVerificationRequest, UserApiKeyResponse, + get_user_api_key, FingerprintRequest, FingerprintResponse, SecretVerificationRequest, + UserApiKeyResponse, }; use crate::{error::Result, Client}; @@ -25,8 +27,7 @@ impl<'a> ClientPlatform<'a> { get_user_api_key(self.client, &input).await } - /// At the moment this is just a stub implementation that doesn't do anything. It's here to make - /// it possible to check the usability API on the native clients. + #[cfg(feature = "uniffi")] pub fn fido2(&'a mut self) -> ClientFido2<'a> { ClientFido2 { client: self.client, diff --git a/crates/bitwarden/src/platform/fido2/authenticator.rs b/crates/bitwarden/src/platform/fido2/authenticator.rs index eccb1ed12..69aae6f0c 100644 --- a/crates/bitwarden/src/platform/fido2/authenticator.rs +++ b/crates/bitwarden/src/platform/fido2/authenticator.rs @@ -1,30 +1,35 @@ -#![allow(dead_code, unused_mut, unused_imports, unused_variables)] +use std::sync::Mutex; -use bitwarden_crypto::{EncString, KeyEncryptable}; -use chrono::DateTime; +use bitwarden_crypto::KeyEncryptable; +use log::error; use passkey::{ - authenticator::{Authenticator, UserCheck}, + authenticator::{Authenticator, DiscoverabilitySupport, StoreInfo, UIHint, UserCheck}, types::{ - ctap2::{make_credential::Request, Aaguid, Ctap2Error, StatusCode}, + ctap2::{self, Ctap2Error, StatusCode, VendorError}, Passkey, }, }; -use uuid::Uuid; -use super::{CredentialStore, SelectedCredential, UserInterface}; +use super::{ + types::*, CheckUserOptions, CheckUserResult, CipherViewContainer, Fido2CredentialStore, + Fido2UserInterface, SelectedCredential, AAGUID, +}; use crate::{ - error::{Error, Result}, + error::{require, Error, Result}, + platform::fido2::string_to_guid_bytes, vault::{ - login::{Fido2CredentialView, LoginView}, - CipherView, Fido2Credential, + login::Fido2CredentialView, CipherView, Fido2CredentialFullView, Fido2CredentialNewView, }, Client, }; pub struct Fido2Authenticator<'a> { pub(crate) client: &'a mut Client, - pub(crate) user_interface: &'a dyn UserInterface, - pub(crate) credential_store: &'a dyn CredentialStore, + pub(crate) user_interface: &'a dyn Fido2UserInterface, + pub(crate) credential_store: &'a dyn Fido2CredentialStore, + + pub(crate) selected_credential: Mutex>, + pub(crate) requested_uv: Mutex>, } impl<'a> Fido2Authenticator<'a> { @@ -32,17 +37,16 @@ impl<'a> Fido2Authenticator<'a> { &mut self, request: MakeCredentialRequest, ) -> Result { - // TODO: Placeholder value - let my_aaguid = Aaguid::new_empty(); + // Insert the received UV to be able to return it later in check_user + self.requested_uv + .get_mut() + .expect("Mutex is not poisoned") + .replace(request.options.uv); - let mut authenticator = Authenticator::new( - my_aaguid, - self.to_credential_store(), - self.to_user_interface(), - ); + let mut authenticator = self.get_authenticator(true); - /*let response = authenticator - .make_credential(Request { + let response = authenticator + .make_credential(ctap2::make_credential::Request { client_data_hash: request.client_data_hash.into(), rp: passkey::types::ctap2::make_credential::PublicKeyCredentialRpEntity { id: request.rp.id, @@ -56,31 +60,20 @@ impl<'a> Fido2Authenticator<'a> { pub_key_cred_params: request .pub_key_cred_params .into_iter() - .map( - |x| passkey::types::webauthn::PublicKeyCredentialParameters { - ty: todo!(), - alg: todo!(), - }, - ) - .collect(), + .map(TryInto::try_into) + .collect::>()?, exclude_list: request .exclude_list - .map(|x: Vec| { - x.into_iter() - .map( - |x| passkey::types::webauthn::PublicKeyCredentialDescriptor { - ty: todo!(), - id: todo!(), - transports: None, - }, - ) - .collect() - }), - extensions: None, // TODO: request.extensions, + .map(|x| x.into_iter().map(TryInto::try_into).collect()) + .transpose()?, + extensions: request + .extensions + .map(|e| serde_json::from_str(&e)) + .transpose()?, options: passkey::types::ctap2::make_credential::Options { - rk: true, + rk: request.options.rk, up: true, - uv: true, + uv: self.convert_requested_uv(request.options.uv).await, }, pin_auth: None, pin_protocol: None, @@ -92,17 +85,17 @@ impl<'a> Fido2Authenticator<'a> { Err(e) => return Err(format!("make_credential error: {e:?}").into()), }; - Ok(MakeCredentialResult { - credential_id: response - .auth_data - .attested_credential_data - .expect("Missing attested_credential_data") - .credential_id() - .to_vec(), - })*/ + let authenticator_data = response.auth_data.to_vec(); + let attested_credential_data = response + .auth_data + .attested_credential_data + .ok_or("Missing attested_credential_data")?; + let credential_id = attested_credential_data.credential_id().to_vec(); Ok(MakeCredentialResult { - credential_id: vec![], + authenticator_data, + attested_credential_data: attested_credential_data.into_iter().collect(), + credential_id, }) } @@ -110,105 +103,191 @@ impl<'a> Fido2Authenticator<'a> { &mut self, request: GetAssertionRequest, ) -> Result { - let enc = self.client.get_encryption_settings()?; - let key = enc.get_key(&None).ok_or(Error::VaultLocked)?; + // Insert the received UV to be able to return it later in check_user + self.requested_uv + .get_mut() + .expect("Mutex is not poisoned") + .replace(request.options.uv); - Ok(GetAssertionResult { - credential_id: vec![], - authenticator_data: vec![], - signature: vec![], - user_handle: vec![], - selected_credential: SelectedCredential { - cipher: CipherView { - id: Some(Uuid::new_v4()), - organization_id: None, - folder_id: None, - collection_ids: vec![], - key: None, - name: "".to_string(), - notes: Some("".to_string()), - r#type: crate::vault::CipherType::Login, - login: Some(LoginView { - username: None, - password: None, - password_revision_date: None, - uris: None, - totp: None, - autofill_on_page_load: None, - fido2_credentials: Some(vec![]), - }), - identity: None, - card: None, - secure_note: None, - favorite: false, - reprompt: crate::vault::CipherRepromptType::None, - organization_use_totp: true, - edit: true, - view_password: true, - local_data: None, - attachments: Some(vec![]), - fields: Some(vec![]), - password_history: Some(vec![]), - creation_date: chrono::offset::Utc::now(), - deleted_date: None, - revision_date: chrono::offset::Utc::now(), - }, - credential: Fido2Credential { - credential_id: "".to_owned().encrypt_with_key(key)?, - key_type: "".to_owned().encrypt_with_key(key)?, - key_algorithm: "".to_owned().encrypt_with_key(key)?, - key_curve: "".to_owned().encrypt_with_key(key)?, - key_value: "".to_owned().encrypt_with_key(key)?, - rp_id: "".to_owned().encrypt_with_key(key)?, - user_handle: Some("".to_owned().encrypt_with_key(key)?), - user_name: Some("".to_owned().encrypt_with_key(key)?), - counter: "".to_owned().encrypt_with_key(key)?, - rp_name: Some("".to_owned().encrypt_with_key(key)?), - user_display_name: Some("".to_owned().encrypt_with_key(key)?), - discoverable: "".to_owned().encrypt_with_key(key)?, - creation_date: chrono::offset::Utc::now(), + let mut authenticator = self.get_authenticator(false); + + let response = authenticator + .get_assertion(ctap2::get_assertion::Request { + rp_id: request.rp_id, + client_data_hash: request.client_data_hash.into(), + allow_list: request + .allow_list + .map(|l| { + l.into_iter() + .map(TryInto::try_into) + .collect::, _>>() + }) + .transpose()?, + extensions: request + .extensions + .map(|e| serde_json::from_str(&e)) + .transpose()?, + options: passkey::types::ctap2::make_credential::Options { + rk: request.options.rk, + up: true, + uv: self.convert_requested_uv(request.options.uv).await, }, - }, + pin_auth: None, + pin_protocol: None, + }) + .await; + + let response = match response { + Ok(x) => x, + Err(e) => return Err(format!("get_assertion error: {e:?}").into()), + }; + + let authenticator_data = response.auth_data.to_vec(); + let credential_id = response + .auth_data + .attested_credential_data + .ok_or("Missing attested_credential_data")? + .credential_id() + .to_vec(); + + Ok(GetAssertionResult { + credential_id, + authenticator_data, + signature: response.signature.into(), + user_handle: response.user.ok_or("Missing user")?.id.into(), + selected_credential: self.get_selected_credential()?, }) } - // TODO: Fido2CredentialView contains all the fields, maybe we need a Fido2CredentialListView? pub async fn silently_discover_credentials( &mut self, rp_id: String, ) -> Result> { - Ok(vec![]) + let result = self.credential_store.find_credentials(None, rp_id).await?; + Ok(result + .into_iter() + .filter_map(|c| c.login?.fido2_credentials) + .flatten() + .collect()) } - pub(crate) fn to_user_interface(&'a self) -> UserInterfaceImpl<'_> { - UserInterfaceImpl { - authenticator: self, - } + pub(super) fn get_authenticator( + &self, + create_credential: bool, + ) -> Authenticator { + Authenticator::new( + AAGUID, + CredentialStoreImpl { + authenticator: self, + create_credential, + }, + UserValidationMethodImpl { + authenticator: self, + }, + ) } - pub(crate) fn to_credential_store(&'a self) -> CredentialStoreImpl<'_> { - CredentialStoreImpl { - authenticator: self, + + async fn convert_requested_uv(&self, uv: UV) -> bool { + let verification_enabled = self.user_interface.is_verification_enabled().await; + match (uv, verification_enabled) { + (UV::Preferred, true) => true, + (UV::Preferred, false) => false, + (UV::Required, _) => true, + (UV::Discouraged, _) => false, } } + + pub(super) fn get_selected_credential(&self) -> Result { + let cipher = self + .selected_credential + .lock() + .expect("Mutex is not poisoned") + .clone() + .ok_or("No selected credential available")?; + + let login = require!(cipher.login.as_ref()); + let creds = require!(login.fido2_credentials.as_ref()); + + let credential = creds.first().ok_or("No Fido2 credentials found")?.clone(); + + Ok(SelectedCredential { cipher, credential }) + } } -pub(crate) struct CredentialStoreImpl<'a> { +pub(super) struct CredentialStoreImpl<'a> { authenticator: &'a Fido2Authenticator<'a>, + create_credential: bool, } -pub(crate) struct UserInterfaceImpl<'a> { +pub(super) struct UserValidationMethodImpl<'a> { authenticator: &'a Fido2Authenticator<'a>, } #[async_trait::async_trait] impl passkey::authenticator::CredentialStore for CredentialStoreImpl<'_> { - type PasskeyItem = CipherView; - + type PasskeyItem = CipherViewContainer; async fn find_credentials( &self, ids: Option<&[passkey::types::webauthn::PublicKeyCredentialDescriptor]>, rp_id: &str, ) -> Result, StatusCode> { - Ok(vec![]) + // This is just a wrapper around the actual implementation to allow for ? error handling + async fn inner( + this: &CredentialStoreImpl<'_>, + ids: Option<&[passkey::types::webauthn::PublicKeyCredentialDescriptor]>, + rp_id: &str, + ) -> Result> { + let ids: Option>> = + ids.map(|ids| ids.iter().map(|id| id.id.clone().into()).collect()); + + let ciphers = this + .authenticator + .credential_store + .find_credentials(ids, rp_id.to_string()) + .await?; + + let enc = this.authenticator.client.get_encryption_settings()?; + + // Remove any that don't have Fido2 credentials + let creds: Vec<_> = ciphers + .into_iter() + .filter(|c| { + c.login + .as_ref() + .and_then(|l| l.fido2_credentials.as_ref()) + .is_some() + }) + .collect(); + + // When using the credential for authentication we have to ask the user to pick one. + if this.create_credential { + creds + .into_iter() + .map(|c| CipherViewContainer::new(c, enc)) + .collect() + } else { + let picked = this + .authenticator + .user_interface + .pick_credential_for_authentication(creds) + .await?; + + // Store the selected credential for later use + this.authenticator + .selected_credential + .lock() + .expect("Mutex is not poisoned") + .replace(picked.clone()); + + Ok(vec![CipherViewContainer::new(picked, enc)?]) + } + } + + inner(self, ids, rp_id).await.map_err(|e| { + error!("Error finding credentials: {e:?}"); + VendorError::try_from(0xF0) + .expect("Valid vendor error code") + .into() + }) } async fn save_credential( @@ -216,118 +295,192 @@ impl passkey::authenticator::CredentialStore for CredentialStoreImpl<'_> { cred: Passkey, user: passkey::types::ctap2::make_credential::PublicKeyCredentialUserEntity, rp: passkey::types::ctap2::make_credential::PublicKeyCredentialRpEntity, + _options: passkey::types::ctap2::get_assertion::Options, ) -> Result<(), StatusCode> { - Ok(()) + // This is just a wrapper around the actual implementation to allow for ? error handling + async fn inner( + this: &mut CredentialStoreImpl<'_>, + cred: Passkey, + user: passkey::types::ctap2::make_credential::PublicKeyCredentialUserEntity, + rp: passkey::types::ctap2::make_credential::PublicKeyCredentialRpEntity, + ) -> Result<()> { + let enc = this.authenticator.client.get_encryption_settings()?; + + let cred = Fido2CredentialFullView::try_from_credential(cred, user, rp)?; + + // Get the previously selected cipher and add the new credential to it + let mut selected: CipherView = this.authenticator.get_selected_credential()?.cipher; + selected.set_new_fido2_credentials(enc, vec![cred])?; + + // Store the updated credential for later use + this.authenticator + .selected_credential + .lock() + .expect("Mutex is not poisoned") + .replace(selected.clone()); + + // Encrypt the updated cipher before sending it to the clients to be stored + let key = enc + .get_key(&selected.organization_id) + .ok_or(Error::VaultLocked)?; + let encrypted = selected.encrypt_with_key(key)?; + + this.authenticator + .credential_store + .save_credential(encrypted) + .await?; + + Ok(()) + } + + inner(self, cred, user, rp).await.map_err(|e| { + error!("Error saving credential: {e:?}"); + VendorError::try_from(0xF1) + .expect("Valid vendor error code") + .into() + }) } async fn update_credential(&mut self, cred: Passkey) -> Result<(), StatusCode> { - Ok(()) + // This is just a wrapper around the actual implementation to allow for ? error handling + async fn inner(this: &mut CredentialStoreImpl<'_>, cred: Passkey) -> Result<()> { + let enc = this.authenticator.client.get_encryption_settings()?; + + // Get the previously selected cipher and update the credential + let selected = this.authenticator.get_selected_credential()?; + + // Check that the provided credential ID matches the selected credential + let new_id: &Vec = &cred.credential_id; + let selected_id = string_to_guid_bytes(&selected.credential.credential_id)?; + if new_id != &selected_id { + return Err("Credential ID does not match selected credential".into()); + } + + let cred = selected.credential.fill_with_credential(cred)?; + + let mut selected = selected.cipher; + selected.set_new_fido2_credentials(enc, vec![cred])?; + + // Store the updated credential for later use + this.authenticator + .selected_credential + .lock() + .expect("Mutex is not poisoned") + .replace(selected.clone()); + + // Encrypt the updated cipher before sending it to the clients to be stored + let key = enc + .get_key(&selected.organization_id) + .ok_or(Error::VaultLocked)?; + let encrypted = selected.encrypt_with_key(key)?; + + this.authenticator + .credential_store + .save_credential(encrypted) + .await?; + + Ok(()) + } + + inner(self, cred).await.map_err(|e| { + error!("Error updating credential: {e:?}"); + VendorError::try_from(0xF2) + .expect("Valid vendor error code") + .into() + }) + } + + async fn get_info(&self) -> StoreInfo { + StoreInfo { + discoverability: DiscoverabilitySupport::Full, + } } } #[async_trait::async_trait] -impl passkey::authenticator::UserValidationMethod for UserInterfaceImpl<'_> { - type PasskeyItem = CipherView; +impl passkey::authenticator::UserValidationMethod for UserValidationMethodImpl<'_> { + type PasskeyItem = CipherViewContainer; - async fn check_user( + async fn check_user<'a>( &self, - credential: Option, + hint: UIHint<'a, Self::PasskeyItem>, presence: bool, - verification: bool, + _verification: bool, ) -> Result { + let verification = self + .authenticator + .requested_uv + .lock() + .expect("Mutex is not poisoned") + .ok_or(Ctap2Error::UserVerificationInvalid)?; + + let options = CheckUserOptions { + require_presence: presence, + require_verification: verification.into(), + }; + + let result = match hint { + UIHint::RequestNewCredential(user, rp) => { + let new_credential = Fido2CredentialNewView::try_from_credential(user, rp) + .map_err(|_| Ctap2Error::InvalidCredential)?; + + let cipher_view = self + .authenticator + .user_interface + .check_user_and_pick_credential_for_creation(options, new_credential) + .await + .map_err(|_| Ctap2Error::OperationDenied)?; + + self.authenticator + .selected_credential + .lock() + .expect("Mutex is not poisoned") + .replace(cipher_view); + + Ok(CheckUserResult { + user_present: true, + user_verified: verification != UV::Discouraged, + }) + } + _ => { + self.authenticator + .user_interface + .check_user(options, map_ui_hint(hint)) + .await + } + }; + + let result = result.map_err(|e| { + error!("Error checking user: {e:?}"); + Ctap2Error::UserVerificationInvalid + })?; + Ok(UserCheck { - presence, - verification, + presence: result.user_present, + verification: result.user_verified, }) } - fn is_presence_enabled(&self) -> bool { + async fn is_presence_enabled(&self) -> bool { true } - fn is_verification_enabled(&self) -> Option { - Some(true) + async fn is_verification_enabled(&self) -> Option { + Some( + self.authenticator + .user_interface + .is_verification_enabled() + .await, + ) } } -// What type do we need this to be? We probably can't use Serialize over the FFI boundary -pub type Extensions = Option>; - -#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] -pub struct PublicKeyCredentialRpEntity { - pub id: String, - pub name: Option, -} - -#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] -pub struct PublicKeyCredentialUserEntity { - pub id: Vec, - pub display_name: String, - pub name: String, -} - -#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] -pub struct PublicKeyCredentialParameters { - pub ty: String, - pub alg: i64, -} - -#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] -pub struct PublicKeyCredentialDescriptor { - pub ty: i64, - pub id: Vec, -} - -#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] -pub struct MakeCredentialRequest { - client_data_hash: Vec, - rp: PublicKeyCredentialRpEntity, - user: PublicKeyCredentialUserEntity, - pub_key_cred_params: Vec, - exclude_list: Option>, - require_resident_key: bool, - extensions: Extensions, -} - -#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] -pub struct MakeCredentialResult { - // TODO - // authenticator_data: Vec, - // attested_credential_data: Vec, - credential_id: Vec, -} - -#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] -pub struct GetAssertionRequest { - rp_id: String, - client_data_hash: Vec, - allow_list: Option>, - options: Options, - extensions: Extensions, -} - -#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] -pub struct Options { - rk: bool, - uv: UV, -} - -#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] -pub enum UV { - Discouraged, - Preferred, - Required, -} - -#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] -pub struct GetAssertionResult { - credential_id: Vec, - authenticator_data: Vec, - signature: Vec, - user_handle: Vec, - /** - * SDK IMPL NOTE: This is not part of the spec and is not returned by passkey-rs. - * The SDK needs to add this after the response from passkey-rs is received. - */ - selected_credential: SelectedCredential, +fn map_ui_hint(hint: UIHint<'_, CipherViewContainer>) -> UIHint<'_, CipherView> { + use UIHint::*; + match hint { + InformExcludedCredentialFound(c) => InformExcludedCredentialFound(&c.cipher), + InformNoCredentialsFound => InformNoCredentialsFound, + RequestNewCredential(u, r) => RequestNewCredential(u, r), + RequestExistingCredential(c) => RequestExistingCredential(&c.cipher), + } } diff --git a/crates/bitwarden/src/platform/fido2/client.rs b/crates/bitwarden/src/platform/fido2/client.rs index 626d9abcb..f2f5703cf 100644 --- a/crates/bitwarden/src/platform/fido2/client.rs +++ b/crates/bitwarden/src/platform/fido2/client.rs @@ -1,18 +1,15 @@ -#![allow(dead_code, unused_variables)] - -use std::collections::HashMap; - -use bitwarden_crypto::KeyEncryptable; -use passkey::{authenticator::Authenticator, types::ctap2::Aaguid}; use reqwest::Url; -use serde::Serialize; -use uuid::Uuid; -use super::{Fido2Authenticator, SelectedCredential}; -use crate::{ - error::{Error, Result}, - vault::{login::LoginView, CipherView, Fido2Credential}, +use super::{ + get_string_name_from_enum, + types::{ + AuthenticatorAssertionResponse, AuthenticatorAttestationResponse, ClientData, + ClientExtensionResults, CredPropsResult, + }, + Fido2Authenticator, PublicKeyCredentialAuthenticatorAssertionResponse, + PublicKeyCredentialAuthenticatorAttestationResponse, }; +use crate::error::Result; pub struct Fido2Client<'a> { pub(crate) authenticator: Fido2Authenticator<'a>, @@ -25,291 +22,102 @@ impl<'a> Fido2Client<'a> { request: String, client_data: ClientData, ) -> Result { - // TODO: Placeholder value - let my_aaguid = Aaguid::new_empty(); + let origin = Url::parse(&origin).map_err(|e| format!("Invalid origin: {}", e))?; + + let request: passkey::types::webauthn::CredentialCreationOptions = + serde_json::from_str(&request)?; - let authenticator = Authenticator::new( - my_aaguid, - self.authenticator.to_credential_store(), - self.authenticator.to_user_interface(), - ); - let mut client = passkey::client::Client::new(authenticator); + // Insert the received UV to be able to return it later in check_user + let uv = request + .public_key + .authenticator_selection + .as_ref() + .map(|s| s.user_verification.into()); + *self + .authenticator + .requested_uv + .get_mut() + .expect("Mutex is not poisoned") = uv; - let origin = Url::parse(&origin).expect("Invalid URL"); + let rp_id = request.public_key.rp.id.clone(); - let result = client - .register(&origin, serde_json::from_str(&request)?, client_data) - .await?; + let mut client = passkey::client::Client::new(self.authenticator.get_authenticator(true)); + let result = client.register(&origin, request, client_data).await?; - /*Ok(PublicKeyCredentialAuthenticatorAttestationResponse { + Ok(PublicKeyCredentialAuthenticatorAttestationResponse { id: result.id, raw_id: result.raw_id.into(), - ty: "public-key".to_string(), - authenticator_attachment: todo!(), - client_extension_results: todo!(), + ty: get_string_name_from_enum(result.ty)?, + authenticator_attachment: result + .authenticator_attachment + .map(get_string_name_from_enum) + .transpose()?, + client_extension_results: ClientExtensionResults { + cred_props: result.client_extension_results.cred_props.map(Into::into), + }, response: AuthenticatorAttestationResponse { client_data_json: result.response.client_data_json.into(), authenticator_data: result.response.authenticator_data.into(), public_key: result.response.public_key.map(|x| x.into()), public_key_algorithm: result.response.public_key_algorithm, attestation_object: result.response.attestation_object.into(), - transports: todo!(), - }, - selected_credential: SelectedCredential { - cipher: todo!(), - credential: todo!(), - }, - })*/ - let enc = self.authenticator.client.get_encryption_settings()?; - let key = enc.get_key(&None).ok_or(Error::VaultLocked)?; - - Ok(PublicKeyCredentialAuthenticatorAttestationResponse { - id: String::new(), - raw_id: vec![], - ty: "public-key".to_string(), - authenticator_attachment: String::new(), - client_extension_results: HashMap::new(), - response: AuthenticatorAttestationResponse { - client_data_json: vec![], - authenticator_data: vec![], - public_key: None, - public_key_algorithm: 0, - attestation_object: vec![], - transports: None, - }, - selected_credential: SelectedCredential { - cipher: CipherView { - id: Some(Uuid::new_v4()), - organization_id: None, - folder_id: None, - collection_ids: vec![], - key: None, - name: "".to_string(), - notes: Some("".to_string()), - r#type: crate::vault::CipherType::Login, - login: Some(LoginView { - username: None, - password: None, - password_revision_date: None, - uris: None, - totp: None, - autofill_on_page_load: None, - fido2_credentials: Some(vec![]), - }), - identity: None, - card: None, - secure_note: None, - favorite: false, - reprompt: crate::vault::CipherRepromptType::None, - organization_use_totp: true, - edit: true, - view_password: true, - local_data: None, - attachments: Some(vec![]), - fields: Some(vec![]), - password_history: Some(vec![]), - creation_date: chrono::offset::Utc::now(), - deleted_date: None, - revision_date: chrono::offset::Utc::now(), - }, - credential: Fido2Credential { - credential_id: "".to_owned().encrypt_with_key(key)?, - key_type: "".to_owned().encrypt_with_key(key)?, - key_algorithm: "".to_owned().encrypt_with_key(key)?, - key_curve: "".to_owned().encrypt_with_key(key)?, - key_value: "".to_owned().encrypt_with_key(key)?, - rp_id: "".to_owned().encrypt_with_key(key)?, - user_handle: Some("".to_owned().encrypt_with_key(key)?), - user_name: Some("".to_owned().encrypt_with_key(key)?), - counter: "".to_owned().encrypt_with_key(key)?, - rp_name: Some("".to_owned().encrypt_with_key(key)?), - user_display_name: Some("".to_owned().encrypt_with_key(key)?), - discoverable: "".to_owned().encrypt_with_key(key)?, - creation_date: chrono::offset::Utc::now(), + transports: if rp_id.unwrap_or_default() == "https://google.com" { + Some(vec!["internal".to_string(), "usb".to_string()]) + } else { + Some(vec!["internal".to_string()]) }, }, + selected_credential: self.authenticator.get_selected_credential()?, }) } + pub async fn authenticate( &mut self, origin: String, request: String, client_data: ClientData, ) -> Result { - // TODO: Placeholder value - let my_aaguid = Aaguid::new_empty(); + let origin = Url::parse(&origin).map_err(|e| format!("Invalid origin: {}", e))?; - let authenticator = Authenticator::new( - my_aaguid, - self.authenticator.to_credential_store(), - self.authenticator.to_user_interface(), - ); - let mut client = passkey::client::Client::new(authenticator); + let request: passkey::types::webauthn::CredentialRequestOptions = + serde_json::from_str(&request)?; - let origin = Url::parse(&origin).expect("Invalid URL"); + // Insert the received UV to be able to return it later in check_user + let uv = request.public_key.user_verification.into(); + self.authenticator + .requested_uv + .get_mut() + .expect("Mutex is not poisoned") + .replace(uv); - let result = client - .authenticate(&origin, serde_json::from_str(&request)?, client_data) - .await?; + let mut client = passkey::client::Client::new(self.authenticator.get_authenticator(false)); + let result = client.authenticate(&origin, request, client_data).await?; - /*Ok(PublicKeyCredentialAuthenticatorAssertionResponse { + Ok(PublicKeyCredentialAuthenticatorAssertionResponse { id: result.id, raw_id: result.raw_id.into(), - ty: "public-key".to_string(), - authenticator_attachment: todo!(), - client_extension_results: todo!(), + ty: get_string_name_from_enum(result.ty)?, + + authenticator_attachment: result + .authenticator_attachment + .map(get_string_name_from_enum) + .transpose()?, + client_extension_results: ClientExtensionResults { + cred_props: result + .client_extension_results + .cred_props + .map(|c| CredPropsResult { + rk: c.discoverable, + authenticator_display_name: c.authenticator_display_name, + }), + }, response: AuthenticatorAssertionResponse { client_data_json: result.response.client_data_json.into(), authenticator_data: result.response.authenticator_data.into(), signature: result.response.signature.into(), user_handle: result.response.user_handle.unwrap_or_default().into(), }, - selected_credential: SelectedCredential { - cipher: todo!(), - credential: todo!(), - }, - })*/ - - let enc = self.authenticator.client.get_encryption_settings()?; - let key = enc.get_key(&None).ok_or(Error::VaultLocked)?; - - Ok(PublicKeyCredentialAuthenticatorAssertionResponse { - id: String::new(), - raw_id: vec![], - ty: "public-key".to_string(), - authenticator_attachment: String::new(), - client_extension_results: HashMap::new(), - response: AuthenticatorAssertionResponse { - client_data_json: vec![], - authenticator_data: vec![], - signature: vec![], - user_handle: vec![], - }, - selected_credential: SelectedCredential { - cipher: CipherView { - id: Some(Uuid::new_v4()), - organization_id: None, - folder_id: None, - collection_ids: vec![], - key: None, - name: "".to_string(), - notes: Some("".to_string()), - r#type: crate::vault::CipherType::Login, - login: Some(LoginView { - username: None, - password: None, - password_revision_date: None, - uris: None, - totp: None, - autofill_on_page_load: None, - fido2_credentials: Some(vec![]), - }), - identity: None, - card: None, - secure_note: None, - favorite: false, - reprompt: crate::vault::CipherRepromptType::None, - organization_use_totp: true, - edit: true, - view_password: true, - local_data: None, - attachments: Some(vec![]), - fields: Some(vec![]), - password_history: Some(vec![]), - creation_date: chrono::offset::Utc::now(), - deleted_date: None, - revision_date: chrono::offset::Utc::now(), - }, - credential: Fido2Credential { - credential_id: "".to_owned().encrypt_with_key(key)?, - key_type: "".to_owned().encrypt_with_key(key)?, - key_algorithm: "".to_owned().encrypt_with_key(key)?, - key_curve: "".to_owned().encrypt_with_key(key)?, - key_value: "".to_owned().encrypt_with_key(key)?, - rp_id: "".to_owned().encrypt_with_key(key)?, - user_handle: Some("".to_owned().encrypt_with_key(key)?), - user_name: Some("".to_owned().encrypt_with_key(key)?), - counter: "".to_owned().encrypt_with_key(key)?, - rp_name: Some("".to_owned().encrypt_with_key(key)?), - user_display_name: Some("".to_owned().encrypt_with_key(key)?), - discoverable: "".to_owned().encrypt_with_key(key)?, - creation_date: chrono::offset::Utc::now(), - }, - }, + selected_credential: self.authenticator.get_selected_credential()?, }) } } - -#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] -pub enum ClientData { - DefaultWithExtraData { android_package_name: String }, - DefaultWithCustomHash { hash: Vec }, -} - -#[derive(Serialize, Clone)] -struct AndroidClientData { - android_package_name: String, -} - -// TODO: I'm implementing this to convert from a basic enum into the generic -// passkey::client::ClientData Not fully sure that it's correct to return None for extra_client_data -// instead of () -impl passkey::client::ClientData> for ClientData { - fn extra_client_data(&self) -> Option { - match self { - ClientData::DefaultWithExtraData { - android_package_name, - } => Some(AndroidClientData { - android_package_name: android_package_name.clone(), - }), - ClientData::DefaultWithCustomHash { .. } => None, - } - } - - fn client_data_hash(&self) -> Option> { - match self { - ClientData::DefaultWithExtraData { .. } => None, - ClientData::DefaultWithCustomHash { hash } => Some(hash.clone()), - } - } -} - -#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] -pub struct PublicKeyCredentialAuthenticatorAttestationResponse { - id: String, - raw_id: Vec, - ty: String, - authenticator_attachment: String, - client_extension_results: HashMap, - response: AuthenticatorAttestationResponse, - selected_credential: SelectedCredential, -} - -#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] -pub struct AuthenticatorAttestationResponse { - client_data_json: Vec, - authenticator_data: Vec, - public_key: Option>, - public_key_algorithm: i64, - attestation_object: Vec, - transports: Option>, -} - -#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] -pub struct PublicKeyCredentialAuthenticatorAssertionResponse { - id: String, - raw_id: Vec, - ty: String, - authenticator_attachment: String, - client_extension_results: HashMap, - response: AuthenticatorAssertionResponse, - selected_credential: SelectedCredential, -} - -#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] -pub struct AuthenticatorAssertionResponse { - client_data_json: Vec, - authenticator_data: Vec, - signature: Vec, - user_handle: Vec, -} diff --git a/crates/bitwarden/src/platform/fido2/crypto.rs b/crates/bitwarden/src/platform/fido2/crypto.rs new file mode 100644 index 000000000..a7fb5cff1 --- /dev/null +++ b/crates/bitwarden/src/platform/fido2/crypto.rs @@ -0,0 +1,37 @@ +use coset::{ + iana::{self}, + CoseKey, +}; +use p256::{pkcs8::EncodePrivateKey, SecretKey}; +use passkey::authenticator::{private_key_from_cose_key, CoseKeyPair}; + +use crate::error::{Error, Result}; + +pub fn cose_key_to_pkcs8(cose_key: &CoseKey) -> Result> { + // cose_key. + let secret_key = private_key_from_cose_key(cose_key).map_err(|error| { + log::error!("Failed to extract private key from cose_key: {:?}", error); + Error::Internal("Failed to extract private key from cose_key".into()) + })?; + + let vec = secret_key + .to_pkcs8_der() + .map_err(|error| { + log::error!("Failed to convert P256 private key to PKC8: {:?}", error); + Error::Internal("Failed to convert P256 private key to PKC8".into()) + })? + .as_bytes() + .to_vec(); + + Ok(vec) +} + +pub fn pkcs8_to_cose_key(secret_key: &[u8]) -> Result { + let secret_key = SecretKey::from_slice(secret_key).map_err(|error| { + log::error!("Failed to extract private key from secret_key: {:?}", error); + Error::Internal("Failed to extract private key from secret_key".into()) + })?; + + let cose_key_pair = CoseKeyPair::from_secret_key(&secret_key, iana::Algorithm::ES256); + Ok(cose_key_pair.private) +} diff --git a/crates/bitwarden/src/platform/fido2/mod.rs b/crates/bitwarden/src/platform/fido2/mod.rs index fbdd8cfd6..099ce4fa3 100644 --- a/crates/bitwarden/src/platform/fido2/mod.rs +++ b/crates/bitwarden/src/platform/fido2/mod.rs @@ -1,24 +1,42 @@ -use crate::{ - error::Result, - vault::{login::Fido2Credential, CipherView}, - Client, -}; +use std::sync::Mutex; + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; +use bitwarden_crypto::KeyContainer; +use passkey::types::{ctap2::Aaguid, Passkey}; mod authenticator; mod client; +mod crypto; mod traits; +mod types; -pub use authenticator::{ - Fido2Authenticator, GetAssertionRequest, GetAssertionResult, MakeCredentialRequest, - MakeCredentialResult, +pub use authenticator::Fido2Authenticator; +pub use client::Fido2Client; +pub use passkey::authenticator::UIHint; +pub use traits::{ + CheckUserOptions, CheckUserResult, Fido2CallbackError, Fido2CredentialStore, + Fido2UserInterface, Verification, }; -pub use client::{ - AuthenticatorAssertionResponse, AuthenticatorAttestationResponse, ClientData, Fido2Client, +pub use types::{ + AuthenticatorAssertionResponse, AuthenticatorAttestationResponse, ClientData, + GetAssertionRequest, GetAssertionResult, MakeCredentialRequest, MakeCredentialResult, Options, PublicKeyCredentialAuthenticatorAssertionResponse, - PublicKeyCredentialAuthenticatorAttestationResponse, + PublicKeyCredentialAuthenticatorAttestationResponse, PublicKeyCredentialRpEntity, + PublicKeyCredentialUserEntity, +}; + +use self::crypto::{cose_key_to_pkcs8, pkcs8_to_cose_key}; +use crate::{ + error::{Error, Result}, + vault::{CipherView, Fido2CredentialFullView, Fido2CredentialNewView, Fido2CredentialView}, + Client, }; -use passkey::types::Passkey; -pub use traits::{CheckUserOptions, CheckUserResult, CredentialStore, UserInterface, Verification}; + +// This is the AAGUID for the Bitwarden Passkey provider (d548826e-79b4-db40-a3d8-11116f7e8349) +// It is used for the Relaying Parties to identify the authenticator during registration +const AAGUID: Aaguid = Aaguid([ + 0xd5, 0x48, 0x82, 0x6e, 0x79, 0xb4, 0xdb, 0x40, 0xa3, 0xd8, 0x11, 0x11, 0x6f, 0x7e, 0x83, 0x49, +]); pub struct ClientFido2<'a> { #[allow(dead_code)] @@ -29,21 +47,23 @@ impl<'a> ClientFido2<'a> { pub fn create_authenticator( &'a mut self, - user_interface: &'a dyn UserInterface, - credential_store: &'a dyn CredentialStore, + user_interface: &'a dyn Fido2UserInterface, + credential_store: &'a dyn Fido2CredentialStore, ) -> Result> { Ok(Fido2Authenticator { client: self.client, user_interface, credential_store, + selected_credential: Mutex::new(None), + requested_uv: Mutex::new(None), }) } pub fn create_client( &'a mut self, - user_interface: &'a dyn UserInterface, - credential_store: &'a dyn CredentialStore, + user_interface: &'a dyn Fido2UserInterface, + credential_store: &'a dyn Fido2CredentialStore, ) -> Result> { Ok(Fido2Client { authenticator: self.create_authenticator(user_interface, credential_store)?, @@ -55,18 +75,202 @@ impl<'a> ClientFido2<'a> { #[cfg_attr(feature = "uniffi", derive(uniffi::Record))] pub struct SelectedCredential { cipher: CipherView, - credential: Fido2Credential, + credential: Fido2CredentialView, +} + +// This container is needed so we can properly implement the TryFrom trait for Passkey +// Otherwise we need to decrypt the Fido2 credentials every time we create a CipherView +#[derive(Clone)] +pub(crate) struct CipherViewContainer { + cipher: CipherView, + fido2_credentials: Vec, +} + +impl CipherViewContainer { + fn new(cipher: CipherView, enc: &dyn KeyContainer) -> Result { + let fido2_credentials = cipher.get_fido2_credentials(enc)?; + Ok(Self { + cipher, + fido2_credentials, + }) + } } -impl TryFrom for Passkey { +impl TryFrom for Passkey { type Error = crate::error::Error; - fn try_from(value: CipherView) -> std::prelude::v1::Result { - let _creds = value - .login - .and_then(|l| l.fido2_credentials) - .ok_or("No Fido2Credential")?; + fn try_from(value: CipherViewContainer) -> Result { + let cred = value + .fido2_credentials + .first() + .ok_or(Error::Internal("No Fido2 credentials found".into()))?; + + cred.clone().try_into() + } +} + +impl TryFrom for Passkey { + type Error = crate::error::Error; + + fn try_from(value: Fido2CredentialFullView) -> Result { + let counter: u32 = value.counter.parse().expect("Invalid counter"); + let counter = (counter != 0).then_some(counter); + + let key = pkcs8_to_cose_key(&value.key_value)?; + + Ok(Self { + key, + credential_id: string_to_guid_bytes(&value.credential_id)?.into(), + rp_id: value.rp_id.clone(), + user_handle: value.user_handle.map(|u| u.into()), + counter, + }) + } +} + +impl Fido2CredentialView { + pub(crate) fn fill_with_credential(&self, value: Passkey) -> Result { + let cred_id: Vec = value.credential_id.into(); + + Ok(Fido2CredentialFullView { + credential_id: guid_bytes_to_string(&cred_id)?, + key_type: "public-key".to_owned(), + key_algorithm: "ECDSA".to_owned(), + key_curve: "P-256".to_owned(), + key_value: cose_key_to_pkcs8(&value.key)?, + rp_id: value.rp_id, + rp_name: self.rp_name.clone(), + user_handle: Some(cred_id), + + counter: value.counter.unwrap_or(0).to_string(), + user_name: self.user_name.clone(), + user_display_name: self.user_display_name.clone(), + discoverable: "true".to_owned(), + creation_date: chrono::offset::Utc::now(), + }) + } +} + +impl Fido2CredentialNewView { + pub(crate) fn try_from_credential( + user: &passkey::types::ctap2::make_credential::PublicKeyCredentialUserEntity, + rp: &passkey::types::ctap2::make_credential::PublicKeyCredentialRpEntity, + ) -> Result { + let cred_id: Vec = vec![0; 16]; + + Ok(Fido2CredentialNewView { + credential_id: guid_bytes_to_string(&cred_id)?, + key_type: "public-key".to_owned(), + key_algorithm: "ECDSA".to_owned(), + key_curve: "P-256".to_owned(), + rp_id: rp.id.clone(), + rp_name: rp.name.clone(), + user_handle: Some(cred_id), + + counter: 0.to_string(), + user_name: user.name.clone(), + user_display_name: user.display_name.clone(), + discoverable: "true".to_owned(), + creation_date: chrono::offset::Utc::now(), + }) + } +} + +impl Fido2CredentialFullView { + pub(crate) fn try_from_credential( + value: Passkey, + user: passkey::types::ctap2::make_credential::PublicKeyCredentialUserEntity, + rp: passkey::types::ctap2::make_credential::PublicKeyCredentialRpEntity, + ) -> Result { + let cred_id: Vec = value.credential_id.into(); + + Ok(Fido2CredentialFullView { + credential_id: guid_bytes_to_string(&cred_id)?, + key_type: "public-key".to_owned(), + key_algorithm: "ECDSA".to_owned(), + key_curve: "P-256".to_owned(), + key_value: cose_key_to_pkcs8(&value.key)?, + rp_id: value.rp_id, + rp_name: rp.name, + user_handle: Some(cred_id), + + counter: value.counter.unwrap_or(0).to_string(), + user_name: user.name, + user_display_name: user.display_name, + discoverable: "true".to_owned(), + creation_date: chrono::offset::Utc::now(), + }) + } +} + +pub fn guid_bytes_to_string(source: &[u8]) -> Result { + if source.len() != 16 { + return Err(Error::Internal("Input should be a 16 byte array".into())); + } + Ok(uuid::Uuid::from_bytes(source.try_into().expect("Invalid length")).to_string()) +} + +pub fn string_to_guid_bytes(source: &str) -> Result> { + if source.starts_with("b64.") { + let bytes = URL_SAFE_NO_PAD.decode(source.trim_start_matches("b64."))?; + Ok(bytes) + } else { + let Ok(uuid) = uuid::Uuid::try_parse(source) else { + return Err(Error::Internal("Input should be a valid GUID".into())); + }; + Ok(uuid.as_bytes().to_vec()) + } +} + +// Some utilities to convert back and forth between enums and strings +fn get_enum_from_string_name(s: &str) -> Result { + let serialized = format!(r#""{}""#, s); + let deserialized: T = serde_json::from_str(&serialized)?; + Ok(deserialized) +} + +fn get_string_name_from_enum(s: impl serde::Serialize) -> Result { + let serialized = serde_json::to_string(&s)?; + let deserialized: String = serde_json::from_str(&serialized)?; + Ok(deserialized) +} + +#[cfg(test)] +mod tests { + use passkey::types::webauthn::AuthenticatorAttachment; + + use super::{get_enum_from_string_name, get_string_name_from_enum}; + + #[test] + fn test_enum_string_conversion_works_as_expected() { + assert_eq!( + get_string_name_from_enum(AuthenticatorAttachment::CrossPlatform).unwrap(), + "cross-platform" + ); + + assert_eq!( + get_enum_from_string_name::("cross-platform").unwrap(), + AuthenticatorAttachment::CrossPlatform + ); + } + + #[test] + fn string_to_guid_with_uuid_works() { + let uuid = "d548826e-79b4-db40-a3d8-11116f7e8349"; + let bytes = super::string_to_guid_bytes(uuid).unwrap(); + assert_eq!( + bytes, + vec![213, 72, 130, 110, 121, 180, 219, 64, 163, 216, 17, 17, 111, 126, 131, 73] + ); + } - todo!("We have more than one credential, we need to pick one?") + #[test] + fn string_to_guid_with_b64_works() { + let b64 = "b64.1UiCbnm020Cj2BERb36DSQ"; + let bytes = super::string_to_guid_bytes(b64).unwrap(); + assert_eq!( + bytes, + vec![213, 72, 130, 110, 121, 180, 219, 64, 163, 216, 17, 17, 111, 126, 131, 73] + ); } } diff --git a/crates/bitwarden/src/platform/fido2/traits.rs b/crates/bitwarden/src/platform/fido2/traits.rs index d27554e05..cc27e4bd6 100644 --- a/crates/bitwarden/src/platform/fido2/traits.rs +++ b/crates/bitwarden/src/platform/fido2/traits.rs @@ -1,42 +1,61 @@ +use passkey::authenticator::UIHint; +use thiserror::Error; + use crate::{ error::Result, - vault::{login::Fido2Credential, Cipher, CipherView}, + vault::{Cipher, CipherView, Fido2CredentialNewView}, }; +#[derive(Debug, Error)] +pub enum Fido2CallbackError { + #[error("The operation requires user interaction")] + UserInterfaceRequired, + + #[error("The operation was cancelled by the user")] + OperationCancelled, + + #[error("Unknown error: {0}")] + Unknown(String), +} + #[async_trait::async_trait] -pub trait UserInterface: Send + Sync { - async fn check_user( +pub trait Fido2UserInterface: Send + Sync { + async fn check_user<'a>( &self, options: CheckUserOptions, - credential: Option, - ) -> Result; + hint: UIHint<'a, CipherView>, + ) -> Result; async fn pick_credential_for_authentication( &self, - available_credentials: Vec, - ) -> Result; - async fn pick_credential_for_creation( + available_credentials: Vec, + ) -> Result; + async fn check_user_and_pick_credential_for_creation( &self, - available_credentials: Vec, - new_credential: Fido2Credential, - ) -> Result; + options: CheckUserOptions, + new_credential: Fido2CredentialNewView, + ) -> Result; + async fn is_verification_enabled(&self) -> bool; } #[async_trait::async_trait] -pub trait CredentialStore: Send + Sync { +pub trait Fido2CredentialStore: Send + Sync { async fn find_credentials( &self, ids: Option>>, rip_id: String, - ) -> Result>; + ) -> Result, Fido2CallbackError>; - async fn save_credential(&self, cred: Cipher) -> Result<()>; + async fn save_credential(&self, cred: Cipher) -> Result<(), Fido2CallbackError>; } -#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] -pub enum CheckUserOptions { - RequirePresence(bool), - RequireVerification(Verification), +#[derive(Clone)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +pub struct CheckUserOptions { + pub require_presence: bool, + pub require_verification: Verification, } + +#[derive(Clone)] #[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] pub enum Verification { Discouraged, diff --git a/crates/bitwarden/src/platform/fido2/types.rs b/crates/bitwarden/src/platform/fido2/types.rs new file mode 100644 index 000000000..bee66dbe8 --- /dev/null +++ b/crates/bitwarden/src/platform/fido2/types.rs @@ -0,0 +1,318 @@ +use passkey::types::webauthn::UserVerificationRequirement; +use serde::Serialize; + +use super::{get_enum_from_string_name, SelectedCredential, Verification}; + +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +pub struct PublicKeyCredentialRpEntity { + pub id: String, + pub name: Option, +} + +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +pub struct PublicKeyCredentialUserEntity { + pub id: Vec, + pub display_name: String, + pub name: String, +} + +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +pub struct PublicKeyCredentialParameters { + pub ty: String, + pub alg: i64, +} + +impl TryFrom + for passkey::types::webauthn::PublicKeyCredentialParameters +{ + type Error = crate::error::Error; + + fn try_from(value: PublicKeyCredentialParameters) -> Result { + use coset::iana::EnumI64; + Ok(Self { + ty: get_enum_from_string_name(&value.ty)?, + alg: coset::iana::Algorithm::from_i64(value.alg).ok_or("Invalid algorithm")?, + }) + } +} + +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +pub struct PublicKeyCredentialDescriptor { + pub ty: String, + pub id: Vec, + pub transports: Option>, +} + +impl TryFrom + for passkey::types::webauthn::PublicKeyCredentialDescriptor +{ + type Error = crate::error::Error; + + fn try_from(value: PublicKeyCredentialDescriptor) -> Result { + Ok(Self { + ty: get_enum_from_string_name(&value.ty)?, + id: value.id.into(), + transports: value + .transports + .map(|tt| { + tt.into_iter() + .map(|t| get_enum_from_string_name(&t)) + .collect::, Self::Error>>() + }) + .transpose()?, + }) + } +} + +pub type Extensions = Option; + +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +pub struct MakeCredentialRequest { + pub client_data_hash: Vec, + pub rp: PublicKeyCredentialRpEntity, + pub user: PublicKeyCredentialUserEntity, + pub pub_key_cred_params: Vec, + pub exclude_list: Option>, + pub options: Options, + pub extensions: Extensions, +} + +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +pub struct MakeCredentialResult { + pub authenticator_data: Vec, + pub attested_credential_data: Vec, + pub credential_id: Vec, +} + +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +pub struct GetAssertionRequest { + pub rp_id: String, + pub client_data_hash: Vec, + pub allow_list: Option>, + pub options: Options, + pub extensions: Extensions, +} + +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +pub struct Options { + pub rk: bool, + pub uv: UV, +} + +impl From for Options { + fn from(value: super::CheckUserOptions) -> Self { + Self { + rk: value.require_presence, + uv: value.require_verification.into(), + } + } +} + +impl From for super::CheckUserOptions { + fn from(value: Options) -> Self { + Self { + require_presence: value.rk, + require_verification: value.uv.into(), + } + } +} + +#[derive(Eq, PartialEq, Clone, Copy)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] +pub enum UV { + Discouraged, + Preferred, + Required, +} + +impl From for Verification { + fn from(value: UV) -> Self { + match value { + UV::Discouraged => Verification::Discouraged, + UV::Preferred => Verification::Preferred, + UV::Required => Verification::Required, + } + } +} + +impl From for UV { + fn from(value: Verification) -> Self { + match value { + Verification::Discouraged => UV::Discouraged, + Verification::Preferred => UV::Preferred, + Verification::Required => UV::Required, + } + } +} + +impl From for UV { + fn from(value: UserVerificationRequirement) -> Self { + match value { + UserVerificationRequirement::Discouraged => UV::Discouraged, + UserVerificationRequirement::Preferred => UV::Preferred, + UserVerificationRequirement::Required => UV::Required, + } + } +} + +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +pub struct GetAssertionResult { + pub credential_id: Vec, + pub authenticator_data: Vec, + pub signature: Vec, + pub user_handle: Vec, + + pub selected_credential: SelectedCredential, +} + +#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] +pub enum ClientData { + DefaultWithExtraData { android_package_name: String }, + DefaultWithCustomHash { hash: Vec }, +} + +#[derive(Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub(super) struct AndroidClientData { + android_package_name: String, +} + +impl passkey::client::ClientData> for ClientData { + fn extra_client_data(&self) -> Option { + match self { + ClientData::DefaultWithExtraData { + android_package_name, + } => Some(AndroidClientData { + android_package_name: android_package_name.clone(), + }), + ClientData::DefaultWithCustomHash { .. } => None, + } + } + + fn client_data_hash(&self) -> Option> { + match self { + ClientData::DefaultWithExtraData { .. } => None, + ClientData::DefaultWithCustomHash { hash } => Some(hash.clone()), + } + } +} + +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +pub struct ClientExtensionResults { + pub cred_props: Option, +} + +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +pub struct CredPropsResult { + pub rk: Option, + pub authenticator_display_name: Option, +} + +impl From for CredPropsResult { + fn from(value: passkey::types::webauthn::CredentialPropertiesOutput) -> Self { + Self { + rk: value.discoverable, + authenticator_display_name: value.authenticator_display_name, + } + } +} + +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +pub struct PublicKeyCredentialAuthenticatorAttestationResponse { + pub id: String, + pub raw_id: Vec, + pub ty: String, + pub authenticator_attachment: Option, + pub client_extension_results: ClientExtensionResults, + pub response: AuthenticatorAttestationResponse, + pub selected_credential: SelectedCredential, +} + +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +pub struct AuthenticatorAttestationResponse { + pub client_data_json: Vec, + pub authenticator_data: Vec, + pub public_key: Option>, + pub public_key_algorithm: i64, + pub attestation_object: Vec, + pub transports: Option>, +} + +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +pub struct PublicKeyCredentialAuthenticatorAssertionResponse { + pub id: String, + pub raw_id: Vec, + pub ty: String, + pub authenticator_attachment: Option, + pub client_extension_results: ClientExtensionResults, + pub response: AuthenticatorAssertionResponse, + pub selected_credential: SelectedCredential, +} + +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +pub struct AuthenticatorAssertionResponse { + pub client_data_json: Vec, + pub authenticator_data: Vec, + pub signature: Vec, + pub user_handle: Vec, +} + +#[cfg(test)] +mod tests { + use serde::{Deserialize, Serialize}; + + use crate::platform::fido2::types::AndroidClientData; + + // This is a stripped down of the passkey-rs implementation, to test the + // serialization of the `ClientData` enum, and to make sure that () and None + // are serialized the same way when going through #[serde(flatten)]. + #[derive(Serialize, Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct CollectedClientData + where + E: Serialize, + { + pub origin: String, + + #[serde(flatten)] + pub extra_data: E, + } + + #[test] + fn test_serialize_unit_data() { + let data = CollectedClientData { + origin: "https://example.com".to_owned(), + extra_data: (), + }; + + let serialized = serde_json::to_string(&data).unwrap(); + assert_eq!(serialized, r#"{"origin":"https://example.com"}"#); + } + + #[test] + fn test_serialize_none_data() { + let data = CollectedClientData { + origin: "https://example.com".to_owned(), + extra_data: Option::::None, + }; + + let serialized = serde_json::to_string(&data).unwrap(); + assert_eq!(serialized, r#"{"origin":"https://example.com"}"#); + } + + #[test] + fn test_serialize_android_data() { + let data = CollectedClientData { + origin: "https://example.com".to_owned(), + extra_data: Some(AndroidClientData { + android_package_name: "com.example.app".to_owned(), + }), + }; + + let serialized = serde_json::to_string(&data).unwrap(); + assert_eq!( + serialized, + r#"{"origin":"https://example.com","androidPackageName":"com.example.app"}"# + ); + } +} diff --git a/crates/bitwarden/src/platform/mod.rs b/crates/bitwarden/src/platform/mod.rs index 4ddc7561c..b905d2356 100644 --- a/crates/bitwarden/src/platform/mod.rs +++ b/crates/bitwarden/src/platform/mod.rs @@ -1,9 +1,11 @@ pub mod client_platform; +#[cfg(feature = "uniffi")] pub mod fido2; mod generate_fingerprint; mod get_user_api_key; mod secret_verification_request; +#[cfg(feature = "uniffi")] pub use fido2::{ClientFido2, Fido2Authenticator, Fido2Client}; pub use generate_fingerprint::{FingerprintRequest, FingerprintResponse}; pub(crate) use get_user_api_key::get_user_api_key; diff --git a/crates/bitwarden/src/vault/cipher/cipher.rs b/crates/bitwarden/src/vault/cipher/cipher.rs index 0814278ee..4b469bb1d 100644 --- a/crates/bitwarden/src/vault/cipher/cipher.rs +++ b/crates/bitwarden/src/vault/cipher/cipher.rs @@ -14,6 +14,8 @@ use super::{ local_data::{LocalData, LocalDataView}, login, secure_note, }; +#[cfg(feature = "uniffi")] +use crate::vault::Fido2CredentialFullView; use crate::{ error::{require, Error, Result}, vault::password_history, @@ -386,6 +388,43 @@ impl CipherView { self.organization_id = Some(organization_id); Ok(()) } + + #[cfg(feature = "uniffi")] + pub(crate) fn set_new_fido2_credentials( + &mut self, + enc: &dyn KeyContainer, + creds: Vec, + ) -> Result<()> { + let key = enc + .get_key(&self.organization_id) + .ok_or(Error::VaultLocked)?; + + let ciphers_key = Cipher::get_cipher_key(key, &self.key)?; + let ciphers_key = ciphers_key.as_ref().unwrap_or(key); + + require!(self.login.as_mut()).fido2_credentials = + Some(creds.encrypt_with_key(ciphers_key)?); + + Ok(()) + } + + #[cfg(feature = "uniffi")] + pub(crate) fn get_fido2_credentials( + &self, + enc: &dyn KeyContainer, + ) -> Result> { + let key = enc + .get_key(&self.organization_id) + .ok_or(Error::VaultLocked)?; + + let ciphers_key = Cipher::get_cipher_key(key, &self.key)?; + let ciphers_key = ciphers_key.as_ref().unwrap_or(key); + + let login = require!(self.login.as_ref()); + let creds = require!(login.fido2_credentials.as_ref()); + let res = creds.decrypt_with_key(ciphers_key)?; + Ok(res) + } } impl KeyDecryptable for Cipher { diff --git a/crates/bitwarden/src/vault/cipher/login.rs b/crates/bitwarden/src/vault/cipher/login.rs index ad117d658..85ab98ff6 100644 --- a/crates/bitwarden/src/vault/cipher/login.rs +++ b/crates/bitwarden/src/vault/cipher/login.rs @@ -96,9 +96,31 @@ pub struct Fido2CredentialView { pub key_type: String, pub key_algorithm: String, pub key_curve: String, - pub key_value: String, + // This value doesn't need to be returned to the client + // so we keep it encrypted until we need it + pub key_value: EncString, + pub rp_id: String, + pub user_handle: Option>, + pub user_name: Option, + pub counter: String, + pub rp_name: Option, + pub user_display_name: Option, + pub discoverable: String, + pub creation_date: DateTime, +} + +// This is mostly a copy of the Fido2CredentialView, but with the key exposed +// Only meant to be used internally and not exposed to the outside world +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct Fido2CredentialFullView { + pub credential_id: String, + pub key_type: String, + pub key_algorithm: String, + pub key_curve: String, + pub key_value: Vec, pub rp_id: String, - pub user_handle: Option, + pub user_handle: Option>, pub user_name: Option, pub counter: String, pub rp_name: Option, @@ -107,6 +129,115 @@ pub struct Fido2CredentialView { pub creation_date: DateTime, } +// This is mostly a copy of the Fido2CredentialView, meant to be exposed to the clients +// to let them select where to store the new credential. Note that it doesn't contain +// the encrypted key as that is only filled when the cipher is selected +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +pub struct Fido2CredentialNewView { + pub credential_id: String, + pub key_type: String, + pub key_algorithm: String, + pub key_curve: String, + pub rp_id: String, + pub user_handle: Option>, + pub user_name: Option, + pub counter: String, + pub rp_name: Option, + pub user_display_name: Option, + pub discoverable: String, + pub creation_date: DateTime, +} + +impl From for Fido2CredentialNewView { + fn from(value: Fido2CredentialFullView) -> Self { + Fido2CredentialNewView { + credential_id: value.credential_id, + key_type: value.key_type, + key_algorithm: value.key_algorithm, + key_curve: value.key_curve, + rp_id: value.rp_id, + user_handle: value.user_handle, + user_name: value.user_name, + counter: value.counter, + rp_name: value.rp_name, + user_display_name: value.user_display_name, + discoverable: value.discoverable, + creation_date: value.creation_date, + } + } +} + +impl KeyEncryptable for Fido2CredentialFullView { + fn encrypt_with_key( + self, + key: &SymmetricCryptoKey, + ) -> Result { + Ok(Fido2CredentialView { + credential_id: self.credential_id, + key_type: self.key_type, + key_algorithm: self.key_algorithm, + key_curve: self.key_curve, + key_value: self.key_value.encrypt_with_key(key)?, + rp_id: self.rp_id, + user_handle: self.user_handle, + user_name: self.user_name, + counter: self.counter, + rp_name: self.rp_name, + user_display_name: self.user_display_name, + discoverable: self.discoverable, + creation_date: self.creation_date, + }) + } +} + +impl KeyDecryptable for Fido2Credential { + fn decrypt_with_key( + &self, + key: &SymmetricCryptoKey, + ) -> Result { + Ok(Fido2CredentialFullView { + credential_id: self.credential_id.decrypt_with_key(key)?, + key_type: self.key_type.decrypt_with_key(key)?, + key_algorithm: self.key_algorithm.decrypt_with_key(key)?, + key_curve: self.key_curve.decrypt_with_key(key)?, + key_value: self.key_value.decrypt_with_key(key)?, + rp_id: self.rp_id.decrypt_with_key(key)?, + user_handle: self.user_handle.decrypt_with_key(key)?, + user_name: self.user_name.decrypt_with_key(key)?, + counter: self.counter.decrypt_with_key(key)?, + rp_name: self.rp_name.decrypt_with_key(key)?, + user_display_name: self.user_display_name.decrypt_with_key(key)?, + discoverable: self.discoverable.decrypt_with_key(key)?, + creation_date: self.creation_date, + }) + } +} + +impl KeyDecryptable for Fido2CredentialView { + fn decrypt_with_key( + &self, + key: &SymmetricCryptoKey, + ) -> Result { + Ok(Fido2CredentialFullView { + credential_id: self.credential_id.clone(), + key_type: self.key_type.clone(), + key_algorithm: self.key_algorithm.clone(), + key_curve: self.key_curve.clone(), + key_value: self.key_value.decrypt_with_key(key)?, + rp_id: self.rp_id.clone(), + user_handle: self.user_handle.clone(), + user_name: self.user_name.clone(), + counter: self.counter.clone(), + rp_name: self.rp_name.clone(), + user_display_name: self.user_display_name.clone(), + discoverable: self.discoverable.clone(), + creation_date: self.creation_date, + }) + } +} + #[derive(Serialize, Deserialize, Debug, JsonSchema, Clone)] #[serde(rename_all = "camelCase", deny_unknown_fields)] #[cfg_attr(feature = "uniffi", derive(uniffi::Record))] @@ -135,7 +266,7 @@ pub struct LoginView { pub autofill_on_page_load: Option, // TODO: Remove this once the SDK supports state - pub fido2_credentials: Option>, + pub fido2_credentials: Option>, } impl KeyEncryptable for LoginUriView { @@ -157,7 +288,7 @@ impl KeyEncryptable for LoginView { uris: self.uris.encrypt_with_key(key)?, totp: self.totp.encrypt_with_key(key)?, autofill_on_page_load: self.autofill_on_page_load, - fido2_credentials: self.fido2_credentials, + fido2_credentials: self.fido2_credentials.encrypt_with_key(key)?, }) } } @@ -181,7 +312,7 @@ impl KeyDecryptable for Login { uris: self.uris.decrypt_with_key(key).ok().flatten(), totp: self.totp.decrypt_with_key(key).ok().flatten(), autofill_on_page_load: self.autofill_on_page_load, - fido2_credentials: self.fido2_credentials.clone(), + fido2_credentials: self.fido2_credentials.decrypt_with_key(key).ok().flatten(), }) } } @@ -193,10 +324,16 @@ impl KeyEncryptable for Fido2CredentialView key_type: self.key_type.encrypt_with_key(key)?, key_algorithm: self.key_algorithm.encrypt_with_key(key)?, key_curve: self.key_curve.encrypt_with_key(key)?, - key_value: self.key_value.encrypt_with_key(key)?, + key_value: self.key_value, rp_id: self.rp_id.encrypt_with_key(key)?, - user_handle: self.user_handle.encrypt_with_key(key)?, - user_name: self.user_name.encrypt_with_key(key)?, + user_handle: self + .user_handle + .map(|h| h.encrypt_with_key(key)) + .transpose()?, + user_name: self + .user_name + .map(|n| n.encrypt_with_key(key)) + .transpose()?, counter: self.counter.encrypt_with_key(key)?, rp_name: self.rp_name.encrypt_with_key(key)?, user_display_name: self.user_display_name.encrypt_with_key(key)?, @@ -216,7 +353,7 @@ impl KeyDecryptable for Fido2Credential key_type: self.key_type.decrypt_with_key(key)?, key_algorithm: self.key_algorithm.decrypt_with_key(key)?, key_curve: self.key_curve.decrypt_with_key(key)?, - key_value: self.key_value.decrypt_with_key(key)?, + key_value: self.key_value.clone(), rp_id: self.rp_id.decrypt_with_key(key)?, user_handle: self.user_handle.decrypt_with_key(key)?, user_name: self.user_name.decrypt_with_key(key)?, diff --git a/crates/bitwarden/src/vault/cipher/mod.rs b/crates/bitwarden/src/vault/cipher/mod.rs index 89f1bc911..91e5bc7eb 100644 --- a/crates/bitwarden/src/vault/cipher/mod.rs +++ b/crates/bitwarden/src/vault/cipher/mod.rs @@ -14,5 +14,7 @@ pub use attachment::{ }; pub use cipher::{Cipher, CipherListView, CipherRepromptType, CipherType, CipherView}; pub use field::FieldView; -pub use login::{Fido2Credential, Fido2CredentialView}; +#[cfg(feature = "uniffi")] +pub(crate) use login::Fido2CredentialFullView; +pub use login::{Fido2Credential, Fido2CredentialNewView, Fido2CredentialView}; pub use secure_note::SecureNoteType; diff --git a/languages/swift/iOS/App/ContentView.swift b/languages/swift/iOS/App/ContentView.swift index 4a32dd9f4..2cda3e68b 100644 --- a/languages/swift/iOS/App/ContentView.swift +++ b/languages/swift/iOS/App/ContentView.swift @@ -334,23 +334,23 @@ struct ContentView: View { } func authenticatorTest(clientFido: ClientFido2) async throws { - let ui = UserInterfaceImpl() - let cs = CredentialStoreImpl() + let ui = Fido2UserInterfaceImpl() + let cs = Fido2CredentialStoreImpl() let authenticator = clientFido.authenticator(userInterface: ui, credentialStore: cs) // Make credential - try await authenticator.makeCredential(request: MakeCredentialRequest( + let _ = try await authenticator.makeCredential(request: MakeCredentialRequest( clientDataHash: Data(), rp: PublicKeyCredentialRpEntity(id: "abc", name: "test"), user: PublicKeyCredentialUserEntity(id: Data(), displayName: "b", name: "c"), pubKeyCredParams: [PublicKeyCredentialParameters(ty: "public-key", alg: 0)], excludeList: nil, - requireResidentKey: true, + options: Options(rk:true, uv:.preferred), extensions: nil )) // Get Assertion - try await authenticator.getAssertion(request: GetAssertionRequest( + let _ = try await authenticator.getAssertion(request: GetAssertionRequest( rpId: "", clientDataHash: Data(), allowList: nil, @@ -358,12 +358,12 @@ struct ContentView: View { extensions: nil )) - try await authenticator.silentlyDiscoverCredentials(rpId: "") + let _ = try await authenticator.silentlyDiscoverCredentials(rpId: "") // Only on android! let client = clientFido.client(userInterface: ui, credentialStore: cs) - try await client.authenticate(origin: "test", request: "test", clientData: ClientData.defaultWithExtraData(androidPackageName: "abc")) - try await client.register(origin: "test", request: "test", clientData: ClientData.defaultWithExtraData(androidPackageName: "abc")) + let _ = try await client.authenticate(origin: "test", request: "test", clientData: ClientData.defaultWithExtraData(androidPackageName: "abc")) + let _ = try await client.register(origin: "test", request: "test", clientData: ClientData.defaultWithExtraData(androidPackageName: "abc")) } } @@ -395,22 +395,26 @@ extension IgnoreHttpsDelegate: URLSessionDelegate { } } -class UserInterfaceImpl: UserInterface { - func pickCredentialForAuthentication(availableCredentials: [BitwardenSdk.Cipher]) async throws -> BitwardenSdk.CipherViewWrapper { +class Fido2UserInterfaceImpl: Fido2UserInterface { + func pickCredentialForAuthentication(availableCredentials: [BitwardenSdk.CipherView]) async throws -> BitwardenSdk.CipherViewWrapper { abort() } - func pickCredentialForCreation(availableCredentials: [BitwardenSdk.Cipher], newCredential: BitwardenSdk.Fido2Credential) async throws -> BitwardenSdk.CipherViewWrapper { + func checkUserAndPickCredentialForCreation(options: BitwardenSdk.CheckUserOptions, newCredential: BitwardenSdk.Fido2CredentialNewView) async throws -> BitwardenSdk.CipherViewWrapper { abort() } - func checkUser(options: BitwardenSdk.CheckUserOptions, credential: BitwardenSdk.CipherView?) async throws -> BitwardenSdk.CheckUserResult { + func checkUser(options: BitwardenSdk.CheckUserOptions, hint: UiHint) async throws -> BitwardenSdk.CheckUserResult { return CheckUserResult(userPresent: true, userVerified: true) } + + func isVerificationEnabled() async -> Bool { + true + } } -class CredentialStoreImpl: CredentialStore { - func findCredentials(ids: [Data]?, ripId: String) async throws -> [BitwardenSdk.Cipher] { +class Fido2CredentialStoreImpl: Fido2CredentialStore { + func findCredentials(ids: [Data]?, ripId: String) async throws -> [BitwardenSdk.CipherView] { abort() }