Skip to content

Commit c0aadea

Browse files
Test canonical encoding of serialized structs
This patch makes sure that all structs use the canonical CBOR encoding as defined and required by § 8 Message Encoding of the CTAP specification [0]. Structs using serde_indexed::SerializeIndexed are not tested because in serde-indexed v0.1.1, the correct order is automatically enforced. [0] https://fidoalliance.org/specs/fido-v2.2-ps-20250714/fido-client-to-authenticator-protocol-v2.2-ps-20250714.html#message-encoding
1 parent 7d8122b commit c0aadea

9 files changed

Lines changed: 305 additions & 3 deletions

File tree

.github/workflows/ci.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ jobs:
6464
override: true
6565
- name: Run tests
6666
run: cargo test
67+
- name: Run tests with all features
68+
run: cargo test --all-features
6769

6870
clippy:
6971
name: Run clippy

Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ serde_bytes = { version = "0.11.14", default-features = false }
2222
serde_repr = "0.1"
2323

2424
[dev-dependencies]
25+
cbor-smol = "0.5"
26+
ciborium = "0.2"
27+
hex = "0.4"
2528
hex-literal = "0.4.1"
2629
serde_test = "1.0.176"
2730

src/ctap2.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -552,3 +552,18 @@ impl<A: Authenticator> crate::Rpc<Error, Request<'_>, Response> for A {
552552
self.call_ctap2(request)
553553
}
554554
}
555+
556+
#[cfg(test)]
557+
mod tests {
558+
use super::*;
559+
560+
#[test]
561+
fn test_authenticator_options_canonical() {
562+
let options = AuthenticatorOptions {
563+
uv: Some(true),
564+
up: Some(true),
565+
rk: Some(true),
566+
};
567+
crate::test::assert_canonical_cbor(&options);
568+
}
569+
}

src/ctap2/get_assertion.rs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,3 +163,43 @@ impl ResponseBuilder {
163163
#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
164164
#[non_exhaustive]
165165
pub struct UnsignedExtensionOutputs {}
166+
167+
#[cfg(test)]
168+
mod tests {
169+
use super::*;
170+
171+
#[test]
172+
fn test_extensions_input_canonical() {
173+
let input = ExtensionsInput {
174+
hmac_secret: Some(HmacSecretInput {
175+
key_agreement: EcdhEsHkdf256PublicKey {
176+
x: [0xff; 32].try_into().unwrap(),
177+
y: [0xff; 32].try_into().unwrap(),
178+
},
179+
salt_enc: [0xff; 80].try_into().unwrap(),
180+
salt_auth: [0xff; 32].try_into().unwrap(),
181+
pin_protocol: Some(1),
182+
}),
183+
large_blob_key: Some(true),
184+
#[cfg(feature = "third-party-payment")]
185+
third_party_payment: Some(true),
186+
};
187+
crate::test::assert_canonical_cbor(&input);
188+
}
189+
190+
#[test]
191+
fn test_extensions_output_canonical() {
192+
let output = ExtensionsOutput {
193+
hmac_secret: Some([0xff; 80].try_into().unwrap()),
194+
#[cfg(feature = "third-party-payment")]
195+
third_party_payment: Some(true),
196+
};
197+
crate::test::assert_canonical_cbor(&output);
198+
}
199+
200+
#[test]
201+
fn test_unsigned_extension_outputs_canonical() {
202+
let outputs = UnsignedExtensionOutputs {};
203+
crate::test::assert_canonical_cbor(&outputs);
204+
}
205+
}

src/ctap2/get_info.rs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -606,4 +606,55 @@ mod tests {
606606
],
607607
);
608608
}
609+
610+
#[test]
611+
fn test_ctap_options_canonical() {
612+
let options = CtapOptions {
613+
#[cfg(feature = "get-info-full")]
614+
ep: Some(true),
615+
rk: false,
616+
up: true,
617+
uv: Some(true),
618+
plat: Some(true),
619+
#[cfg(feature = "get-info-full")]
620+
uv_acfg: Some(true),
621+
#[cfg(feature = "get-info-full")]
622+
always_uv: Some(true),
623+
cred_mgmt: Some(true),
624+
#[cfg(feature = "get-info-full")]
625+
authnr_cfg: Some(true),
626+
#[cfg(feature = "get-info-full")]
627+
bio_enroll: Some(true),
628+
client_pin: Some(true),
629+
large_blobs: Some(true),
630+
#[cfg(feature = "get-info-full")]
631+
uv_bio_enroll: Some(true),
632+
pin_uv_auth_token: Some(true),
633+
#[cfg(feature = "get-info-full")]
634+
set_min_pin_length: Some(true),
635+
#[cfg(feature = "get-info-full")]
636+
make_cred_uv_not_rqd: Some(true),
637+
#[cfg(feature = "get-info-full")]
638+
credential_mgmt_preview: Some(true),
639+
#[cfg(feature = "get-info-full")]
640+
user_verification_mgmt_preview: Some(true),
641+
#[cfg(feature = "get-info-full")]
642+
no_mc_ga_permissions_with_client_pin: Some(true),
643+
};
644+
crate::test::assert_canonical_cbor(&options);
645+
}
646+
647+
#[test]
648+
#[cfg(feature = "get-info-full")]
649+
fn test_certifications_canonical() {
650+
let certifications = Certifications {
651+
fips_cmpv2: Some(1),
652+
fips_cmpv3: Some(1),
653+
fips_cmpv2_phy: Some(1),
654+
fips_cmpv3_phy: Some(1),
655+
cc_eal: Some(1),
656+
fido: Some(1),
657+
};
658+
crate::test::assert_canonical_cbor(&certifications);
659+
}
609660
}

