From dba967adf5d0c3640254d18bc5bfebad496ce910 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Sun, 10 May 2026 21:00:37 +0100 Subject: [PATCH 1/2] fix(ctap1): emit extended-length Le in ApduRequest::raw_long U2F REGISTER and AUTHENTICATE are Case 4 APDUs per FIDO U2F Raw Message Formats v1.2 sections 3 and 4. The encoder previously stopped after the Lc + data field, silently dropping the response_max_length that callers were setting. Strict authenticators reject these as Case 3 (no response expected) requests. Append a 2-byte big-endian Le when response_max_length is Some, with values >= 65536 encoded as the 0x0000 wildcard. --- libwebauthn/src/proto/ctap1/apdu/request.rs | 73 +++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/libwebauthn/src/proto/ctap1/apdu/request.rs b/libwebauthn/src/proto/ctap1/apdu/request.rs index 9aae6a38..f8b9a41a 100644 --- a/libwebauthn/src/proto/ctap1/apdu/request.rs +++ b/libwebauthn/src/proto/ctap1/apdu/request.rs @@ -106,6 +106,14 @@ impl ApduRequest { raw.write_u24::(0)?; } + // Per ISO 7816-4 and FIDO U2F Raw Message Formats §3, §4: when a + // response is expected, append a 2-byte extended-length Le. + // Le=0x0000 is the wildcard meaning "up to 65536 bytes". + if let Some(le) = self.response_max_length { + let le_field = if le >= 0x1_0000 { 0u16 } else { le as u16 }; + raw.write_u16::(le_field)?; + } + Ok(raw) } } @@ -210,4 +218,69 @@ mod tests { ); assert_eq!(&serialized[7..519], data.as_slice()); } + + #[test] + fn apdu_raw_long_with_data_and_le() { + // Case 4 Extended APDU: header + Lc(3 BE) + data + Le(2 BE). + let data: Vec = vec![0xAA, 0xBB, 0xCC]; + let apdu = ApduRequest::new(0x01, 0x02, 0x03, Some(&data), Some(0x100)); + assert_eq!( + apdu.raw_long().unwrap(), + [ + 0x00, 0x01, 0x02, 0x03, // CLA, INS, P1, P2 + 0x00, 0x00, 0x03, // Lc = 3 (extended) + 0xAA, 0xBB, 0xCC, // payload + 0x01, 0x00, // Le = 256 (big-endian) + ], + ); + } + + #[test] + fn apdu_raw_long_no_data_with_le() { + // Case 2 Extended APDU: header + Lc=0 (3 BE) + Le(2 BE). + let apdu = ApduRequest::new(0x01, 0x02, 0x03, None, Some(0x100)); + assert_eq!( + apdu.raw_long().unwrap(), + [ + 0x00, 0x01, 0x02, 0x03, // CLA, INS, P1, P2 + 0x00, 0x00, 0x00, // Lc = 0 (extended) + 0x01, 0x00, // Le = 256 (big-endian) + ], + ); + } + + #[test] + fn apdu_raw_long_with_data_and_le_wildcard() { + // Le >= 65536 encodes as 0x0000 wildcard per ISO 7816-4. + let data: Vec = vec![0xAA]; + let apdu = ApduRequest::new(0x01, 0x02, 0x03, Some(&data), Some(0x1_0000)); + let serialized = apdu.raw_long().unwrap(); + let trailing = &serialized[serialized.len() - 2..]; + assert_eq!(trailing, &[0x00, 0x00], "Le wildcard for max length"); + } + + #[test] + fn apdu_raw_long_register_request_is_case_4() { + // Mirrors the encoding produced by `From<&Ctap1RegisterRequest> for ApduRequest`. + let mut payload = vec![0x11u8; 32]; // challenge + payload.extend(vec![0x22u8; 32]); // app id hash + let apdu = ApduRequest::new( + 0x01, // U2F_REGISTER + 0x03, // CONTROL_BYTE_ENFORCE_UP_AND_SIGN + 0x00, + Some(&payload), + Some(0x100), + ); + let serialized = apdu.raw_long().unwrap(); + // Header (4) + extended Lc (3) + payload (64) + extended Le (2) + assert_eq!(serialized.len(), 4 + 3 + 64 + 2); + // Must terminate with the 2-byte Le; otherwise it is Case 3 and + // strict authenticators reject it. + let trailing = &serialized[serialized.len() - 2..]; + assert_eq!( + trailing, + &[0x01, 0x00], + "REGISTER must be Case 4 with Le=256 (extended)", + ); + } } From a3e58c20dd284ef324ac21478aa145a9723f4cd0 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Sun, 10 May 2026 21:00:55 +0100 Subject: [PATCH 2/2] fix(nfc): propagate response_max_length as Le on CTAP1 APDUs The From<&ApduRequest> for Command impl was calling Command::new_with_payload, which produced a Case 3 APDU and dropped the response_max_length field. U2F REGISTER and AUTHENTICATE require a Case 4 APDU per FIDO U2F NFC section 3.1. Switch to new_with_payload_le so Le is included when the request asks for a response. For the typical short-form request (le=256), apdu-core encodes Le as a single 0x00 byte. --- libwebauthn/src/transport/nfc/commands.rs | 50 +++++++++++++++++++---- 1 file changed, 43 insertions(+), 7 deletions(-) diff --git a/libwebauthn/src/transport/nfc/commands.rs b/libwebauthn/src/transport/nfc/commands.rs index b011d2ea..7307e2f0 100644 --- a/libwebauthn/src/transport/nfc/commands.rs +++ b/libwebauthn/src/transport/nfc/commands.rs @@ -83,13 +83,20 @@ impl<'a> From> for Command<'a> { impl<'a> From<&'a ApduRequest> for Command<'a> { fn from(cmd: &'a ApduRequest) -> Self { - Self::new_with_payload( - 0, // CLA - cmd.ins, - cmd.p1, - cmd.p2, - cmd.data.as_deref().unwrap_or(&[]), - ) + // U2F REGISTER and AUTHENTICATE are Case 4 APDUs per FIDO U2F Raw + // Message Formats §3 / §4 and FIDO U2F NFC §3.1: the encoder must + // propagate `response_max_length` as Le so the authenticator knows + // a response payload is expected. Strict implementations reject + // requests missing Le. + let payload = cmd.data.as_deref().unwrap_or(&[]); + match cmd.response_max_length { + Some(le) => { + // Short-form Le: APDU_SHORT_LE (256) is mapped to a single + // byte 0x00 by apdu-core (`l as u8`). + Self::new_with_payload_le(CLA_DEFAULT, cmd.ins, cmd.p1, cmd.p2, le as u16, payload) + } + None => Self::new_with_payload(CLA_DEFAULT, cmd.ins, cmd.p1, cmd.p2, payload), + } } } @@ -99,3 +106,32 @@ impl_into_vec!(CtapMsgCommand<'a>); pub fn command_ctap_msg(has_more: bool, payload: &[u8]) -> CtapMsgCommand<'_> { CtapMsgCommand::new(has_more, payload) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn apdu_request_with_le_encodes_as_case_4_short() { + // U2F REGISTER-style request: 64 byte payload, Le = 256. + // Short form encodes Le as a single 0x00 byte (since 256 as u8 = 0). + let payload = vec![0xAAu8; 64]; + let request = ApduRequest::new(0x01, 0x03, 0x00, Some(&payload), Some(0x100)); + let bytes: Vec = Command::from(&request).into(); + assert_eq!(bytes[0..5], [0x00, 0x01, 0x03, 0x00, 0x40]); // header + Lc=64 + assert_eq!(&bytes[5..69], payload.as_slice()); + assert_eq!(bytes[69], 0x00, "Le must be present (0x00 = 256)"); + assert_eq!(bytes.len(), 70); + } + + #[test] + fn apdu_request_without_le_encodes_as_case_3() { + // Genuine Case 3: no Le. + let payload = vec![0xAAu8; 64]; + let request = ApduRequest::new(0x01, 0x03, 0x00, Some(&payload), None); + let bytes: Vec = Command::from(&request).into(); + assert_eq!(bytes[0..5], [0x00, 0x01, 0x03, 0x00, 0x40]); // header + Lc=64 + assert_eq!(&bytes[5..69], payload.as_slice()); + assert_eq!(bytes.len(), 69, "no trailing Le"); + } +}