Skip to content

Commit 0a719ce

Browse files
feat(ctap2): add authenticatorLargeBlobs command (0x0C)
Implements the wire-level model and protocol method for CTAP 2.1 `authenticatorLargeBlobs` (command code 0x0C, spec §6.10). This is the device-side primitive the platform uses to fetch and update the authenticator's serialized largeBlobArray. Includes only the `get` request shape so far; `set` is reserved for a follow-up that will also handle the pinUvAuthParam binding required for writes. Refs: CTAP 2.2 §6.10.
1 parent 4f484fb commit 0a719ce

5 files changed

Lines changed: 153 additions & 2 deletions

File tree

libwebauthn/src/proto/ctap2/cbor/request.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ use crate::proto::ctap2::model::Ctap2MakeCredentialRequest;
88
use crate::proto::ctap2::Ctap2AuthenticatorConfigRequest;
99
use crate::proto::ctap2::Ctap2BioEnrollmentRequest;
1010
use crate::proto::ctap2::Ctap2CredentialManagementRequest;
11+
use crate::proto::ctap2::Ctap2LargeBlobsRequest;
1112
use crate::webauthn::Error;
1213

1314
#[derive(Debug, Clone, PartialEq)]
@@ -106,3 +107,13 @@ impl TryFrom<&Ctap2CredentialManagementRequest> for CborRequest {
106107
})
107108
}
108109
}
110+
111+
impl TryFrom<&Ctap2LargeBlobsRequest> for CborRequest {
112+
type Error = Error;
113+
fn try_from(request: &Ctap2LargeBlobsRequest) -> Result<CborRequest, Error> {
114+
Ok(CborRequest {
115+
command: Ctap2CommandCode::AuthenticatorLargeBlobs,
116+
encoded_data: cbor::to_vec(&request)?,
117+
})
118+
}
119+
}

