Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

[Unreleased]: https://github.com/trussed-dev/ctap-types/compare/0.5.0...HEAD
[Unreleased]: https://github.com/trussed-dev/ctap-types/compare/0.6.0-rc.1...HEAD

-

## [0.6.0-rc.1] 2026-05-21

[0.6.0-rc.1]: https://github.com/trussed-dev/ctap-types/compare/0.5.0...0.6.0-rc.1

- `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.
- Add support for missing CTAP 2.2 features:
- Add `hmac-secret-mc` extension.
- Add support for CTAP 2.3:
- Add `Version::Fido2_3` variant.
- Add `Transport::SmartCard` variant.
- `ctap2::authenticator_config`: Add `Subcommand::EnableLongTouchForReset`.
- Add `EXTENSION_COUNT`, `TRANSPORT_COUNT` and `VERSION_COUNT` constants for the size of the corresponding enums.

## [0.5.0] 2026-03-23

Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "ctap-types"
version = "0.5.0"
version = "0.6.0-rc.1"
authors = ["Nicolas Stalder <n@stalder.io>", "The Trussed developers"]
edition = "2021"
license = "Apache-2.0 OR MIT"
Expand Down
62 changes: 62 additions & 0 deletions src/arbitrary.rs
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,68 @@ 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
};
let hmac_secret_mc = u.arbitrary()?;
Ok(Self {
cred_protect,
hmac_secret,
large_blob_key,
#[cfg(feature = "third-party-payment")]
third_party_payment,
cred_blob,
hmac_secret_mc,
})
}
}

// 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
Expand Down
26 changes: 24 additions & 2 deletions src/ctap2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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());
}
Expand All @@ -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,
}
Expand All @@ -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;
Expand Down Expand Up @@ -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> {
Expand Down Expand Up @@ -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");
Expand Down
179 changes: 179 additions & 0 deletions src/ctap2/authenticator_config.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
//! `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,
EnableLongTouchForReset = 0x04,
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::EnableLongTouchForReset, 0x04),
(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,
],
);
}
}
Loading
Loading