diff --git a/CHANGELOG.md b/CHANGELOG.md index 8160139..d23955d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [Unreleased]: https://github.com/trussed-dev/ctap-types/compare/0.2.0...HEAD -- +### Breaking Changes + +- Use enums instead of string constants + - Introduce `Version`, `Extension` and `Transport` enums and use them in `ctap2::get_info` + - Fix serialization of the `AttestationStatementFormat` enum and use it in `ctap2::make_credential` ## [0.2.0] - 2024-06-21 diff --git a/Cargo.toml b/Cargo.toml index 98eff84..0ccc0f1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,9 @@ serde-indexed = "0.1.1" serde_bytes = { version = "0.11.14", default-features = false } serde_repr = "0.1" +[dev-dependencies] +serde_test = "1.0.176" + [features] # enables all fields for ctap2::get_info get-info-full = [] diff --git a/src/ctap2/get_info.rs b/src/ctap2/get_info.rs index 2b999f4..e625c96 100644 --- a/src/ctap2/get_info.rs +++ b/src/ctap2/get_info.rs @@ -1,5 +1,5 @@ use crate::webauthn::FilteredPublicKeyCredentialParameters; -use crate::{Bytes, String, Vec}; +use crate::{Bytes, TryFromStrError, Vec}; use serde::{Deserialize, Serialize}; use serde_indexed::{DeserializeIndexed, SerializeIndexed}; @@ -10,11 +10,11 @@ pub type AuthenticatorInfo = Response; #[serde_indexed(offset = 1)] pub struct Response { // 0x01 - pub versions: Vec, 4>, + pub versions: Vec, // 0x02 #[serde(skip_serializing_if = "Option::is_none")] - pub extensions: Option, 4>>, + pub extensions: Option>, // 0x03 pub aaguid: Bytes<16>, @@ -44,7 +44,7 @@ pub struct Response { // 0x09 // FIDO_2_1 #[serde(skip_serializing_if = "Option::is_none")] - pub transports: Option, 4>>, + pub transports: Option>, // 0x0A // FIDO_2_1 @@ -135,7 +135,7 @@ impl Default for Response { #[derive(Debug)] pub struct ResponseBuilder { - pub versions: Vec, 4>, + pub versions: Vec, pub aaguid: Bytes<16>, } @@ -178,6 +178,120 @@ impl ResponseBuilder { } } +#[derive(Copy, Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] +#[serde(into = "&str", try_from = "&str")] +pub enum Version { + Fido2_0, + Fido2_1, + Fido2_1Pre, + U2fV2, +} + +impl Version { + const FIDO_2_0: &'static str = "FIDO_2_0"; + const FIDO_2_1: &'static str = "FIDO_2_1"; + const FIDO_2_1_PRE: &'static str = "FIDO_2_1_PRE"; + const U2F_V2: &'static str = "U2F_V2"; +} + +impl From for &str { + fn from(version: Version) -> Self { + match version { + Version::Fido2_0 => Version::FIDO_2_0, + Version::Fido2_1 => Version::FIDO_2_1, + Version::Fido2_1Pre => Version::FIDO_2_1_PRE, + Version::U2fV2 => Version::U2F_V2, + } + } +} + +impl TryFrom<&str> for Version { + type Error = TryFromStrError; + + fn try_from(s: &str) -> Result { + match s { + Self::FIDO_2_0 => Ok(Self::Fido2_0), + Self::FIDO_2_1 => Ok(Self::Fido2_1), + Self::FIDO_2_1_PRE => Ok(Self::Fido2_1Pre), + Self::U2F_V2 => Ok(Self::U2fV2), + _ => Err(TryFromStrError), + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] +#[serde(into = "&str", try_from = "&str")] +pub enum Extension { + CredProtect, + HmacSecret, + LargeBlobKey, +} + +impl Extension { + const CRED_PROTECT: &'static str = "credProtect"; + const HMAC_SECRET: &'static str = "hmac-secret"; + const LARGE_BLOB_KEY: &'static str = "largeBlobKey"; +} + +impl From for &str { + fn from(extension: Extension) -> Self { + match extension { + Extension::CredProtect => Extension::CRED_PROTECT, + Extension::HmacSecret => Extension::HMAC_SECRET, + Extension::LargeBlobKey => Extension::LARGE_BLOB_KEY, + } + } +} + +impl TryFrom<&str> for Extension { + type Error = TryFromStrError; + + fn try_from(s: &str) -> Result { + match s { + Self::CRED_PROTECT => Ok(Self::CredProtect), + Self::HMAC_SECRET => Ok(Self::HmacSecret), + Self::LARGE_BLOB_KEY => Ok(Self::LargeBlobKey), + _ => Err(TryFromStrError), + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] +#[serde(into = "&str", try_from = "&str")] +pub enum Transport { + Nfc, + Usb, +} + +impl Transport { + const NFC: &'static str = "nfc"; + const USB: &'static str = "usb"; +} + +impl From for &str { + fn from(transport: Transport) -> Self { + match transport { + Transport::Nfc => Transport::NFC, + Transport::Usb => Transport::USB, + } + } +} + +impl TryFrom<&str> for Transport { + type Error = TryFromStrError; + + fn try_from(s: &str) -> Result { + match s { + Self::NFC => Ok(Self::Nfc), + Self::USB => Ok(Self::Usb), + _ => Err(TryFromStrError), + } + } +} + #[derive(Copy, Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[non_exhaustive] #[serde(rename_all = "camelCase")] @@ -299,3 +413,168 @@ pub struct Certifications { #[serde(skip_serializing_if = "Option::is_none")] pub fido: Option, } + +#[cfg(test)] +mod tests { + use super::*; + use serde_test::{assert_ser_tokens, assert_tokens, Token}; + + #[test] + fn test_serde_version() { + let versions = [ + (Version::Fido2_0, "FIDO_2_0"), + (Version::Fido2_1, "FIDO_2_1"), + (Version::Fido2_1Pre, "FIDO_2_1_PRE"), + (Version::U2fV2, "U2F_V2"), + ]; + for (version, s) in versions { + assert_tokens(&version, &[Token::BorrowedStr(s)]); + } + } + + #[test] + fn test_serde_extension() { + let extensions = [ + (Extension::CredProtect, "credProtect"), + (Extension::HmacSecret, "hmac-secret"), + (Extension::LargeBlobKey, "largeBlobKey"), + ]; + for (extension, s) in extensions { + assert_tokens(&extension, &[Token::BorrowedStr(s)]); + } + } + + #[test] + fn test_serde_transport() { + let transports = [(Transport::Nfc, "nfc"), (Transport::Usb, "usb")]; + for (transport, s) in transports { + assert_tokens(&transport, &[Token::BorrowedStr(s)]); + } + } + + #[test] + fn test_serde_get_info_minimal() { + let versions = Vec::from_slice(&[Version::Fido2_0, Version::Fido2_1]).unwrap(); + let aaguid = Bytes::from_slice(&[0xff; 16]).unwrap(); + let response = ResponseBuilder { versions, aaguid }.build(); + assert_tokens( + &response, + &[ + Token::Map { len: Some(2) }, + Token::U64(1), + Token::Seq { len: Some(2) }, + Token::BorrowedStr("FIDO_2_0"), + Token::BorrowedStr("FIDO_2_1"), + Token::SeqEnd, + Token::U64(3), + Token::BorrowedBytes(&[0xff; 16]), + Token::MapEnd, + ], + ); + } + + #[test] + fn test_serde_get_info_default() { + // This corresponds to the response sent by the Nitrokey 3, see for example: + // https://github.com/Nitrokey/nitrokey-3-firmware/blob/0d7209f1f75354878c0cf3454055defe8372ed14/utils/fido2-mds/metadata/v4/metadata-nk3xn-v4.json + const AAGUID: &[u8] = &[ + 236, 153, 219, 25, 205, 31, 76, 6, 162, 169, 148, 15, 23, 166, 163, 11, + ]; + let versions = + Vec::from_slice(&[Version::U2fV2, Version::Fido2_0, Version::Fido2_1]).unwrap(); + let aaguid = Bytes::from_slice(AAGUID).unwrap(); + let mut options = CtapOptions::default(); + options.rk = true; + options.plat = Some(false); + options.client_pin = Some(false); + options.cred_mgmt = Some(true); + options.large_blobs = Some(false); + options.pin_uv_auth_token = Some(true); + let mut response = ResponseBuilder { versions, aaguid }.build(); + response.extensions = + Some(Vec::from_slice(&[Extension::CredProtect, Extension::HmacSecret]).unwrap()); + response.options = Some(options); + response.max_msg_size = Some(3072); + response.pin_protocols = Some(Vec::from_slice(&[1, 0]).unwrap()); + response.max_creds_in_list = Some(10); + response.max_cred_id_length = Some(255); + response.transports = Some(Vec::from_slice(&[Transport::Nfc, Transport::Usb]).unwrap()); + assert_ser_tokens( + &response, + &[ + Token::Map { len: Some(9) }, + // 0x01: versions + Token::U64(0x01), + Token::Seq { len: Some(3) }, + Token::BorrowedStr("U2F_V2"), + Token::BorrowedStr("FIDO_2_0"), + Token::BorrowedStr("FIDO_2_1"), + Token::SeqEnd, + // 0x02: extensions + Token::U64(0x02), + Token::Some, + Token::Seq { len: Some(2) }, + Token::BorrowedStr("credProtect"), + Token::BorrowedStr("hmac-secret"), + Token::SeqEnd, + // 0x03: aaguid + Token::U64(0x03), + Token::BorrowedBytes(AAGUID), + // 0x04: options + Token::U64(0x04), + Token::Some, + Token::Struct { + name: "CtapOptions", + len: 7, + }, + Token::BorrowedStr("rk"), + Token::Bool(true), + Token::BorrowedStr("up"), + Token::Bool(true), + Token::BorrowedStr("plat"), + Token::Some, + Token::Bool(false), + Token::BorrowedStr("credMgmt"), + Token::Some, + Token::Bool(true), + Token::BorrowedStr("clientPin"), + Token::Some, + Token::Bool(false), + Token::BorrowedStr("largeBlobs"), + Token::Some, + Token::Bool(false), + Token::BorrowedStr("pinUvAuthToken"), + Token::Some, + Token::Bool(true), + Token::StructEnd, + // 0x05: maxMsgSize + Token::U64(0x05), + Token::Some, + Token::U64(3072), + // 0x06: pinUvAuthProtocols + Token::U64(0x06), + Token::Some, + Token::Seq { len: Some(2) }, + Token::U8(1), + Token::U8(0), + Token::SeqEnd, + // 0x07: maxCredentialCountInList + Token::U64(0x07), + Token::Some, + Token::U64(10), + // 0x08: maxCredentialIdLength + Token::U64(0x08), + Token::Some, + Token::U64(255), + // 0x09: transports + Token::U64(0x09), + Token::Some, + Token::Seq { len: Some(2) }, + Token::BorrowedStr("nfc"), + Token::BorrowedStr("usb"), + Token::SeqEnd, + Token::MapEnd, + ], + ); + } +} diff --git a/src/ctap2/make_credential.rs b/src/ctap2/make_credential.rs index 7ba3ad8..7840bb9 100644 --- a/src/ctap2/make_credential.rs +++ b/src/ctap2/make_credential.rs @@ -1,4 +1,4 @@ -use crate::{Bytes, String, Vec}; +use crate::{Bytes, TryFromStrError, Vec}; use serde::{Deserialize, Serialize}; use serde_bytes::ByteArray; @@ -103,7 +103,7 @@ impl<'a> super::SerializeAttestedCredentialData for AttestedCredentialData<'a> { #[non_exhaustive] #[serde_indexed(offset = 1)] pub struct Response { - pub fmt: String<32>, + pub fmt: AttestationStatementFormat, pub auth_data: super::SerializedAuthenticatorData, #[serde(skip_serializing_if = "Option::is_none")] pub att_stmt: Option, @@ -115,7 +115,7 @@ pub struct Response { #[derive(Debug)] pub struct ResponseBuilder { - pub fmt: String<32>, + pub fmt: AttestationStatementFormat, pub auth_data: super::SerializedAuthenticatorData, } @@ -143,12 +143,38 @@ pub enum AttestationStatement { #[derive(Clone, Debug, Eq, PartialEq, Serialize)] #[non_exhaustive] -#[serde(untagged)] +#[serde(into = "&str", try_from = "&str")] pub enum AttestationStatementFormat { None, Packed, } +impl AttestationStatementFormat { + const NONE: &'static str = "none"; + const PACKED: &'static str = "packed"; +} + +impl From for &str { + fn from(format: AttestationStatementFormat) -> Self { + match format { + AttestationStatementFormat::None => AttestationStatementFormat::NONE, + AttestationStatementFormat::Packed => AttestationStatementFormat::PACKED, + } + } +} + +impl TryFrom<&str> for AttestationStatementFormat { + type Error = TryFromStrError; + + fn try_from(s: &str) -> Result { + match s { + Self::NONE => Ok(Self::None), + Self::PACKED => Ok(Self::Packed), + _ => Err(TryFromStrError), + } + } +} + #[derive(Clone, Debug, Eq, PartialEq, Serialize)] pub struct NoneAttestationStatement {} @@ -163,6 +189,7 @@ pub struct PackedAttestationStatement { #[cfg(test)] mod tests { use super::*; + use serde_test::{assert_ser_tokens, Token}; #[test] fn rp_entity_icon() { @@ -174,4 +201,15 @@ mod tests { let cbor = b"\xa4\x01X \xcd\xcd\xcd\xcd\xcd\xcd\xcd\xcd\xcd\xcd\xcd\xcd\xcd\xcd\xcd\xcd\xcd\xcd\xcd\xcd\xcd\xcd\xcd\xcd\xcd\xcd\xcd\xcd\xcd\xcd\xcd\xcd\x02\xa2bidx0make_credential_relying_party_entity.example.comcurlohttp://icon.png\x03\xa2bidX \x1d\x1d\x1d\x1d\x1d\x1d\x1d\x1d\x1d\x1d\x1d\x1d\x1d\x1d\x1d\x1d\x1d\x1d\x1d\x1d\x1d\x1d\x1d\x1d\x1d\x1d\x1d\x1d\x1d\x1d\x1d\x1ddnamedAdam\x04\x81\xa2calg&dtypejpublic-key"; let _request: Request = cbor_smol::cbor_deserialize(cbor.as_slice()).unwrap(); } + + #[test] + fn test_serde_attestation_statement_format() { + let formats = [ + (AttestationStatementFormat::None, "none"), + (AttestationStatementFormat::Packed, "packed"), + ]; + for (format, s) in formats { + assert_ser_tokens(&format, &[Token::BorrowedStr(s)]); + } + } } diff --git a/src/lib.rs b/src/lib.rs index 6763da2..feb2e1f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -35,6 +35,19 @@ pub mod webauthn; pub use ctap2::{Error, Result}; +use core::fmt::{self, Display, Formatter}; + +/// An error returned by the `TryFrom<&str>` implementation for enums if an invalid value is +/// provided. +#[derive(Debug)] +pub struct TryFromStrError; + +impl Display for TryFromStrError { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + "invalid enum value".fmt(f) + } +} + #[cfg(test)] mod tests {}