diff --git a/CHANGELOG.md b/CHANGELOG.md index be6f10c..b55f1b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,11 +2,6 @@ ## Unreleased -- ⚠ BREAKING: The `UserValidationMethod` trait has been updated to use `UiHint` - to give the implementation more information about the request, which can be used - to decide whether additional validations are needed. To reflect this, the - `UserValidationMethod` trait now also returns which validations were performed. - ## Passkey v0.5.0 - Migrate project to Rust 2024 edition @@ -20,6 +15,14 @@ This allows for updating/replacing credentials should the user so wish. (#67) - Fix hmac-secret logic around the second salt (#67) - ⚠ BREAKING: Fix Ctap2Api trait to correctly call the concrete method to prevent recursion (#67) +- ⚠ BREAKING: The `UserValidationMethod` trait has been updated to use `UiHint` + to give the implementation more information about the request, which can be used + to decide whether additional validations are needed. To reflect this, the + `UserValidationMethod` trait now also returns which validations were performed. (#76) +- ⚠ BREAKING: Change the `CredentialStore` and `UserValidationMethod` associated type constraint + to a new `PasskeyAccessor` trait instead of the `TryInto`, making it possible to use a + custom passkey representation type that goes throughout the entire flow without losing any + additional information through a conversion. (#87) ### passkey-client v0.5.0 @@ -31,6 +34,7 @@ - Make output types Hashable in Swift code gen (#67) - Support stringified booleans in webauthn requests (#67) - Be more tolerant to failed deserialization of optional vectors (#67) +- ⚠ BREAKING: Add `username` and `user_display_name` to the `Passkey` type and its mock builder. (#87) ## Passkey v0.4.0 ### passkey-authenticator v0.4.0 diff --git a/passkey-authenticator/src/authenticator/extensions.rs b/passkey-authenticator/src/authenticator/extensions.rs index ed7e32b..9d8edea 100644 --- a/passkey-authenticator/src/authenticator/extensions.rs +++ b/passkey-authenticator/src/authenticator/extensions.rs @@ -9,10 +9,7 @@ //! [webauthn]: https://w3c.github.io/webauthn/#sctn-defined-extensions //! [AuthenticatorDisplayName]: https://w3c.github.io/webauthn/#dom-credentialpropertiesoutput-authenticatordisplayname -use passkey_types::{ - Passkey, - ctap2::{StatusCode, get_assertion, get_info, make_credential}, -}; +use passkey_types::ctap2::{StatusCode, get_assertion, get_info, make_credential}; mod hmac_secret; pub use hmac_secret::{HmacSecretConfig, HmacSecretCredentialSupport}; @@ -23,7 +20,7 @@ pub(crate) use hmac_secret::tests::prf_eval_request; #[cfg(doc)] use passkey_types::webauthn; -use crate::Authenticator; +use crate::{Authenticator, passkey::PasskeyAccessor}; #[derive(Debug, Default)] #[non_exhaustive] @@ -93,12 +90,15 @@ impl Authenticator { passkey_types::CredentialExtensions { hmac_secret } } - pub(super) fn get_extensions( + pub(super) fn get_extensions

