ctap2.1: add wire types for credBlob, minPinLength, authenticatorConfig

This commit is contained in:
Emanuele Cesena
2026-05-08 17:02:22 +02:00
committed by Robin Krahl
parent 94c5b5586e
commit e5bd6cabc9
8 changed files with 354 additions and 10 deletions
+4
View File
@@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
[Unreleased]: https://github.com/trussed-dev/ctap-types/compare/0.5.0...HEAD
- `ctap2::get_info`: Fix field order of the `CtapOptions` and `Certifications` structs to produce canonical CBOR
- Add support for missing CTAP 2.1 features:
- Add `AuthenticatorConfig` command.
- Add `credBlob` extension and split `make_credential::Extensions` into `ExtensionsInput` and `ExtensionsOutput`.
- Add `minPinLength` extension.
## [0.5.0] 2026-03-23
+60
View File
@@ -294,6 +294,66 @@ impl<'a> Arbitrary<'a> for webauthn::PublicKeyCredentialUserEntity {
}
}
// cannot be derived because of missing impl for &'a serde_bytes::Bytes (cred_blob).
// Mirrors the make_credential::Request handling of `pin_auth: Option<&Bytes>`.
impl<'a> Arbitrary<'a> for ctap2::make_credential::ExtensionsInput<'a> {
fn arbitrary(u: &mut Unstructured<'a>) -> Result<Self> {
let cred_protect = u.arbitrary()?;
let hmac_secret = u.arbitrary()?;
let large_blob_key = u.arbitrary()?;
#[cfg(feature = "third-party-payment")]
let third_party_payment = u.arbitrary()?;
let cred_blob = if bool::arbitrary(u)? {
Some(serde_bytes::Bytes::new(u.arbitrary()?))
} else {
None
};
Ok(Self {
cred_protect,
hmac_secret,
large_blob_key,
#[cfg(feature = "third-party-payment")]
third_party_payment,
cred_blob,
})
}
}
// cannot be derived because of missing impl for Vec<&'a str, N> (rp ID list).
impl<'a> Arbitrary<'a> for ctap2::authenticator_config::SubcommandParameters<'a> {
fn arbitrary(u: &mut Unstructured<'a>) -> Result<Self> {
let new_min_pin_length = u.arbitrary()?;
let min_pin_length_rp_ids = arbitrary_option(u, arbitrary_vec)?;
let force_change_pin = u.arbitrary()?;
Ok(Self {
new_min_pin_length,
min_pin_length_rp_ids,
force_change_pin,
})
}
}
// cannot be derived because of missing impl for &'a serde_bytes::Bytes (pin_auth)
// + Vec<_> in SubcommandParameters.
impl<'a> Arbitrary<'a> for ctap2::authenticator_config::Request<'a> {
fn arbitrary(u: &mut Unstructured<'a>) -> Result<Self> {
let sub_command = u.arbitrary()?;
let sub_command_params = u.arbitrary()?;
let pin_protocol = u.arbitrary()?;
let pin_auth = if bool::arbitrary(u)? {
Some(serde_bytes::Bytes::new(u.arbitrary()?))
} else {
None
};
Ok(Self {
sub_command,
sub_command_params,
pin_protocol,
pin_auth,
})
}
}
fn arbitrary_byte_array<'a, const N: usize>(u: &mut Unstructured<'_>) -> Result<&'a ByteArray<N>> {
let bytes: &[u8; N] = u.bytes(N)?.try_into().unwrap();
// TODO: conversion should be provided by serde_bytes
+24 -2
View File
@@ -11,6 +11,7 @@ use crate::{sizes::*, Bytes, TryFromStrError};
pub use crate::operation::{Operation, VendorOperation};
pub mod authenticator_config;
pub mod client_pin;
pub mod credential_management;
pub mod get_assertion;
@@ -45,6 +46,8 @@ pub enum Request<'a> {
Selection,
// 0xC
LargeBlobs(large_blobs::Request<'a>),
// 0xD
AuthenticatorConfig(authenticator_config::Request<'a>),
// vendor, to be embellished
// Q: how to handle the associated CBOR structures
Vendor(crate::operation::VendorOperation),
@@ -118,10 +121,14 @@ impl<'a> Request<'a> {
Request::LargeBlobs(cbor_deserialize(data).map_err(CtapMappingError::ParsingError)?)
}
Operation::Config => Request::AuthenticatorConfig(
cbor_deserialize(data).map_err(CtapMappingError::ParsingError)?,
),
// NB: FIDO Alliance "stole" 0x40 and 0x41, so these are not available
Operation::Vendor(vendor_operation) => Request::Vendor(vendor_operation),
Operation::BioEnrollment | Operation::PreviewBioEnrollment | Operation::Config => {
Operation::BioEnrollment | Operation::PreviewBioEnrollment => {
debug_now!("unhandled CBOR operation {:?}", operation);
return Err(CtapMappingError::InvalidCommand(op).into());
}
@@ -143,6 +150,7 @@ pub enum Response {
Selection,
CredentialManagement(credential_management::Response),
LargeBlobs(large_blobs::Response),
AuthenticatorConfig,
// Q: how to handle the associated CBOR structures
Vendor,
}
@@ -161,7 +169,7 @@ impl Response {
GetAssertion(response) | GetNextAssertion(response) => cbor_serialize(response, data),
CredentialManagement(response) => cbor_serialize(response, data),
LargeBlobs(response) => cbor_serialize(response, data),
Reset | Selection | Vendor => Ok([].as_slice()),
Reset | Selection | AuthenticatorConfig | Vendor => Ok([].as_slice()),
};
if let Ok(slice) = outcome {
*status = 0;
@@ -445,6 +453,11 @@ pub trait Authenticator {
Err(Error::InvalidCommand)
}
fn authenticator_config(&mut self, request: &authenticator_config::Request) -> Result<()> {
let _ = request;
Err(Error::InvalidCommand)
}
/// Dispatches the enum of possible requests into the appropriate trait method.
#[inline(never)]
fn call_ctap2(&mut self, request: &Request) -> Result<Response> {
@@ -533,6 +546,15 @@ pub trait Authenticator {
))
}
// 0xD
Request::AuthenticatorConfig(request) => {
debug_now!("CTAP2.CFG");
self.authenticator_config(request).inspect_err(|_e| {
debug!("error: {:?}", _e);
})?;
Ok(Response::AuthenticatorConfig)
}
// Not stable
Request::Vendor(op) => {
debug_now!("CTAP2.V");
+177
View File
@@ -0,0 +1,177 @@
//! `authenticatorConfig` (CTAP 2.1 §6.11), command `0x0D`.
//!
//! Carries `setMinPINLength`, `toggleAlwaysUv`, and (CTAP 2.3) `enableLongTouchForReset`
//! sub-commands, plus the spec-required `enableEnterpriseAttestation` and
//! `vendorPrototype` placeholders.
use serde_indexed::{DeserializeIndexed, SerializeIndexed};
use serde_repr::{Deserialize_repr, Serialize_repr};
use crate::Vec;
pub const MAX_MIN_PIN_LENGTH_RP_IDS: usize = 4;
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize_repr, Deserialize_repr)]
#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
#[non_exhaustive]
#[repr(u8)]
pub enum Subcommand {
EnableEnterpriseAttestation = 0x01,
ToggleAlwaysUv = 0x02,
SetMinPINLength = 0x03,
VendorPrototype = 0xff,
}
#[derive(Clone, Debug, Default, Eq, PartialEq, SerializeIndexed, DeserializeIndexed)]
#[non_exhaustive]
#[serde_indexed(offset = 1)]
pub struct SubcommandParameters<'a> {
// 0x01
#[serde(skip_serializing_if = "Option::is_none")]
pub new_min_pin_length: Option<u8>,
// 0x02
#[serde(skip_serializing_if = "Option::is_none")]
pub min_pin_length_rp_ids: Option<Vec<&'a str, MAX_MIN_PIN_LENGTH_RP_IDS>>,
// 0x03
#[serde(skip_serializing_if = "Option::is_none")]
pub force_change_pin: Option<bool>,
}
#[derive(Clone, Debug, Eq, PartialEq, SerializeIndexed, DeserializeIndexed)]
#[non_exhaustive]
#[serde_indexed(offset = 1)]
pub struct Request<'a> {
// 0x01
pub sub_command: Subcommand,
// 0x02
#[serde(skip_serializing_if = "Option::is_none")]
pub sub_command_params: Option<SubcommandParameters<'a>>,
// 0x03
#[serde(skip_serializing_if = "Option::is_none")]
pub pin_protocol: Option<u8>,
// 0x04
#[serde(skip_serializing_if = "Option::is_none")]
pub pin_auth: Option<&'a serde_bytes::Bytes>,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_test::{assert_de_tokens, assert_ser_tokens, assert_tokens, Token};
#[test]
fn test_serde_subcommand() {
for (sub, byte) in [
(Subcommand::EnableEnterpriseAttestation, 0x01),
(Subcommand::ToggleAlwaysUv, 0x02),
(Subcommand::SetMinPINLength, 0x03),
(Subcommand::VendorPrototype, 0xff),
] {
assert_tokens(&sub, &[Token::U8(byte)]);
}
}
#[test]
fn test_de_request_toggle_always_uv() {
// A bare ToggleAlwaysUv request has no params and (typically) no pinAuth.
let req = Request {
sub_command: Subcommand::ToggleAlwaysUv,
sub_command_params: None,
pin_protocol: None,
pin_auth: None,
};
assert_de_tokens(
&req,
&[
Token::Map { len: Some(1) },
Token::U64(1),
Token::U8(0x02),
Token::MapEnd,
],
);
}
#[test]
fn test_de_request_set_min_pin_length() {
let mut rp_ids = Vec::new();
rp_ids.push("login.example.com").unwrap();
let req = Request {
sub_command: Subcommand::SetMinPINLength,
sub_command_params: Some(SubcommandParameters {
new_min_pin_length: Some(6),
min_pin_length_rp_ids: Some(rp_ids),
force_change_pin: Some(true),
}),
pin_protocol: Some(2),
pin_auth: None,
};
// Deserialization side: serde_indexed reads each present key directly,
// without `Token::Some` wrappers (presence of the key encodes Some).
assert_de_tokens(
&req,
&[
Token::Map { len: Some(3) },
Token::U64(1),
Token::U8(0x03),
Token::U64(2),
Token::Map { len: Some(3) },
Token::U64(1),
Token::U8(6),
Token::U64(2),
Token::Seq { len: Some(1) },
Token::BorrowedStr("login.example.com"),
Token::SeqEnd,
Token::U64(3),
Token::Bool(true),
Token::MapEnd,
Token::U64(3),
Token::U8(2),
Token::MapEnd,
],
);
}
#[test]
fn test_ser_request_set_min_pin_length() {
let mut rp_ids = Vec::new();
rp_ids.push("login.example.com").unwrap();
let req = Request {
sub_command: Subcommand::SetMinPINLength,
sub_command_params: Some(SubcommandParameters {
new_min_pin_length: Some(6),
min_pin_length_rp_ids: Some(rp_ids),
force_change_pin: Some(true),
}),
pin_protocol: Some(2),
pin_auth: None,
};
// Serialization side: `Some` is emitted for each present optional.
assert_ser_tokens(
&req,
&[
Token::Map { len: Some(3) },
Token::U64(1),
Token::U8(0x03),
Token::U64(2),
Token::Some,
Token::Map { len: Some(3) },
Token::U64(1),
Token::Some,
Token::U8(6),
Token::U64(2),
Token::Some,
Token::Seq { len: Some(1) },
Token::BorrowedStr("login.example.com"),
Token::SeqEnd,
Token::U64(3),
Token::Some,
Token::Bool(true),
Token::MapEnd,
Token::U64(3),
Token::Some,
Token::U8(2),
Token::MapEnd,
],
);
}
}
+19
View File
@@ -24,6 +24,12 @@ pub struct HmacSecretInput {
#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
#[non_exhaustive]
pub struct ExtensionsInput {
/// `credBlob` (CTAP 2.1 §11.1) retrieval flag. `Some(true)` asks the
/// authenticator to return the blob stored at MakeCredential time.
#[serde(rename = "credBlob")]
#[serde(skip_serializing_if = "Option::is_none")]
pub cred_blob: Option<bool>,
#[serde(rename = "hmac-secret")]
#[serde(skip_serializing_if = "Option::is_none")]
pub hmac_secret: Option<HmacSecretInput>,
@@ -42,6 +48,13 @@ pub struct ExtensionsInput {
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ExtensionsOutput {
/// `credBlob` retrieval result: the bytes stored at MakeCredential time, up
/// to `maxCredBlobLength` (≥ 32). Absent if the platform did not request
/// `credBlob` or if no blob is associated with the credential.
#[serde(rename = "credBlob")]
#[serde(skip_serializing_if = "Option::is_none")]
pub cred_blob: Option<Bytes<MAX_CRED_BLOB_LENGTH>>,
#[serde(rename = "hmac-secret")]
#[serde(skip_serializing_if = "Option::is_none")]
// *either* enc(output1) *or* enc(output1 || output2)
@@ -57,6 +70,7 @@ impl ExtensionsOutput {
#[inline]
pub fn is_set(&self) -> bool {
let Self {
cred_blob,
hmac_secret,
#[cfg(feature = "third-party-payment")]
third_party_payment,
@@ -64,6 +78,9 @@ impl ExtensionsOutput {
if hmac_secret.is_some() {
return true;
}
if cred_blob.is_some() {
return true;
}
#[cfg(feature = "third-party-payment")]
if third_party_payment.is_some() {
return true;
@@ -181,6 +198,7 @@ mod tests {
pin_protocol: Some(1),
}),
large_blob_key: Some(true),
cred_blob: Some(true),
#[cfg(feature = "third-party-payment")]
third_party_payment: Some(true),
};
@@ -191,6 +209,7 @@ mod tests {
fn test_extensions_output_canonical() {
let output = ExtensionsOutput {
hmac_secret: Some([0xff; 80].try_into().unwrap()),
cred_blob: Some([0xff; 32].try_into().unwrap()),
#[cfg(feature = "third-party-payment")]
third_party_payment: Some(true),
};
+13 -2
View File
@@ -14,7 +14,7 @@ pub struct Response {
// 0x02
#[serde(skip_serializing_if = "Option::is_none")]
pub extensions: Option<Vec<Extension, 4>>,
pub extensions: Option<Vec<Extension, 6>>,
// 0x03
pub aaguid: Bytes<16>,
@@ -66,7 +66,7 @@ pub struct Response {
// FIDO_2_1
#[cfg(feature = "get-info-full")]
#[serde(skip_serializing_if = "Option::is_none")]
pub min_pin_length: Option<usize>,
pub min_pin_length: Option<u8>,
// 0x0E
// FIDO_2_1
@@ -250,15 +250,19 @@ impl TryFrom<&str> for Version {
#[serde(into = "&str", try_from = "&str")]
pub enum Extension {
CredProtect,
CredBlob,
HmacSecret,
LargeBlobKey,
MinPinLength,
ThirdPartyPayment,
}
impl Extension {
const CRED_PROTECT: &'static str = "credProtect";
const CRED_BLOB: &'static str = "credBlob";
const HMAC_SECRET: &'static str = "hmac-secret";
const LARGE_BLOB_KEY: &'static str = "largeBlobKey";
const MIN_PIN_LENGTH: &'static str = "minPinLength";
const THIRD_PARTY_PAYMENT: &'static str = "thirdPartyPayment";
}
@@ -266,8 +270,10 @@ impl From<Extension> for &str {
fn from(extension: Extension) -> Self {
match extension {
Extension::CredProtect => Extension::CRED_PROTECT,
Extension::CredBlob => Extension::CRED_BLOB,
Extension::HmacSecret => Extension::HMAC_SECRET,
Extension::LargeBlobKey => Extension::LARGE_BLOB_KEY,
Extension::MinPinLength => Extension::MIN_PIN_LENGTH,
Extension::ThirdPartyPayment => Extension::THIRD_PARTY_PAYMENT,
}
}
@@ -279,8 +285,10 @@ impl TryFrom<&str> for Extension {
fn try_from(s: &str) -> Result<Self, Self::Error> {
match s {
Self::CRED_PROTECT => Ok(Self::CredProtect),
Self::CRED_BLOB => Ok(Self::CredBlob),
Self::HMAC_SECRET => Ok(Self::HmacSecret),
Self::LARGE_BLOB_KEY => Ok(Self::LargeBlobKey),
Self::MIN_PIN_LENGTH => Ok(Self::MinPinLength),
Self::THIRD_PARTY_PAYMENT => Ok(Self::ThirdPartyPayment),
_ => Err(TryFromStrError),
}
@@ -465,8 +473,11 @@ mod tests {
fn test_serde_extension() {
let extensions = [
(Extension::CredProtect, "credProtect"),
(Extension::CredBlob, "credBlob"),
(Extension::HmacSecret, "hmac-secret"),
(Extension::LargeBlobKey, "largeBlobKey"),
(Extension::MinPinLength, "minPinLength"),
(Extension::ThirdPartyPayment, "thirdPartyPayment"),
];
for (extension, s) in extensions {
assert_tokens(&extension, &[Token::BorrowedStr(s)]);
+55 -6
View File
@@ -24,10 +24,20 @@ impl TryFrom<u8> for CredentialProtectionPolicy {
}
}
/// Extensions input to `authenticatorMakeCredential`.
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
#[non_exhaustive]
pub struct Extensions {
// `Arbitrary` impl lives in `crate::arbitrary` because `&'a serde_bytes::Bytes`
// (cred_blob) doesn't satisfy `Arbitrary<'_>` and the derive macro can't
// special-case it. Same pattern as `make_credential::Request<'a>`.
pub struct ExtensionsInput<'a> {
/// `credBlob` (CTAP 2.1 §11.1): platform-supplied bytes to associate with
/// the credential. Up to `maxCredBlobLength` (≥ 32) bytes per credential.
#[serde(rename = "credBlob")]
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(borrow)]
pub cred_blob: Option<&'a serde_bytes::Bytes>,
#[serde(rename = "credProtect")]
#[serde(skip_serializing_if = "Option::is_none")]
pub cred_protect: Option<u8>,
@@ -47,6 +57,32 @@ pub struct Extensions {
pub third_party_payment: Option<bool>,
}
/// Extensions output emitted in `authenticatorData.extensions` after
/// `authenticatorMakeCredential`.
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ExtensionsOutput {
/// `credBlob` storage acknowledgement: `Some(true)` if the platform-supplied
/// blob was stored, `Some(false)` if not stored, absent if the platform did
/// not request the extension.
#[serde(rename = "credBlob")]
#[serde(skip_serializing_if = "Option::is_none")]
pub cred_blob: Option<bool>,
#[serde(rename = "credProtect")]
#[serde(skip_serializing_if = "Option::is_none")]
pub cred_protect: Option<u8>,
#[serde(rename = "hmac-secret")]
#[serde(skip_serializing_if = "Option::is_none")]
pub hmac_secret: Option<bool>,
#[cfg(feature = "third-party-payment")]
#[serde(rename = "thirdPartyPayment")]
#[serde(skip_serializing_if = "Option::is_none")]
pub third_party_payment: Option<bool>,
}
#[derive(Clone, Debug, Eq, PartialEq, DeserializeIndexed)]
#[non_exhaustive]
#[serde_indexed(offset = 1)]
@@ -58,7 +94,7 @@ pub struct Request<'a> {
#[serde(skip_serializing_if = "Option::is_none")]
pub exclude_list: Option<Vec<PublicKeyCredentialDescriptorRef<'a>, 16>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub extensions: Option<Extensions>,
pub extensions: Option<ExtensionsInput<'a>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub options: Option<AuthenticatorOptions>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -74,7 +110,7 @@ pub struct Request<'a> {
pub type AttestationObject = Response;
pub type AuthenticatorData<'a> =
super::AuthenticatorData<'a, AttestedCredentialData<'a>, Extensions>;
super::AuthenticatorData<'a, AttestedCredentialData<'a>, ExtensionsOutput>;
// NOTE: This is not CBOR, it has a custom encoding...
// https://www.w3.org/TR/webauthn/#sec-attested-credential-data
@@ -179,13 +215,26 @@ mod tests {
}
#[test]
fn test_extensions_canonical() {
let extensions = Extensions {
fn test_extensions_input_canonical() {
let extensions = ExtensionsInput {
cred_protect: Some(1),
hmac_secret: Some(true),
large_blob_key: Some(true),
#[cfg(feature = "third-party-payment")]
third_party_payment: Some(true),
cred_blob: Some(serde_bytes::Bytes::new(b"1234")),
};
crate::test::assert_canonical_cbor(&extensions);
}
#[test]
fn test_extensions_output_canonical() {
let extensions = ExtensionsOutput {
cred_protect: Some(1),
hmac_secret: Some(true),
#[cfg(feature = "third-party-payment")]
third_party_payment: Some(true),
cred_blob: Some(true),
};
crate::test::assert_canonical_cbor(&extensions);
}
+2
View File
@@ -30,3 +30,5 @@ pub const THEORETICAL_MAX_MESSAGE_SIZE: usize = PACKET_SIZE - 7 + 128 * (PACKET_
pub const LARGE_BLOB_MAX_FRAGMENT_LENGTH: usize = 0;
#[cfg(feature = "large-blobs")]
pub const LARGE_BLOB_MAX_FRAGMENT_LENGTH: usize = 3008;
pub const MAX_CRED_BLOB_LENGTH: usize = 32;