libwebauthn/src/proto/ctap2/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ pub use model::{
3232
pub use model::{
3333
Ctap2GetAssertionRequest, Ctap2GetAssertionResponse, Ctap2GetAssertionResponseExtensions,
3434
};
35+
pub use model::{Ctap2LargeBlobsRequest, Ctap2LargeBlobsResponse};
3536
pub use model::{
3637
Ctap2MakeCredentialRequest, Ctap2MakeCredentialResponse, Ctap2MakeCredentialsResponseExtensions,
3738
};

libwebauthn/src/proto/ctap2/model.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ pub use credential_management::{
4444
Ctap2CredentialData, Ctap2CredentialManagementMetadata, Ctap2CredentialManagementRequest,
4545
Ctap2CredentialManagementResponse, Ctap2RPData,
4646
};
47+
mod large_blobs;
48+
pub use large_blobs::{Ctap2LargeBlobsRequest, Ctap2LargeBlobsResponse};
4749

4850
#[derive(Debug, IntoPrimitive, TryFromPrimitive, Copy, Clone, PartialEq, Serialize_repr)]
4951
#[repr(u8)]
@@ -58,6 +60,7 @@ pub enum Ctap2CommandCode {
5860
AuthenticatorCredentialManagement = 0x0A,
5961
AuthenticatorCredentialManagementPreview = 0x41,
6062
AuthenticatorSelection = 0x0B,
63+
AuthenticatorLargeBlobs = 0x0C,
6164
AuthenticatorConfig = 0x0D,
6265
}
6366

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
//! CTAP 2.1 `authenticatorLargeBlobs` command (cmd `0x0C`).
2+
//!
3+
//! Spec: CTAP 2.2 §6.10. The command provides paginated read/write access to a
4+
//! single authenticator-stored byte array (the "serialized largeBlobArray"),
5+
//! which is shared across all credentials on the device. Individual blobs are
6+
//! encrypted with the per-credential `largeBlobKey`.
7+
//!
8+
//! This module implements the wire-level request/response model. Higher-level
9+
//! helpers that drive paginated reads (and, in the future, writes) live in the
10+
//! `ops::webauthn::large_blob` module.
11+
12+
use serde_bytes::ByteBuf;
13+
use serde_indexed::{DeserializeIndexed, SerializeIndexed};
14+
15+
/// `authenticatorLargeBlobs` request parameters. The map shape is defined in
16+
/// CTAP 2.2 §6.10.1. Per the spec the platform sends EITHER `get` (read path)
17+
/// OR `set` (write path) per request; the unused field MUST be absent.
18+
#[derive(Debug, Clone, SerializeIndexed)]
19+
pub struct Ctap2LargeBlobsRequest {
20+
/// `get` (0x01): when present, requests up to this many bytes starting at
21+
/// `offset`. Mutually exclusive with `set`.
22+
#[serde(skip_serializing_if = "Option::is_none")]
23+
#[serde(index = 0x01)]
24+
pub get: Option<u32>,
25+
26+
/// `set` (0x02): when present, this chunk of the serialized largeBlobArray
27+
/// is written at `offset`. Mutually exclusive with `get`.
28+
#[serde(skip_serializing_if = "Option::is_none")]
29+
#[serde(index = 0x02)]
30+
pub set: Option<ByteBuf>,
31+
32+
/// `offset` (0x03): byte offset for the read/write window. Required.
33+
#[serde(index = 0x03)]
34+
pub offset: u32,
35+
36+
/// `length` (0x04): on the first `set` request only, the total length of
37+
/// the serialized array being written.
38+
#[serde(skip_serializing_if = "Option::is_none")]
39+
#[serde(index = 0x04)]
40+
pub length: Option<u32>,
41+
42+
/// `pinUvAuthParam` (0x05): required for `set`; absent for `get` unless
43+
/// the device requires it.
44+
#[serde(skip_serializing_if = "Option::is_none")]
45+
#[serde(index = 0x05)]
46+
pub pin_uv_auth_param: Option<ByteBuf>,
47+
48+
/// `pinUvAuthProtocol` (0x06): version selected by the platform.
49+
#[serde(skip_serializing_if = "Option::is_none")]
50+
#[serde(index = 0x06)]
51+
pub pin_uv_auth_protocol: Option<u32>,
52+
}
53+
54+
impl Ctap2LargeBlobsRequest {
55+
/// Build a `get` request for `length` bytes at `offset`.
56+
pub fn new_get(offset: u32, length: u32) -> Self {
57+
Self {
58+
get: Some(length),
59+
set: None,
60+
offset,
61+
length: None,
62+
pin_uv_auth_param: None,
63+
pin_uv_auth_protocol: None,
64+
}
65+
}
66+
}
67+
68+
/// `authenticatorLargeBlobs` response. The map carries a single optional
69+
/// `config` field (CTAP 2.2 §6.10.2), which holds the requested slice of the
70+
/// serialized largeBlobArray on the read path. Empty on the write path.
71+
#[cfg_attr(test, derive(SerializeIndexed))]
72+
#[derive(Debug, Default, Clone, DeserializeIndexed)]
73+
pub struct Ctap2LargeBlobsResponse {
74+
/// `config` (0x01): the partial blob array. None on write responses, or on
75+
/// devices that omit the field for an empty array.
76+
#[serde(skip_serializing_if = "Option::is_none")]
77+
#[serde(index = 0x01)]
78+
pub config: Option<ByteBuf>,
79+
}
80+
81+
#[cfg(test)]
82+
mod tests {
83+
use super::*;
84+
use crate::proto::ctap2::cbor;
85+
86+
#[test]
87+
fn get_request_round_trips_through_cbor() {
88+
let req = Ctap2LargeBlobsRequest::new_get(0, 1024);
89+
let bytes = cbor::to_vec(&req).expect("serialize");
90+
// CTAP 2.2 §6.10.1 mandates an integer-keyed map; verify that our
91+
// encoding produces one. Tag 0xa3 = map of 3 items (get=1, offset=3,
92+
// implicit absence of set/length/pinUvAuthParam/pinUvAuthProtocol).
93+
assert_eq!(
94+
bytes[0], 0xa2,
95+
"expected CBOR map of two items, got {:#x}",
96+
bytes[0]
97+
);
98+
// Sanity: re-decoding via the loose Value parser should yield the same
99+
// logical content.
100+
let value: cbor::Value = cbor::from_slice(&bytes).expect("deserialize");
101+
let cbor::Value::Map(map) = value else {
102+
panic!("expected map");
103+
};
104+
assert_eq!(map.len(), 2);
105+
}
106+
}

libwebauthn/src/proto/ctap2/protocol.rs

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ use super::model::Ctap2ClientPinResponse;
1313
use super::{
1414
Ctap2AuthenticatorConfigRequest, Ctap2BioEnrollmentRequest, Ctap2ClientPinRequest,
1515
Ctap2CredentialManagementRequest, Ctap2CredentialManagementResponse, Ctap2GetAssertionRequest,
16-
Ctap2GetAssertionResponse, Ctap2GetInfoResponse, Ctap2MakeCredentialRequest,
17-
Ctap2MakeCredentialResponse,
16+
Ctap2GetAssertionResponse, Ctap2GetInfoResponse, Ctap2LargeBlobsRequest,
17+
Ctap2LargeBlobsResponse, Ctap2MakeCredentialRequest, Ctap2MakeCredentialResponse,
1818
};
1919

2020
const TIMEOUT_GET_INFO: Duration = Duration::from_millis(250);
@@ -73,6 +73,11 @@ pub trait Ctap2 {
7373
request: &Ctap2CredentialManagementRequest,
7474
timeout: Duration,
7575
) -> Result<Ctap2CredentialManagementResponse, Error>;
76+
async fn ctap2_large_blobs(
77+
&mut self,
78+
request: &Ctap2LargeBlobsRequest,
79+
timeout: Duration,
80+
) -> Result<Ctap2LargeBlobsResponse, Error>;
7681
}
7782

7883
#[async_trait]
@@ -272,4 +277,29 @@ where
272277
Ok(Ctap2CredentialManagementResponse::default())
273278
}
274279
}
280+
281+
#[instrument(skip_all)]
282+
async fn ctap2_large_blobs(
283+
&mut self,
284+
request: &Ctap2LargeBlobsRequest,
285+
timeout: Duration,
286+
) -> Result<Ctap2LargeBlobsResponse, Error> {
287+
trace!(?request);
288+
self.cbor_send(&request.try_into()?, timeout).await?;
289+
let cbor_response = self.cbor_recv(timeout).await?;
290+
match cbor_response.status_code {
291+
CtapError::Ok => (),
292+
error => return Err(Error::Ctap(error)),
293+
};
294+
if let Some(data) = cbor_response.data {
295+
let ctap_response = parse_cbor!(Ctap2LargeBlobsResponse, &data);
296+
debug!("CTAP2 LargeBlobs successful");
297+
trace!(?ctap_response);
298+
Ok(ctap_response)
299+
} else {
300+
// Write responses have no body. Same serde_indexed workaround as
301+
// credential_management above.
302+
Ok(Ctap2LargeBlobsResponse::default())
303+
}
304+
}
275305
}

0 commit comments

Comments
 (0)