( &self, - passkey: &Passkey, + passkey: &P, request: Option, uv: bool, - ) -> Result { + ) -> Result + where + P: PasskeyAccessor, + { let Some(ext) = request.and_then(get_assertion::ExtensionInputs::zip_contents) else { return Ok(Default::default()); }; @@ -107,8 +107,8 @@ impl Authenticator { .prf .and_then(|salts| { self.get_prf( - &passkey.credential_id, - passkey.extensions.hmac_secret.as_ref(), + passkey.credential_id(), + passkey.extensions().hmac_secret.as_ref(), salts, uv, ) diff --git a/passkey-authenticator/src/authenticator/get_assertion.rs b/passkey-authenticator/src/authenticator/get_assertion.rs index da930ce..b3f51bd 100644 --- a/passkey-authenticator/src/authenticator/get_assertion.rs +++ b/passkey-authenticator/src/authenticator/get_assertion.rs @@ -1,5 +1,6 @@ use p256::ecdsa::{SigningKey, signature::SignerMut}; use passkey_types::{ + Bytes, ctap2::{ AuthenticatorData, Ctap2Error, Flags, StatusCode, get_assertion::{Request, Response}, @@ -8,7 +9,9 @@ use passkey_types::{ }; use crate::{ - Authenticator, CredentialStore, UserValidationMethod, private_key_from_cose_key, + Authenticator, CredentialStore, UserValidationMethod, + passkey::{AsCredentialDescriptor, PasskeyAccessor}, + private_key_from_cose_key, user_validation::UiHint, }; @@ -80,10 +83,7 @@ where let flags = self.check_user(hint, &input.options).await?; // 8. If no credentials were located in step 1, return CTAP2_ERR_NO_CREDENTIALS. - let mut credential = maybe_credential? - .try_into() - .ok() - .ok_or(Ctap2Error::NoCredentials)?; + let mut credential = maybe_credential?; // 9. If more than one credential was located in step 1 and allowList is present and not // empty, select any applicable credential and proceed to step 12. Otherwise, order the @@ -116,11 +116,9 @@ where // counter value, depending on which approach is implemented by the authenticator, // by some positive value. If the authenticator does not implement a signature // counter, let the signature counter value remain constant at zero. - if let Some(counter) = credential.counter { - credential.counter = Some(counter + 1); - self.store_mut() - .update_credential(credential.clone()) - .await?; + if let Some(counter) = credential.counter() { + credential.set_counter(counter + 1); + self.store_mut().update_credential(&credential).await?; } let extensions = @@ -131,24 +129,24 @@ where // concatenation is safe to use here because the authenticator data describes its own // length. The hash of the serialized client data (which potentially has a variable // length) is always the last element. - let auth_data = AuthenticatorData::new(&input.rp_id, credential.counter) + let auth_data = AuthenticatorData::new(&input.rp_id, credential.counter()) .set_flags(flags) .set_assertion_extensions(extensions.signed)?; let mut signature_target = auth_data.to_vec(); signature_target.extend(input.client_data_hash); - let secret_key = private_key_from_cose_key(&credential.key)?; + let secret_key = private_key_from_cose_key(&credential.key())?; let mut private_key = SigningKey::from(secret_key); let signature: p256::ecdsa::Signature = private_key.sign(&signature_target); let signature_bytes = signature.to_der().to_bytes().to_vec().into(); - let user_handle = credential.user_handle.clone(); + let user_handle = credential.user_handle().map(Bytes::from); Ok(Response { - credential: Some(credential.into()), + credential: Some(credential.as_credential_descriptor(None)), auth_data, signature: signature_bytes, user: user_handle.map(|id| PublicKeyCredentialUserEntity { diff --git a/passkey-authenticator/src/authenticator/make_credential.rs b/passkey-authenticator/src/authenticator/make_credential.rs index 3c7ab57..d7f6b46 100644 --- a/passkey-authenticator/src/authenticator/make_credential.rs +++ b/passkey-authenticator/src/authenticator/make_credential.rs @@ -135,7 +135,9 @@ where key: private, rp_id: input.rp.id.clone(), credential_id: credential_id.into(), - user_handle: is_passkey_rk.then_some(input.user.id.clone()), + user_handle: is_passkey_rk.then(|| input.user.id.clone()), + username: is_passkey_rk.then(|| input.user.name.clone()), + user_display_name: is_passkey_rk.then(|| input.user.display_name.clone()), counter: self.make_credentials_with_signature_counter.then_some(0), extensions: extensions.credential, }; diff --git a/passkey-authenticator/src/authenticator/make_credential/tests.rs b/passkey-authenticator/src/authenticator/make_credential/tests.rs index c66b08b..b76b789 100644 --- a/passkey-authenticator/src/authenticator/make_credential/tests.rs +++ b/passkey-authenticator/src/authenticator/make_credential/tests.rs @@ -92,6 +92,8 @@ async fn assert_excluded_credentials() { rp_id: "".into(), credential_id: cred_id.clone(), user_handle: Some(response.user.id.clone()), + username: Some("Appleseed".into()), + user_display_name: Some("wendy".into()), counter: None, extensions: Default::default(), }; @@ -410,7 +412,7 @@ async fn make_credential_returns_err_when_rk_is_requested_but_not_supported() { unimplemented!("The test should not call save_credential") } - async fn update_credential(&mut self, _cred: Passkey) -> Result<(), StatusCode> { + async fn update_credential(&mut self, _cred: &Passkey) -> Result<(), StatusCode> { #![allow(clippy::unimplemented)] unimplemented!("The test should not call update_credential") } @@ -514,6 +516,8 @@ async fn store_with_credentials_not_in_exclude_list_succeeds() { rp_id: "future.1password.com".into(), credential_id: stored_cred_id.clone(), user_handle: Some(random_vec(16).into()), + username: Some("Appleseed".into()), + user_display_name: Some("wendy".into()), counter: None, extensions: Default::default(), }; diff --git a/passkey-authenticator/src/credential_store.rs b/passkey-authenticator/src/credential_store.rs index 421bd74..506d9f1 100644 --- a/passkey-authenticator/src/credential_store.rs +++ b/passkey-authenticator/src/credential_store.rs @@ -11,6 +11,8 @@ use passkey_types::{ webauthn::PublicKeyCredentialDescriptor, }; +use crate::passkey::PasskeyAccessor; + /// A struct that defines the capabilities of a store. pub struct StoreInfo { /// How the store handles discoverability. @@ -48,7 +50,7 @@ impl DiscoverabilitySupport { #[async_trait::async_trait] pub trait CredentialStore { /// Defines the return type of find_credentials(...) - type PasskeyItem: TryInto + Send + Sync; + type PasskeyItem: PasskeyAccessor + Send + Sync; /// Find all credentials matching the given `ids` and `rp_id`. /// @@ -71,7 +73,7 @@ pub trait CredentialStore { ) -> Result<(), StatusCode>; /// Update the credential in your store - async fn update_credential(&mut self, cred: Passkey) -> Result<(), StatusCode>; + async fn update_credential(&mut self, cred: &Self::PasskeyItem) -> Result<(), StatusCode>; /// Get information about the store async fn get_info(&self) -> StoreInfo; @@ -116,8 +118,8 @@ impl CredentialStore for MemoryStore { Ok(()) } - async fn update_credential(&mut self, cred: Passkey) -> Result<(), StatusCode> { - self.insert(cred.credential_id.clone().into(), cred); + async fn update_credential(&mut self, cred: &Passkey) -> Result<(), StatusCode> { + self.insert(cred.credential_id.clone().into(), cred.clone()); Ok(()) } @@ -161,8 +163,8 @@ impl CredentialStore for Option { Ok(()) } - async fn update_credential(&mut self, cred: Passkey) -> Result<(), StatusCode> { - self.replace(cred); + async fn update_credential(&mut self, cred: &Passkey) -> Result<(), StatusCode> { + self.replace(cred.clone()); Ok(()) } @@ -205,7 +207,7 @@ impl + Send + Sync> CredentialStore .await } - async fn update_credential(&mut self, cred: Passkey) -> Result<(), StatusCode> { + async fn update_credential(&mut self, cred: &Passkey) -> Result<(), StatusCode> { self.lock().await.update_credential(cred).await } @@ -246,7 +248,7 @@ impl + Send + Sync> CredentialStore .await } - async fn update_credential(&mut self, cred: Passkey) -> Result<(), StatusCode> { + async fn update_credential(&mut self, cred: &Passkey) -> Result<(), StatusCode> { self.write().await.update_credential(cred).await } @@ -287,7 +289,7 @@ impl + Send + Sync> CredentialStore .await } - async fn update_credential(&mut self, cred: Passkey) -> Result<(), StatusCode> { + async fn update_credential(&mut self, cred: &Passkey) -> Result<(), StatusCode> { self.lock().await.update_credential(cred).await } @@ -328,7 +330,7 @@ impl + Send + Sync> CredentialStore .await } - async fn update_credential(&mut self, cred: Passkey) -> Result<(), StatusCode> { + async fn update_credential(&mut self, cred: &Passkey) -> Result<(), StatusCode> { self.write().await.update_credential(cred).await } diff --git a/passkey-authenticator/src/lib.rs b/passkey-authenticator/src/lib.rs index 1c54f31..a9381e6 100644 --- a/passkey-authenticator/src/lib.rs +++ b/passkey-authenticator/src/lib.rs @@ -26,6 +26,7 @@ mod authenticator; mod credential_store; mod ctap2; +mod passkey; mod u2f; mod user_validation; @@ -45,6 +46,7 @@ pub use self::{ authenticator::{Authenticator, CredentialIdLength, extensions}, credential_store::{CredentialStore, DiscoverabilitySupport, MemoryStore, StoreInfo}, ctap2::Ctap2Api, + passkey::PasskeyAccessor, u2f::U2fApi, user_validation::{UiHint, UserCheck, UserValidationMethod}, }; diff --git a/passkey-authenticator/src/passkey.rs b/passkey-authenticator/src/passkey.rs new file mode 100644 index 0000000..b03dec3 --- /dev/null +++ b/passkey-authenticator/src/passkey.rs @@ -0,0 +1,101 @@ +use std::{borrow::Cow, ops::Deref}; + +use coset::CoseKey; +use passkey_types::{ + CredentialExtensions, + webauthn::{AuthenticatorTransport, PublicKeyCredentialDescriptor, PublicKeyCredentialType}, +}; + +#[cfg(doc)] +use passkey_types::Passkey; + +/// A trait to model the different aspects of a passkey, this allows the abstraction of a passkey +/// on custom items. +pub trait PasskeyAccessor { + /// The private key + /// + /// This is a Cow in case the type does not save the private key in Cose format. + fn key(&self) -> Cow<'_, CoseKey>; + /// The passkey's credential Id. + fn credential_id(&self) -> &[u8]; + /// The Relying Party ID to which the passkey is bound to. + fn rp_id(&self) -> &str; + /// The user handle for which this passkey is bound to. + fn user_handle(&self) -> Option<&[u8]>; + /// The username that this passkey is created for. + fn username(&self) -> Option<&str>; + /// The user's display name. + fn user_display_name(&self) -> Option<&str>; + /// The counter of times this passkey has been used. + /// + /// See Counter considerations on [`Passkey::counter`] + fn counter(&self) -> Option; + /// Set a new value for the [`PasskeyAccessor::set_counter`] + fn set_counter(&mut self, counter: u32); + /// Get the extensions that that this passkey provides + /// + /// This is a Cow in case the type does not save the extension data in the format defined by + /// `passkey-types`. + fn extensions(&self) -> Cow<'_, CredentialExtensions>; +} + +pub(crate) trait AsCredentialDescriptor: PasskeyAccessor { + fn as_credential_descriptor( + &self, + transports: Option>, + ) -> PublicKeyCredentialDescriptor; +} + +impl

AsCredentialDescriptor for P +where + P: PasskeyAccessor, +{ + fn as_credential_descriptor( + &self, + transports: Option>, + ) -> PublicKeyCredentialDescriptor { + PublicKeyCredentialDescriptor { + ty: PublicKeyCredentialType::PublicKey, + id: self.credential_id().into(), + transports, + } + } +} + +impl PasskeyAccessor for passkey_types::Passkey { + fn key(&self) -> Cow<'_, CoseKey> { + Cow::Borrowed(&self.key) + } + + fn credential_id(&self) -> &[u8] { + &self.credential_id + } + + fn rp_id(&self) -> &str { + &self.rp_id + } + + fn user_handle(&self) -> Option<&[u8]> { + self.user_handle.as_ref().map(|b| b.deref().deref()) + } + + fn username(&self) -> Option<&str> { + self.username.as_deref() + } + + fn user_display_name(&self) -> Option<&str> { + self.user_display_name.as_deref() + } + + fn counter(&self) -> Option { + self.counter + } + + fn set_counter(&mut self, counter: u32) { + self.counter = Some(counter); + } + + fn extensions(&self) -> Cow<'_, CredentialExtensions> { + Cow::Borrowed(&self.extensions) + } +} diff --git a/passkey-authenticator/src/u2f.rs b/passkey-authenticator/src/u2f.rs index 1942995..9a728b7 100644 --- a/passkey-authenticator/src/u2f.rs +++ b/passkey-authenticator/src/u2f.rs @@ -1,6 +1,8 @@ //! Follows U2F 1.2 -use crate::{Authenticator, CoseKeyPair, CredentialStore, UserValidationMethod}; +use crate::{ + Authenticator, CoseKeyPair, CredentialStore, UserValidationMethod, passkey::PasskeyAccessor, +}; use coset::iana; use p256::{ SecretKey, @@ -136,15 +138,13 @@ impl U2 .await .map_err(|_| U2FError::Other); - let credential: Passkey = maybe_credential? + let credential = maybe_credential? .into_iter() .next() - .ok_or(U2FError::Other)? - .try_into() - .map_err(|_| U2FError::Other)?; + .ok_or(U2FError::Other)?; let secret_key = - super::private_key_from_cose_key(&credential.key).map_err(|_| U2FError::Other)?; + super::private_key_from_cose_key(&credential.key()).map_err(|_| U2FError::Other)?; let signing_key = SigningKey::from(secret_key); // The following signature_target is specified in the U2F Raw Message Formats spec: diff --git a/passkey-authenticator/src/user_validation.rs b/passkey-authenticator/src/user_validation.rs index df7ff4b..cb48303 100644 --- a/passkey-authenticator/src/user_validation.rs +++ b/passkey-authenticator/src/user_validation.rs @@ -1,11 +1,13 @@ -use passkey_types::{ - Passkey, - ctap2::{ - Ctap2Error, - make_credential::{PublicKeyCredentialRpEntity, PublicKeyCredentialUserEntity}, - }, +use passkey_types::ctap2::{ + Ctap2Error, + make_credential::{PublicKeyCredentialRpEntity, PublicKeyCredentialUserEntity}, }; +use crate::passkey::PasskeyAccessor; + +#[cfg(any(test, feature = "testable", doc))] +use passkey_types::Passkey; + #[cfg(doc)] use crate::Authenticator; @@ -43,7 +45,7 @@ pub struct UserCheck { #[async_trait::async_trait] pub trait UserValidationMethod { /// The type of the passkey item that can be used to display additional information about the operation to the user. - type PasskeyItem: TryInto + Send + Sync; + type PasskeyItem: PasskeyAccessor + Send + Sync; /// Check for the user's presence and obtain consent for the operation. The operation may /// also require the user to be verified. diff --git a/passkey-types/src/lib.rs b/passkey-types/src/lib.rs index 3a22b85..2fb26f7 100644 --- a/passkey-types/src/lib.rs +++ b/passkey-types/src/lib.rs @@ -46,5 +46,7 @@ pub use self::{ }, }; +pub use coset::CoseKey; + #[cfg(feature = "testable")] pub use self::passkey::PasskeyBuilder; diff --git a/passkey-types/src/passkey.rs b/passkey-types/src/passkey.rs index d3bcaf2..e50aa11 100644 --- a/passkey-types/src/passkey.rs +++ b/passkey-types/src/passkey.rs @@ -79,6 +79,22 @@ pub struct Passkey { /// [Discoverable Credential]: https://w3c.github.io/webauthn/#client-side-discoverable-credential pub user_handle: Option, + /// This is the [`webauthn::PublicKeyCredentialUserEntity::name`] passed in during the creation + /// of this credential. An authenticator can choose to store this or not. + /// + /// # PII Considerations + /// This is the username which is a human readable personal identifier. While it does not get + /// over the wire to the Relying Party it may be used in displays. + pub username: Option, + + /// This is the [`webauthn::PublicKeyCredentialUserEntity::display_name`] passed in during the creation + /// of this credential. An authenticator can choose to store this or not. + /// + /// # PII Considerations + /// This is the human-readable name for a user account. While it does not get sent + /// over the wire to the Relying Party it may be used in displays. + pub user_display_name: Option, + /// Value tracks the number of times an authentication ceremony has been successfully completed. /// If the value is `None` then it will be sent as the constant `0`. /// See [Signature counter considerations][signCount] for more information. @@ -109,6 +125,8 @@ impl Passkey { credential_id: response.key_handle.clone().to_vec().into(), rp_id: app_id.into(), user_handle: None, + username: None, + user_display_name: None, counter: Some(0), extensions: Default::default(), } @@ -126,6 +144,8 @@ impl Passkey { credential_id: request.key_handle.clone().to_vec().into(), rp_id: app_id.into(), user_handle: None, + username: None, + user_display_name: None, counter: Some(counter), extensions: Default::default(), } @@ -217,3 +237,19 @@ pub struct StoredHmacSecret { /// The credential that is not gated behind user verification, but is gated behind user presence pub cred_without_uv: Option>, } + +impl Debug for StoredHmacSecret { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("StoredHmacSecret") + .field("cred_with_uv", &"") + .field( + "cred_without_uv", + if self.cred_without_uv.is_some() { + &"" + } else { + &"None" + }, + ) + .finish() + } +} diff --git a/passkey-types/src/passkey/mock.rs b/passkey-types/src/passkey/mock.rs index e8e3b5b..a3e8696 100644 --- a/passkey-types/src/passkey/mock.rs +++ b/passkey-types/src/passkey/mock.rs @@ -40,6 +40,8 @@ impl PasskeyBuilder { credential_id: random_vec(16).into(), rp_id, user_handle: None, + username: None, + user_display_name: None, counter: None, extensions: Default::default(), }, @@ -58,6 +60,18 @@ impl PasskeyBuilder { self } + /// Set the username for the passkey. The default is None + pub fn username(mut self, username: String) -> Self { + self.inner.username = Some(username); + self + } + /// + /// Set the user display name for the passkey. The default is None + pub fn user_display_name(mut self, user_display_name: String) -> Self { + self.inner.user_display_name = Some(user_display_name); + self + } + /// Add a counter to the passkey. The default is None pub fn counter(mut self, counter: u32) -> Self { self.inner.counter = Some(counter);