src/ctap2/make_credential.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,4 +177,22 @@ mod tests {
177177
assert_ser_tokens(&format, &[Token::BorrowedStr(s)]);
178178
}
179179
}
180+
181+
#[test]
182+
fn test_extensions_canonical() {
183+
let extensions = Extensions {
184+
cred_protect: Some(1),
185+
hmac_secret: Some(true),
186+
large_blob_key: Some(true),
187+
#[cfg(feature = "third-party-payment")]
188+
third_party_payment: Some(true),
189+
};
190+
crate::test::assert_canonical_cbor(&extensions);
191+
}
192+
193+
#[test]
194+
fn test_unsigned_extension_outputs_canonical() {
195+
let outputs = UnsignedExtensionOutputs {};
196+
crate::test::assert_canonical_cbor(&outputs);
197+
}
180198
}

src/lib.rs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ pub mod ctap2;
3333
pub(crate) mod operation;
3434
pub use cbor_smol as serde;
3535
pub mod sizes;
36+
#[cfg(test)]
37+
mod test;
3638
pub mod webauthn;
3739

3840
pub use ctap2::{Error, Result};
@@ -50,9 +52,6 @@ impl Display for TryFromStrError {
5052
}
5153
}
5254

53-
#[cfg(test)]
54-
mod tests {}
55-
5655
/// Call a remote procedure with a request, receive a response, maybe.
5756
pub trait Rpc<Error, Request, Response> {
5857
fn call(&mut self, request: &Request) -> core::result::Result<Response, Error>;

src/test.rs

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
use ciborium::Value;
2+
use serde::Serialize;
3+
4+
fn parse_argument(additional_info: u8, value: &[u8]) -> (u64, usize) {
5+
match additional_info {
6+
0..24 => (additional_info.into(), 0),
7+
24 => (value[0].into(), 1),
8+
25 => {
9+
let (argument, _) = value.split_first_chunk().unwrap();
10+
(u16::from_be_bytes(*argument).into(), argument.len())
11+
}
12+
26 => {
13+
let (argument, _) = value.split_first_chunk().unwrap();
14+
(u32::from_be_bytes(*argument).into(), argument.len())
15+
}
16+
27 => {
17+
let (argument, _) = value.split_first_chunk().unwrap();
18+
(u64::from_be_bytes(*argument), argument.len())
19+
}
20+
28..=30 => panic!("reserved additional info: {additional_info}"),
21+
31 => panic!("indefinite length items are not allowed in canonical CBOR"),
22+
32.. => panic!("illegal additional info: {additional_info}"),
23+
}
24+
}
25+
26+
fn parse_value<'a>(data: &'a [u8], path: &str) -> &'a [u8] {
27+
println!("Checking value {path}");
28+
29+
assert!(data.len() > 0, "CBOR value must not be empty");
30+
let mut offset = 1;
31+
let major_type = (data[0] & 0b11100000) >> 5;
32+
let additional_info = data[0] & 0b00011111;
33+
let (argument, n) = parse_argument(additional_info, &data[offset..]);
34+
offset += n;
35+
36+
// if the argument encodes an integer, it must be encoded as short as possible
37+
if major_type <= 5 {
38+
let expected = if let Ok(argument) = u8::try_from(argument) {
39+
if argument <= 23 {
40+
argument
41+
} else {
42+
24
43+
}
44+
} else if argument <= u16::MAX.into() {
45+
25
46+
} else if argument <= u32::MAX.into() {
47+
26
48+
} else {
49+
27
50+
};
51+
assert_eq!(
52+
additional_info, expected,
53+
"integer value {argument} must use additional info {expected}"
54+
);
55+
}
56+
57+
let argument = usize::try_from(argument).unwrap();
58+
match major_type {
59+
0 | 1 | 7 => {
60+
// no additional restrictions, no additional data
61+
}
62+
2 => {
63+
// byte strings: no additional restrictions, but additional data
64+
offset += argument;
65+
}
66+
3 => {
67+
// text strings: must be valid UTF-8
68+
let s = &data[offset..][..argument];
69+
assert!(
70+
str::from_utf8(s).is_ok(),
71+
"text must be valid UTF-8: {}",
72+
String::from_utf8_lossy(s)
73+
);
74+
offset += argument;
75+
}
76+
4 => {
77+
// arrays: must have valid items
78+
for i in 0..argument {
79+
let item = parse_value(&data[offset..], &format!("{path}[{i}]"));
80+
offset += item.len();
81+
}
82+
}
83+
5 => {
84+
// maps: must have valid items and be sorted
85+
let mut last_key = None;
86+
for i in 0..argument {
87+
let key = parse_value(&data[offset..], &format!("{path}[{i}].key"));
88+
offset += key.len();
89+
90+
let parsed_key: Value = ciborium::from_reader(key).unwrap();
91+
println!("{path}[{i}].key = {parsed_key:?}");
92+
93+
let value = parse_value(&data[offset..], &format!("{path}[{i}].value"));
94+
offset += value.len();
95+
96+
if let Some(last_key) = last_key {
97+
assert!(
98+
last_key < key,
99+
"map keys must be in lexicographical order: keys[{}] = {}, keys[{i}] = {}",
100+
i - 1,
101+
hex::encode(last_key),
102+
hex::encode(key)
103+
);
104+
}
105+
last_key = Some(key);
106+
}
107+
}
108+
6 => {
109+
panic!("tags are not allowed in canonical CBOR");
110+
}
111+
8.. => panic!("illegal major type: {major_type}"),
112+
};
113+
114+
&data[..offset]
115+
}
116+
117+
pub fn assert_canonical_cbor<T: Serialize>(object: &T) {
118+
let mut buffer = [0; 1024];
119+
let serialized = cbor_smol::cbor_serialize(&object, &mut buffer).unwrap();
120+
let value = parse_value(&serialized, "root");
121+
assert_eq!(
122+
value.len(),
123+
serialized.len(),
124+
"CBOR data must not contain trailing bytes"
125+
);
126+
}

