From ea93ac550b735e0dd62c1db817bf58ec28a0e03f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Garc=C3=ADa?= Date: Tue, 4 Jun 2024 15:29:53 +0200 Subject: [PATCH] [PM-7840] Implement the stubbed out Passkey uniffi API (#779) ## Type of change ``` - [ ] Bug fix - [x] New feature development - [ ] Tech debt (refactoring, code cleanup, dependency upgrades, etc) - [ ] Build/deploy pipeline (DevOps) - [ ] Other ``` ## Objective This PR adds our fork of `passkey-rs` to bitwarden to implement the previously stubbed out API. With it come some changes: - Swapped most parts of the stubbed implementations to use `passkey-rs` instead - Some API callbacks were changed to take decrypted items, the clients would need these decrypted to show the user anyway, and we can skip a few rounds of decrypting/encrypting this way. Note that the FIDO2 credentials private keys are never exposed decrypted to the clients. - Added a separate `Fido2CredentialNewView`, the only difference being that it doesn't contain the private key. This is used to send to the clients in the cipher select callback. Everywhere else we send back the encrypted field but at this point we don't have a key to encrypt the field yet. We could send a dummy value but that seems error prone. - Changed `CheckUserOptions` to be a struct instead of an enum. This was a mistake from the previous PR. - Moved a lot of types that were previously distributed among a few files into a specific `types.rs` file. Also implemented conversion between these types and `passkey` types using `From`/`TryFrom` when possible. There are still some open questions: - Some of the callbacks from `passkey-rs` force us to return `StatusCode` or `Ctap2Error`, how do we want to handle these? At the moment I'm just logging the error and returing a constant value. Do we need specific error values for certain errors to be spec compliant? - The conversion from `Passkey` to `Fido2CredentialView` has a few hardcoded values, is that expected? - I left a few `// TODO(Fido2):` comments around to mention some other small questions I have --------- Co-authored-by: Andreas Coroiu --- Cargo.lock | 15 +- crates/bitwarden-uniffi/Cargo.toml | 1 + crates/bitwarden-uniffi/src/error.rs | 17 - crates/bitwarden-uniffi/src/platform/fido2.rs | 149 +++-- crates/bitwarden/Cargo.toml | 7 +- crates/bitwarden/src/error.rs | 11 +- .../bitwarden/src/platform/client_platform.rs | 9 +- .../src/platform/fido2/authenticator.rs | 581 +++++++++++------- crates/bitwarden/src/platform/fido2/client.rs | 334 +++------- crates/bitwarden/src/platform/fido2/crypto.rs | 37 ++ crates/bitwarden/src/platform/fido2/mod.rs | 254 +++++++- crates/bitwarden/src/platform/fido2/traits.rs | 55 +- crates/bitwarden/src/platform/fido2/types.rs | 318 ++++++++++ crates/bitwarden/src/platform/mod.rs | 2 + crates/bitwarden/src/vault/cipher/cipher.rs | 39 ++ crates/bitwarden/src/vault/cipher/login.rs | 155 ++++- crates/bitwarden/src/vault/cipher/mod.rs | 4 +- languages/swift/iOS/App/ContentView.swift | 32 +- 18 files changed, 1406 insertions(+), 614 deletions(-) create mode 100644 crates/bitwarden/src/platform/fido2/crypto.rs create mode 100644 crates/bitwarden/src/platform/fido2/types.rs 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() }