From c102f74463ad526a80614c05773422768992da6d Mon Sep 17 00:00:00 2001 From: Max Crone Date: Tue, 9 Jul 2024 15:24:15 +0200 Subject: [PATCH 1/2] Add hmac-secret and PRF to CTAP2 extension input types --- .github/workflows/ci.yml | 2 +- passkey-client/src/extensions.rs | 4 +- passkey-client/src/lib.rs | 1 + passkey-types/src/ctap2.rs | 1 + .../src/ctap2/extensions/hmac_secret.rs | 386 ++++++++++++++++++ passkey-types/src/ctap2/extensions/mod.rs | 11 + passkey-types/src/ctap2/extensions/prf.rs | 82 ++++ passkey-types/src/ctap2/get_assertion.rs | 35 +- passkey-types/src/ctap2/make_credential.rs | 50 ++- passkey-types/src/utils/bytes.rs | 14 +- 10 files changed, 580 insertions(+), 6 deletions(-) create mode 100644 passkey-types/src/ctap2/extensions/hmac_secret.rs create mode 100644 passkey-types/src/ctap2/extensions/mod.rs create mode 100644 passkey-types/src/ctap2/extensions/prf.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 42c51fd..f0ec29d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,7 +56,7 @@ jobs: with: profile: minimal toolchain: ${{ matrix.rust }} - - run: rustup run ${{ matrix.rust }} cargo test --all-features + - run: rustup run ${{ matrix.rust }} cargo test typeshare: name: Typeshare diff --git a/passkey-client/src/extensions.rs b/passkey-client/src/extensions.rs index 0521ab8..e83ce59 100644 --- a/passkey-client/src/extensions.rs +++ b/passkey-client/src/extensions.rs @@ -33,7 +33,7 @@ where &self, request: Option<&AuthenticationExtensionsClientInputs>, ) -> Option { - request.map(|_| make_credential::ExtensionInputs {}) + request.map(|_| make_credential::ExtensionInputs::default()) } /// Build the extension outputs for the WebAuthn client in a registration request. @@ -63,6 +63,6 @@ where &self, request: Option<&AuthenticationExtensionsClientInputs>, ) -> Option { - request.map(|_| get_assertion::ExtensionInputs {}) + request.map(|_| get_assertion::ExtensionInputs::default()) } } diff --git a/passkey-client/src/lib.rs b/passkey-client/src/lib.rs index 2502bbc..9e95a85 100644 --- a/passkey-client/src/lib.rs +++ b/passkey-client/src/lib.rs @@ -125,6 +125,7 @@ impl Display for Origin<'_> { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Origin::Web(url) => write!(f, "{}", url.as_str().trim_end_matches('/')), + #[cfg(feature = "android-asset-validation")] Origin::Android(target_link) => { write!( f, diff --git a/passkey-types/src/ctap2.rs b/passkey-types/src/ctap2.rs index 744f3aa..7b7eb55 100644 --- a/passkey-types/src/ctap2.rs +++ b/passkey-types/src/ctap2.rs @@ -9,6 +9,7 @@ mod attestation_fmt; mod error; mod flags; +pub mod extensions; pub mod get_assertion; pub mod get_info; pub mod make_credential; diff --git a/passkey-types/src/ctap2/extensions/hmac_secret.rs b/passkey-types/src/ctap2/extensions/hmac_secret.rs new file mode 100644 index 0000000..d9dcd9b --- /dev/null +++ b/passkey-types/src/ctap2/extensions/hmac_secret.rs @@ -0,0 +1,386 @@ +use crate::Bytes; + +#[cfg(doc)] +use serde::{Deserialize, Serialize}; + +serde_workaround! { + /// Object holding the initial salts for creating the secret. + #[derive(Debug, Clone)] + pub struct HmacGetSecretInput { + /// Should be of form [`coset::CoseKey`] but that doesn't implement [`Serialize`] or [`Deserialize`]. + #[serde(rename=0x01)] + pub key_agreement: ciborium::value::Value, + + /// The salts encrypted using the shared secret key from the pin UV exchange + #[serde(rename= 0x02)] + pub salt_enc: Bytes, + + /// The HMAC of the salts using the shared secret key + #[serde(rename=0x03)] + pub salt_auth: Bytes, + + /// The Pin Authentication protocol used in the derivation of the shared secret. + #[serde(rename=0x04, default, skip_serializing_if= Option::is_none)] + pub pin_uv_auth_protocol: Option, + } +} + +/// The salts (`salt1` and `salt2`) or the outputs (`output1` and `output2`) depending on whether +/// this is in the input request, or in the response. +#[derive(Debug, Clone)] +pub struct HmacSecretSaltOrOutput { + salts: [u8; 64], + + has_salt2: bool, +} + +impl From<[u8; 32]> for HmacSecretSaltOrOutput { + fn from(value: [u8; 32]) -> Self { + let mut salts = [0; 64]; + salts[..32].copy_from_slice(&value); + Self { + salts, + has_salt2: false, + } + } +} + +impl From<[u8; 64]> for HmacSecretSaltOrOutput { + fn from(salts: [u8; 64]) -> Self { + Self { + salts, + has_salt2: true, + } + } +} + +/// An error occurred when converting an badly sized slice to [`HmacSecretSaltOrOutput`] +#[derive(Debug)] +pub struct TryFromSliceError; + +impl From for TryFromSliceError { + fn from(_: std::array::TryFromSliceError) -> Self { + TryFromSliceError + } +} + +impl TryFrom<&[u8]> for HmacSecretSaltOrOutput { + type Error = TryFromSliceError; + + fn try_from(value: &[u8]) -> Result { + if value.len() == 64 { + let salts: [u8; 64] = value.try_into()?; + Ok(salts.into()) + } else if value.len() == 32 { + let salts: [u8; 32] = value.try_into()?; + Ok(salts.into()) + } else { + Err(TryFromSliceError) + } + } +} + +impl HmacSecretSaltOrOutput { + /// Create a new [`HmacSecretSaltOrOutput`] from sized arrays which is infallible + pub fn new(salt1: [u8; 32], salt2: Option<[u8; 32]>) -> Self { + let mut salts = [0; 64]; + let has_salt2 = salt2.is_some(); + let (one, two) = salts.split_at_mut(32); + one.copy_from_slice(&salt1); + + if let Some(salt2) = salt2 { + two.copy_from_slice(&salt2); + } + + Self { salts, has_salt2 } + } + + /// Try creating a new [`HmacSecretSaltOrOutput`] from byte slices. Returns an error if any of the given slices are + /// not exactly 32 bytes long. + pub fn try_new(salt1: &[u8], salt2: Option<&[u8]>) -> Result { + Ok(Self::new( + salt1.try_into()?, + salt2.map(|s| s.try_into()).transpose()?, + )) + } + + /// Get the first value along with the second concatenated if present. + #[inline] + pub fn as_slice(&self) -> &[u8] { + if self.has_salt2 { + &self.salts + } else { + &self.salts[..32] + } + } + + /// Get access to `salt1` or `output1` as a slice + #[inline] + pub fn first(&self) -> &[u8] { + &self.salts[..32] + } + + /// Get access to `salt2` or `output2` as a slice + #[inline] + pub fn second(&self) -> Option<&[u8]> { + self.has_salt2.then_some(&self.salts[32..]) + } +} + +#[cfg(test)] +mod tests { + use ciborium::{cbor, value::Value}; + use coset::AsCborValue; + + use crate::rand::random_vec; + + use super::*; + + const GOOD_SALT1: [u8; 32] = [ + 130, 250, 15, 242, 237, 2, 78, 230, 76, 63, 184, 229, 40, 172, 4, 60, 75, 182, 244, 15, + 109, 248, 177, 205, 235, 65, 32, 16, 183, 12, 145, 39, + ]; + const GOOD_SALT2: [u8; 32] = [ + 188, 232, 220, 195, 110, 115, 163, 139, 67, 124, 35, 10, 117, 252, 33, 207, 48, 16, 59, 32, + 69, 95, 121, 238, 217, 110, 160, 25, 20, 97, 164, 140, + ]; + const GOOD_SALT1_AND_2: [u8; 64] = [ + 130, 250, 15, 242, 237, 2, 78, 230, 76, 63, 184, 229, 40, 172, 4, 60, 75, 182, 244, 15, + 109, 248, 177, 205, 235, 65, 32, 16, 183, 12, 145, 39, 188, 232, 220, 195, 110, 115, 163, + 139, 67, 124, 35, 10, 117, 252, 33, 207, 48, 16, 59, 32, 69, 95, 121, 238, 217, 110, 160, + 25, 20, 97, 164, 140, + ]; + #[test] + fn from_32_byte_array() { + let salt = HmacSecretSaltOrOutput::from(GOOD_SALT1); + assert_eq!(&GOOD_SALT1, salt.first()); + assert!(!salt.has_salt2); + assert!(salt.second().is_none()); + assert_eq!(salt.as_slice(), &GOOD_SALT1); + + let salt = HmacSecretSaltOrOutput::new(GOOD_SALT1, None); + assert_eq!(&GOOD_SALT1, salt.first()); + assert!(!salt.has_salt2); + assert!(salt.second().is_none()); + assert_eq!(salt.as_slice(), &GOOD_SALT1); + } + + #[test] + fn from_64_byte_array() { + let salt = HmacSecretSaltOrOutput::from(GOOD_SALT1_AND_2); + assert_eq!(&GOOD_SALT1, salt.first()); + assert!(salt.has_salt2); + assert_eq!(salt.second(), Some(GOOD_SALT2.as_slice())); + assert_eq!(salt.as_slice(), &GOOD_SALT1_AND_2); + + let salt = HmacSecretSaltOrOutput::new(GOOD_SALT1, Some(GOOD_SALT2)); + assert_eq!(&GOOD_SALT1, salt.first()); + assert!(salt.has_salt2); + assert_eq!(salt.second(), Some(GOOD_SALT2.as_slice())); + assert_eq!(salt.as_slice(), &GOOD_SALT1_AND_2); + } + + #[test] + fn from_32_byte_slice() { + let salt = HmacSecretSaltOrOutput::try_from(GOOD_SALT1.as_slice()) + .expect("Failed to parse slice of one salt"); + assert_eq!(&GOOD_SALT1, salt.first()); + assert!(!salt.has_salt2); + assert!(salt.second().is_none()); + assert_eq!(salt.as_slice(), &GOOD_SALT1); + + let salt = HmacSecretSaltOrOutput::try_new(GOOD_SALT1.as_slice(), None) + .expect("Failed to parse slice of one salt"); + assert_eq!(&GOOD_SALT1, salt.first()); + assert!(!salt.has_salt2); + assert!(salt.second().is_none()); + assert_eq!(salt.as_slice(), &GOOD_SALT1); + } + + #[test] + fn from_64_byte_slice() { + let salt = HmacSecretSaltOrOutput::try_from(GOOD_SALT1_AND_2.as_slice()) + .expect("Failed to parse slice of both salts"); + assert_eq!(&GOOD_SALT1, salt.first()); + assert!(salt.has_salt2); + assert_eq!(salt.second(), Some(GOOD_SALT2.as_slice())); + assert_eq!(salt.as_slice(), &GOOD_SALT1_AND_2); + + let salt = + HmacSecretSaltOrOutput::try_new(GOOD_SALT1.as_slice(), Some(GOOD_SALT2.as_slice())) + .expect("Failed to parse slice of both salts"); + assert_eq!(&GOOD_SALT1, salt.first()); + assert!(salt.has_salt2); + assert_eq!(salt.second(), Some(GOOD_SALT2.as_slice())); + assert_eq!(salt.as_slice(), &GOOD_SALT1_AND_2); + } + + #[test] + fn from_incorrectly_sized_byte_slice() { + let too_short = random_vec(31); + let between = random_vec(33); + let too_long = random_vec(65); + + HmacSecretSaltOrOutput::try_from(too_short.as_slice()) + .expect_err("Failed to detect salt1 is too short"); + HmacSecretSaltOrOutput::try_from(between.as_slice()) + .expect_err("Failed to detect salt2 is too short"); + HmacSecretSaltOrOutput::try_from(too_long.as_slice()) + .expect_err("Failed to detect both salts are too long"); + + HmacSecretSaltOrOutput::try_new(&too_short, None) + .expect_err("Failed to detect salt1 is too short"); + HmacSecretSaltOrOutput::try_new(&between, None) + .expect_err("Failed to detect salt1 is too long"); + + HmacSecretSaltOrOutput::try_new(&too_short, Some(&too_short)) + .expect_err("Failed to detect both salts are too short"); + HmacSecretSaltOrOutput::try_new(&between, Some(&between)) + .expect_err("Failed to detect both salts are too long"); + + HmacSecretSaltOrOutput::try_new(&too_short, Some(&between)) + .expect_err("Failed to detect salt1 is short and salt2 is long"); + HmacSecretSaltOrOutput::try_new(&between, Some(&too_short)) + .expect_err("Failed to detect salt1 is long and salt2 is short"); + + let correct = random_vec(32); + + HmacSecretSaltOrOutput::try_new(&correct, Some(&too_short)) + .expect_err("Failed to detect salt1 is good but salt2 is short"); + HmacSecretSaltOrOutput::try_new(&correct, Some(&between)) + .expect_err("Failed to detect salt1 is good but salt2 is long"); + HmacSecretSaltOrOutput::try_new(&too_short, Some(&correct)) + .expect_err("Failed to detect salt1 is short but salt2 is good"); + HmacSecretSaltOrOutput::try_new(&between, Some(&correct)) + .expect_err("Failed to detect salt1 is long but salt2 is good"); + } + + #[test] + fn from_correct_cbor() { + let key = coset::CoseKeyBuilder::new_ec2_pub_key( + coset::iana::EllipticCurve::P_256, + random_vec(32), + random_vec(32), + ) + .build() + .to_cbor_value() + .unwrap(); + let remote_one_salt = cbor!({ + 0x01 => key, + 0x02 => Value::Bytes(GOOD_SALT1.to_vec()), + // should be a HMAC other salt with the key + 0x03 => Value::Bytes(random_vec(32)) + }) + .unwrap(); + let remote_two_salts = cbor!({ + 0x01 => key, + 0x02 => Value::Bytes(GOOD_SALT1_AND_2.to_vec()), + // should be a HMAC other salt with the key + 0x03 => Value::Bytes(random_vec(32)) + }) + .unwrap(); + + let remote: HmacGetSecretInput = remote_one_salt + .deserialized() + .expect("Failed to deserialize remote with one salt"); + // should be encrypted but lets ignore that for now + let salts = HmacSecretSaltOrOutput::try_from(remote.salt_enc.as_slice()) + .expect("The salts are most likely encrypted"); + assert_eq!(&GOOD_SALT1, salts.first()); + assert!(!salts.has_salt2); + assert!(salts.second().is_none()); + assert_eq!(salts.as_slice(), &GOOD_SALT1); + + let remote: HmacGetSecretInput = remote_two_salts + .deserialized() + .expect("Failed to deserialize remote with two salts"); + // should be encrypted but lets ignore that for now + let salts = HmacSecretSaltOrOutput::try_from(remote.salt_enc.as_slice()) + .expect("The salts are most likely encrypted"); + assert_eq!(&GOOD_SALT1, salts.first()); + assert!(salts.has_salt2); + assert_eq!(salts.second(), Some(GOOD_SALT2.as_slice())); + assert_eq!(salts.as_slice(), &GOOD_SALT1_AND_2); + } + + #[test] + fn cbor_round_trip_one_salt() { + let key = coset::CoseKeyBuilder::new_ec2_pub_key( + coset::iana::EllipticCurve::P_256, + random_vec(32), + random_vec(32), + ) + .build() + .to_cbor_value() + .unwrap(); + let one_salt = HmacGetSecretInput { + key_agreement: key, + salt_enc: Bytes::from(GOOD_SALT1.as_slice()), + salt_auth: random_vec(32).into(), + pin_uv_auth_protocol: None, + }; + let mut buf = Vec::with_capacity(128); + ciborium::ser::into_writer(&one_salt, &mut buf).unwrap(); + + let Value::Map(map) = ciborium::de::from_reader(buf.as_slice()).unwrap() else { + panic!("Could not deserialize to a map") + }; + assert_eq!( + 32, + map.into_iter() + .find(|(i, _)| i.as_integer() == Some(0x02.into())) + .map(|(_, val)| val.as_bytes().unwrap().len()) + .unwrap() + ); + let expect_one_salt: HmacGetSecretInput = + ciborium::de::from_reader(buf.as_slice()).unwrap(); + + assert_eq!(expect_one_salt.key_agreement, one_salt.key_agreement); + assert_eq!(expect_one_salt.salt_enc, one_salt.salt_enc); + assert_eq!(expect_one_salt.salt_auth, one_salt.salt_auth); + assert_eq!( + expect_one_salt.pin_uv_auth_protocol, + one_salt.pin_uv_auth_protocol + ); + } + #[test] + fn cbor_round_trip_both_salts() { + let key = coset::CoseKeyBuilder::new_ec2_pub_key( + coset::iana::EllipticCurve::P_256, + random_vec(32), + random_vec(32), + ) + .build() + .to_cbor_value() + .unwrap(); + let one_salt = HmacGetSecretInput { + key_agreement: key, + salt_enc: Bytes::from(GOOD_SALT1_AND_2.as_slice()), + salt_auth: random_vec(32).into(), + pin_uv_auth_protocol: None, + }; + let mut buf = Vec::with_capacity(128); + ciborium::ser::into_writer(&one_salt, &mut buf).unwrap(); + + let Value::Map(map) = ciborium::de::from_reader(buf.as_slice()).unwrap() else { + panic!("Could not deserialize to a map") + }; + assert_eq!( + 64, + map.into_iter() + .find(|(i, _)| i.as_integer() == Some(0x02.into())) + .map(|(_, val)| val.as_bytes().unwrap().len()) + .unwrap() + ); + let expect_one_salt: HmacGetSecretInput = + ciborium::de::from_reader(buf.as_slice()).unwrap(); + + assert_eq!(expect_one_salt.key_agreement, one_salt.key_agreement); + assert_eq!(expect_one_salt.salt_enc, one_salt.salt_enc); + assert_eq!(expect_one_salt.salt_auth, one_salt.salt_auth); + assert_eq!( + expect_one_salt.pin_uv_auth_protocol, + one_salt.pin_uv_auth_protocol + ); + } +} diff --git a/passkey-types/src/ctap2/extensions/mod.rs b/passkey-types/src/ctap2/extensions/mod.rs new file mode 100644 index 0000000..4c45f22 --- /dev/null +++ b/passkey-types/src/ctap2/extensions/mod.rs @@ -0,0 +1,11 @@ +//! Types for the CTAP2 authenticator extensions. +//! +//! +mod hmac_secret; +pub(super) mod prf; + +pub use hmac_secret::{HmacGetSecretInput, HmacSecretSaltOrOutput, TryFromSliceError}; +pub use prf::{ + AuthenticatorPrfGetOutputs, AuthenticatorPrfInputs, AuthenticatorPrfMakeOutputs, + AuthenticatorPrfValues, +}; diff --git a/passkey-types/src/ctap2/extensions/prf.rs b/passkey-types/src/ctap2/extensions/prf.rs new file mode 100644 index 0000000..e83ff7f --- /dev/null +++ b/passkey-types/src/ctap2/extensions/prf.rs @@ -0,0 +1,82 @@ +//! While this is not an official CTAP extension, +//! it is used on Windows directly and it allows an in-memory authenticator +//! to handle the prf extension in a more efficient manor. + +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +use crate::{webauthn, Bytes}; + +#[cfg(doc)] +use crate::ctap2::{get_assertion, make_credential}; + +/// This struct is a more opiniated mirror of [`webauthn::AuthenticationExtensionsPrfInputs`]. +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct AuthenticatorPrfInputs { + /// See [`webauthn::AuthenticationExtensionsPrfInputs::eval`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub eval: Option, + + /// See [`webauthn::AuthenticationExtensionsPrfInputs::eval_by_credential`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub eval_by_credential: Option>, +} + +/// This struct is a more opiniated mirror of [`webauthn::AuthenticationExtensionsPrfValues`]. +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct AuthenticatorPrfValues { + /// This is the already hashed values of [`webauthn::AuthenticationExtensionsPrfValues::first`]. + pub first: [u8; 32], + + /// This is the already hashed values of [`webauthn::AuthenticationExtensionsPrfValues::second`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub second: Option<[u8; 32]>, +} + +impl From for webauthn::AuthenticationExtensionsPrfValues { + fn from(value: AuthenticatorPrfValues) -> Self { + Self { + first: value.first.to_vec().into(), + second: value.second.map(|b| b.to_vec().into()), + } + } +} + +/// This struct is a more opiniated mirror of [`webauthn::AuthenticationExtensionsPrfOutputs`] +/// specifically for [`make_credential`]. +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct AuthenticatorPrfMakeOutputs { + /// See [`webauthn::AuthenticationExtensionsPrfOutputs::enabled`]. + pub enabled: bool, + + /// See [`webauthn::AuthenticationExtensionsPrfOutputs::results`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub results: Option, +} + +impl From for webauthn::AuthenticationExtensionsPrfOutputs { + fn from(value: AuthenticatorPrfMakeOutputs) -> Self { + Self { + enabled: Some(value.enabled), + results: value.results.map(Into::into), + } + } +} + +/// This struct is a more opiniated mirror of [`webauthn::AuthenticationExtensionsPrfOutputs`] +/// specifically for [`get_assertion`]. +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct AuthenticatorPrfGetOutputs { + /// See [`webauthn::AuthenticationExtensionsPrfOutputs::results`]. + pub results: AuthenticatorPrfValues, +} + +impl From for webauthn::AuthenticationExtensionsPrfOutputs { + fn from(value: AuthenticatorPrfGetOutputs) -> Self { + Self { + enabled: None, + results: Some(value.results.into()), + } + } +} diff --git a/passkey-types/src/ctap2/get_assertion.rs b/passkey-types/src/ctap2/get_assertion.rs index d997848..e247c4d 100644 --- a/passkey-types/src/ctap2/get_assertion.rs +++ b/passkey-types/src/ctap2/get_assertion.rs @@ -12,6 +12,8 @@ pub use crate::ctap2::make_credential::Options; #[cfg(doc)] use crate::webauthn::{CollectedClientData, PublicKeyCredentialRequestOptions}; +use super::extensions::{AuthenticatorPrfInputs, HmacGetSecretInput}; + serde_workaround! { /// While similar in structure to [`PublicKeyCredentialRequestOptions`], /// it is not completely identical, namely the presence of the `options` key. @@ -54,7 +56,38 @@ serde_workaround! { /// All supported Authenticator extensions inputs during credential assertion #[derive(Debug, Serialize, Deserialize, Default)] -pub struct ExtensionInputs {} +pub struct ExtensionInputs { + /// The input salts for fetching and deriving a symmetric secret. + /// + /// + #[serde( + rename = "hmac-secret", + default, + skip_serializing_if = "Option::is_none" + )] + pub hmac_secret: Option, + + /// The direct input from a on-system client for the prf extension. + /// + /// The output from a request using the `prf` extension will not be signed + /// and will be un-encrypted. + /// This input should already be hashed by the client. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prf: Option, +} + +impl ExtensionInputs { + /// Validates that there is at least one extension field that is `Some`. + /// If all fields are `None` then this returns `None` as well. + pub fn zip_contents(self) -> Option { + let Self { hmac_secret, prf } = &self; + + let has_hmac_secret = hmac_secret.is_some(); + let has_prf = prf.is_some(); + + (has_hmac_secret || has_prf).then_some(self) + } +} serde_workaround! { /// Type returned from `Authenticator::get_assertion` on success. diff --git a/passkey-types/src/ctap2/make_credential.rs b/passkey-types/src/ctap2/make_credential.rs index 05c4057..0fedc11 100644 --- a/passkey-types/src/ctap2/make_credential.rs +++ b/passkey-types/src/ctap2/make_credential.rs @@ -9,6 +9,8 @@ use crate::webauthn::{ CollectedClientData, PublicKeyCredentialCreationOptions, PublicKeyCredentialDescriptor, }; +use super::extensions::{AuthenticatorPrfInputs, HmacGetSecretInput}; + serde_workaround! { /// While similar in structure to [`PublicKeyCredentialCreationOptions`], /// it is not completely identical, namely the presence of the `options` key. @@ -201,7 +203,53 @@ const fn default_true() -> bool { /// All supported Authenticator extensions inputs during credential creation #[derive(Debug, Serialize, Deserialize, Default)] -pub struct ExtensionInputs {} +pub struct ExtensionInputs { + /// A boolean value to indicate that this extension is requested by the Relying Party + /// + /// + #[serde( + rename = "hmac-secret", + default, + skip_serializing_if = "Option::is_none" + )] + pub hmac_secret: Option, + + /// The input salts for fetching and deriving a symmetric secret during registration. + /// + /// TODO: link to the hmac-secret-mc extension in the spec once it's published. + #[serde( + rename = "hmac-secret-mc", + default, + skip_serializing_if = "Option::is_none" + )] + pub hmac_secret_mc: Option, + + /// The direct input from a on-system client for the prf extension. + /// + /// The output from a request using the `prf` extension will not be signed + /// and will be un-encrypted. + /// This input should already be hashed by the client. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prf: Option, +} + +impl ExtensionInputs { + /// Validates that there is at least one extension field that is `Some`. + /// If all fields are `None` then this returns `None` as well. + pub fn zip_contents(self) -> Option { + let Self { + hmac_secret, + hmac_secret_mc, + prf, + } = &self; + + let has_hmac_secret = hmac_secret.is_some(); + let has_hmac_secret_mc = hmac_secret_mc.is_some(); + let has_prf = prf.is_some(); + + (has_hmac_secret || has_hmac_secret_mc || has_prf).then_some(self) + } +} serde_workaround! { /// Upon successful creation of a credential, the authenticator returns an attestation object. diff --git a/passkey-types/src/utils/bytes.rs b/passkey-types/src/utils/bytes.rs index 6c884ab..5ca71bf 100644 --- a/passkey-types/src/utils/bytes.rs +++ b/passkey-types/src/utils/bytes.rs @@ -17,7 +17,7 @@ use super::encoding; /// /// It also supports deserializing from `base64` and `base64url` formatted strings. #[typeshare(transparent)] -#[derive(Debug, Default, PartialEq, Eq, Clone)] +#[derive(Debug, Default, PartialEq, Eq, Clone, Hash)] #[repr(transparent)] pub struct Bytes(Vec); @@ -41,6 +41,12 @@ impl From> for Bytes { } } +impl From<&[u8]> for Bytes { + fn from(value: &[u8]) -> Self { + Bytes(value.to_vec()) + } +} + impl From for Vec { fn from(src: Bytes) -> Self { src.0 @@ -153,6 +159,12 @@ impl<'de> Deserialize<'de> for Bytes { } Ok(Bytes(buf)) } + fn visit_bytes(self, v: &[u8]) -> Result + where + E: serde::de::Error, + { + Ok(Bytes(v.to_vec())) + } } deserializer.deserialize_any(Base64Visitor) } From a7a1ef9e83e36b05043f29c49bebe2c2e475bc97 Mon Sep 17 00:00:00 2001 From: Max Crone Date: Tue, 9 Jul 2024 16:35:32 +0200 Subject: [PATCH 2/2] Add signed/unsigned extension output types to authenticator response --- CHANGELOG.md | 1 + .../src/authenticator/get_assertion.rs | 1 + .../src/authenticator/make_credential.rs | 1 + passkey-types/src/ctap2/get_assertion.rs | 55 +++++++++++++++++- passkey-types/src/ctap2/make_credential.rs | 58 ++++++++++++++++++- 5 files changed, 114 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 70a7b57..09e6307 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ - ⚠ BREAKING: Rename webauthn extension outputs to be consistent with inputs. - ⚠ BREAKING: Create new extension inputs for the CTAP authenticator inputs. +- ⚠ BREAKING: Add unsigned extension outputs for the CTAP authenticator outputs. ## Passkey v0.2.0 ### passkey-types v0.2.0 diff --git a/passkey-authenticator/src/authenticator/get_assertion.rs b/passkey-authenticator/src/authenticator/get_assertion.rs index 3109e85..2fc9456 100644 --- a/passkey-authenticator/src/authenticator/get_assertion.rs +++ b/passkey-authenticator/src/authenticator/get_assertion.rs @@ -149,6 +149,7 @@ where name: "".into(), }), number_of_credentials: None, + unsigned_extension_outputs: None, }) } } diff --git a/passkey-authenticator/src/authenticator/make_credential.rs b/passkey-authenticator/src/authenticator/make_credential.rs index 6ccf8a8..68afc09 100644 --- a/passkey-authenticator/src/authenticator/make_credential.rs +++ b/passkey-authenticator/src/authenticator/make_credential.rs @@ -137,6 +137,7 @@ where auth_data, fmt: "None".into(), att_stmt: vec![0xa0].into(), // CBOR exquivalent to empty map + unsigned_extension_outputs: None, }; // 10 diff --git a/passkey-types/src/ctap2/get_assertion.rs b/passkey-types/src/ctap2/get_assertion.rs index e247c4d..ba631f3 100644 --- a/passkey-types/src/ctap2/get_assertion.rs +++ b/passkey-types/src/ctap2/get_assertion.rs @@ -12,7 +12,7 @@ pub use crate::ctap2::make_credential::Options; #[cfg(doc)] use crate::webauthn::{CollectedClientData, PublicKeyCredentialRequestOptions}; -use super::extensions::{AuthenticatorPrfInputs, HmacGetSecretInput}; +use super::extensions::{AuthenticatorPrfGetOutputs, AuthenticatorPrfInputs, HmacGetSecretInput}; serde_workaround! { /// While similar in structure to [`PublicKeyCredentialRequestOptions`], @@ -142,5 +142,58 @@ serde_workaround! { /// file an enhancement request if this limit impacts your application. #[serde(rename = 0x05, default, skip_serializing_if = Option::is_none)] pub number_of_credentials: Option, + + /// A map, keyed by extension identifiers, to unsigned outputs of extensions, if any. + /// Authenticators SHOULD omit this field if no processed extensions define unsigned outputs. + /// Clients MUST treat an empty map the same as an omitted field. + #[serde(rename = 0x08, default, skip_serializing_if = Option::is_none)] + pub unsigned_extension_outputs: Option, + } +} + +/// All supported Authenticator extensions outputs during credential assertion +/// +/// This is to be serialized to [`Value`] in [`AuthenticatorData::extensions`] +#[derive(Debug, Serialize, Deserialize)] +pub struct SignedExtensionOutputs { + /// Outputs the symmetric secrets after successfull processing. The output MUST be encrypted. + /// + /// + #[serde( + rename = "hmac-secret", + default, + skip_serializing_if = "Option::is_none" + )] + pub hmac_secret: Option, +} + +impl SignedExtensionOutputs { + /// Validates that there is at least one extension field that is `Some`. + /// If all fields are `None` then this returns `None` as well. + pub fn zip_contents(self) -> Option { + let Self { hmac_secret } = &self; + hmac_secret.is_some().then_some(self) + } +} + +/// A map, keyed by extension identifiers, to unsigned outputs of extensions, if any. +/// Authenticators SHOULD omit this field if no processed extensions define unsigned outputs. +/// Clients MUST treat an empty map the same as an omitted field. +#[derive(Debug, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct UnsignedExtensionOutputs { + /// This output is supported in the Webauthn specification and will be used when the authenticator + /// and the client are in memory or communicating through an internal channel. + /// + /// If you are using transports where this needs to pass through a wire, use hmac-secret instead. + pub prf: Option, +} + +impl UnsignedExtensionOutputs { + /// Validates that there is at least one extension field that is `Some`. + /// If all fields are `None` then this returns `None` as well. + pub fn zip_contents(self) -> Option { + let Self { prf } = &self; + prf.is_some().then_some(self) } } diff --git a/passkey-types/src/ctap2/make_credential.rs b/passkey-types/src/ctap2/make_credential.rs index 0fedc11..9dbbfd4 100644 --- a/passkey-types/src/ctap2/make_credential.rs +++ b/passkey-types/src/ctap2/make_credential.rs @@ -9,7 +9,7 @@ use crate::webauthn::{ CollectedClientData, PublicKeyCredentialCreationOptions, PublicKeyCredentialDescriptor, }; -use super::extensions::{AuthenticatorPrfInputs, HmacGetSecretInput}; +use super::extensions::{AuthenticatorPrfInputs, AuthenticatorPrfMakeOutputs, HmacGetSecretInput}; serde_workaround! { /// While similar in structure to [`PublicKeyCredentialCreationOptions`], @@ -270,5 +270,61 @@ serde_workaround! { // the keys #[serde(rename = 0x03)] pub att_stmt: ciborium::value::Value, + + /// A map, keyed by extension identifiers, to unsigned outputs of extensions, if any. + /// Authenticators SHOULD omit this field if no processed extensions define unsigned outputs. + /// Clients MUST treat an empty map the same as an omitted field. + #[serde(rename = 0x06, default, skip_serializing_if = Option::is_none)] + pub unsigned_extension_outputs: Option, + } +} + +/// All supported Authenticator extensions outputs during credential creation +/// +/// This is to be serialized to [`Value`] in [`AuthenticatorData::extensions`] +#[derive(Debug, Serialize, Deserialize)] +pub struct SignedExtensionOutputs { + /// A boolean value to indicate that this extension was successfully processed by the extension + /// + /// + #[serde( + rename = "hmac-secret", + default, + skip_serializing_if = "Option::is_none" + )] + pub hmac_secret: Option, +} + +impl SignedExtensionOutputs { + /// Validates that there is at least one extension field that is `Some`. + /// If all fields are `None` then this returns `None` as well. + pub fn zip_contents(self) -> Option { + let Self { hmac_secret } = &self; + let has_hmac_secret = hmac_secret.is_some(); + + (has_hmac_secret).then_some(self) + } +} + +/// A map, keyed by extension identifiers, to unsigned outputs of extensions, if any. +/// Authenticators SHOULD omit this field if no processed extensions define unsigned outputs. +/// Clients MUST treat an empty map the same as an omitted field. +#[derive(Debug, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct UnsignedExtensionOutputs { + /// This output is supported in the Webauthn specification and will be used when the authenticator + /// and the client are in memory or communicating through an internal channel. + /// + /// If you are using transports where this needs to pass through a wire, use hmac-secret instead. + pub prf: Option, +} + +impl UnsignedExtensionOutputs { + /// Validates that there is at least one extension field that is `Some`. + /// If all fields are `None` then this returns `None` as well. + pub fn zip_contents(self) -> Option { + let Self { prf } = &self; + + prf.is_some().then_some(self) } }