src/webauthn.rs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,4 +288,52 @@ mod tests {
288288
assert_eq!(truncate::<5>(s), s);
289289
assert_eq!(truncate::<64>(s), s);
290290
}
291+
292+
#[test]
293+
fn test_rp_entity_canonical() {
294+
let rp_entity = PublicKeyCredentialRpEntity {
295+
id: String::try_from("test").unwrap(),
296+
name: Some(String::try_from("test").unwrap()),
297+
icon: Some(Icon),
298+
};
299+
crate::test::assert_canonical_cbor(&rp_entity);
300+
}
301+
302+
#[test]
303+
fn test_user_entity_canonical() {
304+
let user_entity = PublicKeyCredentialUserEntity {
305+
id: Bytes::try_from(b"test").unwrap(),
306+
icon: Some(String::try_from("test").unwrap()),
307+
name: Some(String::try_from("test").unwrap()),
308+
display_name: Some(String::try_from("test").unwrap()),
309+
};
310+
crate::test::assert_canonical_cbor(&user_entity);
311+
}
312+
313+
#[test]
314+
fn test_cred_parameters_canonical() {
315+
let cred_parameters = PublicKeyCredentialParameters {
316+
alg: -1,
317+
key_type: String::try_from("public-key").unwrap(),
318+
};
319+
crate::test::assert_canonical_cbor(&cred_parameters);
320+
}
321+
322+
#[test]
323+
fn test_cred_descriptor_canonical() {
324+
let cred_descriptor = PublicKeyCredentialDescriptor {
325+
id: Bytes::try_from(b"test").unwrap(),
326+
key_type: String::try_from("test").unwrap(),
327+
};
328+
crate::test::assert_canonical_cbor(&cred_descriptor);
329+
}
330+
331+
#[test]
332+
fn test_cred_descriptor_ref_canonical() {
333+
let cred_descriptor_ref = PublicKeyCredentialDescriptorRef {
334+
id: serde_bytes::Bytes::new(b"test"),
335+
key_type: "test",
336+
};
337+
crate::test::assert_canonical_cbor(&cred_descriptor_ref);
338+
}
291339
}

0 commit comments

Comments
 (0)