Skip to content

Commit 29e94b3

Browse files
committed
ctap2.3: implement ML-DSA-44 (COSE alg -50, behind mldsa44 feature)
1 parent d42c1b8 commit 29e94b3

7 files changed

Lines changed: 184 additions & 92 deletions

File tree

Cargo.toml

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,14 @@ disable-reset-time-window = []
3737
# enables support for a large-blob array longer than 1024 bytes
3838
chunked = ["dep:trussed-chunked"]
3939

40+
# enables ML-DSA-44 (FIPS 204, COSE alg -50). When off, the variant, the
41+
# `pubKeyCredParams = -50` arm, the GetInfo algorithm entry, and the
42+
# `Mldsa44` Trussed-core requirement are all elided. Off by default.
43+
# Also bumps `MAX_PACKED_SIG_LENGTH`, x5c element size, and authData buffer
44+
# in ctap-types so packed attestation can carry the 2420-byte ML-DSA-44 sig
45+
# alongside the larger 1322-byte COSE_Key in authData.
46+
mldsa44 = ["trussed-core/mldsa44", "ctap-types/mldsa44"]
47+
4048
log-all = []
4149
log-none = []
4250
log-trace = []
@@ -52,6 +60,14 @@ cbc = { version = "0.1.2", features = ["alloc"] }
5260
ciborium = "0.2.2"
5361
ciborium-io = "0.2.2"
5462
cipher = "0.4.4"
63+
# The lib's `mldsa44` feature normally turns on both `trussed-core/mldsa44`
64+
# and `ctap-types/mldsa44` together; they keep `Message`'s inner Bytes size
65+
# (1024 → 2048) and `x5c`'s inner Bytes size in lockstep. The dev-dep
66+
# `trussed` below pulls in `trussed-core/mldsa44` unconditionally for the
67+
# test runner, so we need `ctap-types/mldsa44` here too — otherwise the
68+
# `x5c.push(cert)` sites in `ctap2.rs` see `Bytes<2048>` going into a
69+
# `Bytes<1024>` slot and `cargo test` fails to compile.
70+
ctap-types = { version = "0.5", features = ["mldsa44"] }
5571
ctaphid = { version = "0.3.1", default-features = false }
5672
ctaphid-dispatch = "0.4"
5773
delog = { version = "0.1.6", features = ["std-log"] }
@@ -67,7 +83,7 @@ rand = "0.8.4"
6783
rand_chacha = "0.3"
6884
sha2 = "0.10"
6985
serde_test = "1.0.176"
70-
trussed = { git = "https://github.com/trussed-dev/trussed.git", rev = "0f8df68be879acdde1f8cf428c11e5d29692a47b", features = ["virt"] }
86+
trussed = { git = "https://github.com/trussed-dev/trussed.git", rev = "0f8df68be879acdde1f8cf428c11e5d29692a47b", features = ["mldsa44", "virt"] }
7187
trussed-staging = { git = "https://github.com/trussed-dev/trussed-staging.git", tag = "v0.4.0", features = ["chunked", "hkdf", "virt", "fs-info"] }
7288
trussed-usbip = { git = "https://github.com/trussed-dev/pc-usbip-runner.git", rev = "017921df0930707c4af68882ccb1f8b3f1bbf7c5", default-features = false, features = ["ctaphid"] }
7389
usbd-ctaphid = "0.4"

src/ctap1.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,12 @@ impl<UP: UserPresence, T: TrussedRequirements> Authenticator for crate::Authenti
140140
}
141141
};
142142

143+
// U2F register's `attestation_certificate` is fixed at `Bytes<1024>`.
144+
// Real attestation certs comfortably fit; we lift it from the
145+
// trussed `Message`-typed read so it works regardless of how the
146+
// mldsa44 feature sizes that Message buffer.
147+
let cert =
148+
ctap_types::Bytes::<1024>::try_from(&*cert).map_err(|_| Error::NotEnoughMemory)?;
143149
Ok(register::Response::new(
144150
0x05,
145151
&cose_key,

src/ctap2.rs

Lines changed: 114 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,10 @@ impl<UP: UserPresence, T: TrussedRequirements> Authenticator for crate::Authenti
114114
algorithms
115115
.push(KnownPublicKeyCredentialParameters { alg: ED_DSA })
116116
.unwrap();
117+
#[cfg(feature = "mldsa44")]
118+
algorithms
119+
.push(KnownPublicKeyCredentialParameters { alg: -50 })
120+
.ok();
117121
let algorithms = FilteredPublicKeyCredentialParameters(algorithms);
118122

119123
let remaining_discoverable_credentials = self.estimate_remaining();
@@ -131,8 +135,8 @@ impl<UP: UserPresence, T: TrussedRequirements> Authenticator for crate::Authenti
131135
response.max_cred_id_length = Some(ctap_types::sizes::MAX_CREDENTIAL_ID_LENGTH);
132136
response.algorithms = Some(algorithms);
133137
response.firmware_version = Some(self.config.firmware_version as usize);
134-
response.remaining_discoverable_credentials = remaining_discoverable_credentials
135-
.map(|count| count as usize);
138+
response.remaining_discoverable_credentials =
139+
remaining_discoverable_credentials.map(|count| count as usize);
136140
response.max_cred_blob_length = Some(constants::MAX_CRED_BLOB_LENGTH);
137141
response.min_pin_length = Some(self.state.persistent.min_pin_length() as usize);
138142
response.force_pin_change = Some(self.state.persistent.force_pin_change());
@@ -233,17 +237,21 @@ impl<UP: UserPresence, T: TrussedRequirements> Authenticator for crate::Authenti
233237

234238
// 7. check pubKeyCredParams algorithm is valid + supported COSE identifier
235239

240+
// CTAP §6.1.2: walk pubKeyCredParams in order and pick the first
241+
// supported algorithm. The guard on every arm matters — without
242+
// it later entries silently overwrite earlier ones and we end
243+
// up with last-match instead of first-match (caught by the
244+
// ML-DSA-44 vs EdDSA preference test).
236245
let mut algorithm: Option<SigningAlgorithm> = None;
237246
for param in parameters.pub_key_cred_params.0.iter() {
247+
if algorithm.is_some() {
248+
break;
249+
}
238250
match param.alg {
239-
-7 => {
240-
if algorithm.is_none() {
241-
algorithm = Some(SigningAlgorithm::P256);
242-
}
243-
}
244-
-8 => {
245-
algorithm = Some(SigningAlgorithm::Ed25519);
246-
}
251+
-7 => algorithm = Some(SigningAlgorithm::P256),
252+
-8 => algorithm = Some(SigningAlgorithm::Ed25519),
253+
#[cfg(feature = "mldsa44")]
254+
-50 => algorithm = Some(SigningAlgorithm::MlDsa44),
247255
_ => {}
248256
}
249257
}
@@ -556,7 +564,7 @@ impl<UP: UserPresence, T: TrussedRequirements> Authenticator for crate::Authenti
556564
};
557565
// debug_now!("authData = {:?}", &authenticator_data);
558566

559-
let serialized_auth_data = authenticator_data.serialize()?;
567+
let mut serialized_auth_data = authenticator_data.serialize()?;
560568

561569
// select attestation format or use packed attestation as default
562570
let att_stmt_fmt = parameters
@@ -570,20 +578,28 @@ impl<UP: UserPresence, T: TrussedRequirements> Authenticator for crate::Authenti
570578
Some(AttestationStatement::None(NoneAttestationStatement {}))
571579
}
572580
SupportedAttestationFormat::Packed => {
573-
let mut commitment = Bytes::<1024>::new();
574-
commitment
575-
.extend_from_slice(&serialized_auth_data)
576-
.map_err(|_| Error::Other)?;
577-
commitment
581+
// Build the "commitment" (auth_data ‖ cdh) IN PLACE inside
582+
// `serialized_auth_data` to avoid a separate 2.3 KB local.
583+
// With `mldsa44`, `SerializedAuthenticatorData` has 2048 B
584+
// capacity, comfortably fitting the ~1577 B auth_data + 32 B
585+
// cdh = ~1609 B. After signing we truncate to restore the
586+
// original auth_data length so the buffer can be moved into
587+
// `response.auth_data`.
588+
let auth_data_len = serialized_auth_data.len();
589+
serialized_auth_data
578590
.extend_from_slice(parameters.client_data_hash)
579591
.map_err(|_| Error::Other)?;
580592

581593
let (attestation_key, attestation_algorithm) = attestation_maybe
582594
.as_ref()
583595
.map(|attestation| (attestation.0, SigningAlgorithm::P256))
584596
.unwrap_or((private_key, algorithm));
585-
let signature =
586-
attestation_algorithm.sign(&mut self.trussed, attestation_key, &commitment);
597+
let signature = attestation_algorithm.sign(
598+
&mut self.trussed,
599+
attestation_key,
600+
&serialized_auth_data,
601+
);
602+
serialized_auth_data.truncate(auth_data_len);
587603
let packed = PackedAttestationStatement {
588604
alg: attestation_algorithm.into(),
589605
sig: Bytes::try_from(&*signature).map_err(|_| Error::Other)?,
@@ -607,6 +623,11 @@ impl<UP: UserPresence, T: TrussedRequirements> Authenticator for crate::Authenti
607623
info_now!("deleted private credential key: {}", _success);
608624
}
609625

626+
// Write fields directly into the caller-provided slot — avoids
627+
// the 6 KB Response by-value return + move through the dispatch
628+
// chain. `serialized_auth_data` still lives transiently on this
629+
// function's stack (≈2 KB); future work could write it directly
630+
// into `response.auth_data` via a mutable serialize sink.
610631
response.fmt = att_stmt_fmt
611632
.map(From::from)
612633
.unwrap_or(AttestationStatementFormat::None);
@@ -682,10 +703,7 @@ impl<UP: UserPresence, T: TrussedRequirements> Authenticator for crate::Authenti
682703

683704
match request.sub_command {
684705
Subcommand::SetMinPINLength => self.config_set_min_pin_length(request),
685-
Subcommand::ToggleAlwaysUv => self
686-
.state
687-
.persistent
688-
.toggle_always_uv(&mut self.trussed),
706+
Subcommand::ToggleAlwaysUv => self.state.persistent.toggle_always_uv(&mut self.trussed),
689707
// CTAP 2.3 §6.11.5: long-touch is the only reset gesture we
690708
// support, hard-wired on. The subcommand is therefore a no-op:
691709
// already enabled, so we just acknowledge.
@@ -1238,10 +1256,7 @@ impl<UP: UserPresence, T: TrussedRequirements> crate::Authenticator<UP, T> {
12381256
let mut owned = heapless::Vec::new();
12391257
for id in rp_ids {
12401258
owned
1241-
.push(
1242-
heapless::String::try_from(*id)
1243-
.map_err(|_| Error::PinPolicyViolation)?,
1244-
)
1259+
.push(heapless::String::try_from(*id).map_err(|_| Error::PinPolicyViolation)?)
12451260
.map_err(|_| Error::PinPolicyViolation)?;
12461261
}
12471262
self.state
@@ -1788,12 +1803,7 @@ impl<UP: UserPresence, T: TrussedRequirements> crate::Authenticator<UP, T> {
17881803
if extensions.cred_blob.unwrap_or(false) {
17891804
// Spec: if the extension was requested but no blob is associated
17901805
// with the credential, return an empty byte string (not absent).
1791-
output.cred_blob = Some(
1792-
credential
1793-
.cred_blob()
1794-
.cloned()
1795-
.unwrap_or_else(Bytes::new),
1796-
);
1806+
output.cred_blob = Some(credential.cred_blob().cloned().unwrap_or_else(Bytes::new));
17971807
}
17981808

17991809
Ok(output.is_set().then_some(output))
@@ -1882,66 +1892,53 @@ impl<UP: UserPresence, T: TrussedRequirements> crate::Authenticator<UP, T> {
18821892
extensions: extensions_output,
18831893
};
18841894

1885-
let serialized_auth_data = authenticator_data.serialize()?;
1895+
let mut serialized_auth_data = authenticator_data.serialize()?;
18861896

1887-
let mut commitment = Bytes::<1024>::new();
1888-
commitment
1889-
.extend_from_slice(&serialized_auth_data)
1890-
.map_err(|_| Error::Other)?;
1891-
commitment
1897+
// Build commitment in place: append client_data_hash to serialized_auth_data,
1898+
// sign over the concatenation, then truncate back. Mirrors the elision
1899+
// done in make_credential — avoids a separate Bytes<1024> commitment buffer.
1900+
let auth_data_len = serialized_auth_data.len();
1901+
serialized_auth_data
18921902
.extend_from_slice(&data.client_data_hash)
18931903
.map_err(|_| Error::Other)?;
18941904

18951905
let signing_algorithm =
18961906
SigningAlgorithm::try_from(credential.algorithm()).map_err(|_| Error::Other)?;
1897-
let signature =
1898-
Bytes::try_from(&*signing_algorithm.sign(&mut self.trussed, key, &commitment)).unwrap();
1907+
let signature = Bytes::try_from(
1908+
&*signing_algorithm.sign(&mut self.trussed, key, &serialized_auth_data),
1909+
)
1910+
.unwrap();
18991911

1900-
// select preferred format or skip attestation statement
1912+
// select preferred format or skip attestation statement.
1913+
//
1914+
// The Packed branch's `PackedAttestationStatement` carries a
1915+
// `Bytes<MAX_PACKED_SIG_LENGTH>` sig (2436 B) and an x5c
1916+
// `Bytes<MAX_X5C_CERT_LENGTH>` cert (2052 B) — ~4.5 KB total
1917+
// with `mldsa44`. Outline the construction into a `#[inline(never)]`
1918+
// helper so those temporaries live in the helper's frame, not
1919+
// in `assert_with_credential`'s (which is preserved on the lower
1920+
// task's stack during the 72 KB libcrux_sign call above us).
19011921
let att_stmt_fmt = data
19021922
.attestation_formats_preference
19031923
.as_ref()
19041924
.and_then(SupportedAttestationFormat::select);
1905-
let att_stmt = if let Some(format) = att_stmt_fmt {
1906-
match format {
1907-
SupportedAttestationFormat::None => {
1908-
Some(AttestationStatement::None(NoneAttestationStatement {}))
1909-
}
1910-
SupportedAttestationFormat::Packed => {
1911-
let (attestation_maybe, _) = self.state.identity.attestation(&mut self.trussed);
1912-
let (signature, attestation_algorithm) = {
1913-
if let Some(attestation) = attestation_maybe.as_ref() {
1914-
let signing_algorithm = SigningAlgorithm::P256;
1915-
let signature = signing_algorithm.sign(
1916-
&mut self.trussed,
1917-
attestation.0,
1918-
&commitment,
1919-
);
1920-
(
1921-
Bytes::try_from(&*signature).map_err(|_| Error::Other)?,
1922-
signing_algorithm.into(),
1923-
)
1924-
} else {
1925-
(signature.clone(), credential.algorithm())
1926-
}
1927-
};
1928-
let packed = PackedAttestationStatement {
1929-
alg: attestation_algorithm,
1930-
sig: signature,
1931-
x5c: attestation_maybe.as_ref().map(|attestation| {
1932-
// See: https://www.w3.org/TR/webauthn-2/#sctn-packed-attestation-cert-requirements
1933-
let cert = attestation.1.clone();
1934-
let mut x5c = Vec::new();
1935-
x5c.push(cert).ok();
1936-
x5c
1937-
}),
1938-
};
1939-
Some(AttestationStatement::Packed(packed))
1940-
}
1925+
match att_stmt_fmt {
1926+
Some(SupportedAttestationFormat::None) => {
1927+
response.att_stmt = Some(AttestationStatement::None(NoneAttestationStatement {}));
19411928
}
1942-
} else {
1943-
None
1944-
};
1929+
Some(SupportedAttestationFormat::Packed) => {
1930+
self.build_packed_att_stmt(
1931+
&serialized_auth_data,
1932+
&signature,
1933+
credential.algorithm(),
1934+
response,
1935+
)?;
1936+
}
1937+
None => {}
1938+
}
1939+
1940+
// Truncate back so the response carries only authData (without cdh).
1941+
serialized_auth_data.truncate(auth_data_len);
19451942

19461943
if !is_rk {
19471944
syscall!(self.trussed.delete(key));
@@ -1951,7 +1948,6 @@ impl<UP: UserPresence, T: TrussedRequirements> crate::Authenticator<UP, T> {
19511948
response.auth_data = serialized_auth_data;
19521949
response.signature = signature;
19531950
response.number_of_credentials = num_credentials;
1954-
response.att_stmt = att_stmt;
19551951

19561952
// User with empty IDs are ignored for compatibility
19571953
if is_rk {
@@ -1982,6 +1978,43 @@ impl<UP: UserPresence, T: TrussedRequirements> crate::Authenticator<UP, T> {
19821978
Ok(())
19831979
}
19841980

1981+
/// Build a `Packed` attestation statement for `get_assertion` and
1982+
/// write it into `response.att_stmt`. Outlined so its ~4.5 KB worth
1983+
/// of temporaries (`Bytes<MAX_PACKED_SIG_LENGTH>` re-sign output plus
1984+
/// the x5c cert clone) live here instead of inflating the caller's
1985+
/// preserved stack while libcrux_sign runs above us.
1986+
#[inline(never)]
1987+
fn build_packed_att_stmt(
1988+
&mut self,
1989+
message: &[u8],
1990+
fallback_sig: &Bytes<{ ctap_types::sizes::MAX_PACKED_SIG_LENGTH }>,
1991+
fallback_alg: i32,
1992+
response: &mut ctap2::get_assertion::Response,
1993+
) -> Result<()> {
1994+
let (attestation_maybe, _) = self.state.identity.attestation(&mut self.trussed);
1995+
let (sig, alg) = if let Some(attestation) = attestation_maybe.as_ref() {
1996+
let signing_algorithm = SigningAlgorithm::P256;
1997+
let att_sig = signing_algorithm.sign(&mut self.trussed, attestation.0, message);
1998+
(
1999+
Bytes::try_from(&*att_sig).map_err(|_| Error::Other)?,
2000+
signing_algorithm.into(),
2001+
)
2002+
} else {
2003+
(fallback_sig.clone(), fallback_alg)
2004+
};
2005+
response.att_stmt = Some(AttestationStatement::Packed(PackedAttestationStatement {
2006+
alg,
2007+
sig,
2008+
x5c: attestation_maybe.as_ref().map(|attestation| {
2009+
let cert = attestation.1.clone();
2010+
let mut x5c = Vec::new();
2011+
x5c.push(cert).ok();
2012+
x5c
2013+
}),
2014+
}));
2015+
Ok(())
2016+
}
2017+
19852018
#[inline(never)]
19862019
fn delete_resident_key_by_user_id(
19872020
&mut self,

src/ctap2/credential_management.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,13 @@ where
407407
SigningAlgorithm::Ed25519 => PublicKey::Ed25519Key(
408408
ctap_types::serde::cbor_deserialize(&cose_public_key).unwrap(),
409409
),
410+
// `cosey::PublicKey` doesn't have an ML-DSA variant (yet); the
411+
// credential itself works for GA, but `credentialManagement` can't
412+
// serialise its public key via this path. Skip rather than crash —
413+
// the platform will see `Err(InvalidCredential)` and can fall back
414+
// to GA + signature verification to obtain the key.
415+
#[cfg(feature = "mldsa44")]
416+
SigningAlgorithm::MlDsa44 => return Err(Error::InvalidCredential),
410417
};
411418
let cred_protect = match credential.cred_protect {
412419
Some(x) => Some(x),

src/ctap2/pin.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -428,7 +428,7 @@ impl SharedSecret {
428428
}
429429

430430
#[must_use]
431-
pub fn encrypt<T: CryptoClient>(&self, trussed: &mut T, data: &[u8]) -> Bytes<1024> {
431+
pub fn encrypt<T: CryptoClient>(&self, trussed: &mut T, data: &[u8]) -> Message {
432432
let key_id = self.aes_key_id();
433433
let iv = self.generate_iv(trussed);
434434
let mut ciphertext =
@@ -444,7 +444,7 @@ impl SharedSecret {
444444
}
445445

446446
#[must_use]
447-
fn wrap<T: CryptoClient>(&self, trussed: &mut T, key: KeyId) -> Bytes<1024> {
447+
fn wrap<T: CryptoClient>(&self, trussed: &mut T, key: KeyId) -> Message {
448448
let wrapping_key = self.aes_key_id();
449449
let iv = self.generate_iv(trussed);
450450
let mut wrapped_key = syscall!(trussed.wrap_key(
@@ -465,7 +465,7 @@ impl SharedSecret {
465465
}
466466

467467
#[must_use]
468-
pub fn decrypt<T: CryptoClient>(&self, trussed: &mut T, data: &[u8]) -> Option<Bytes<1024>> {
468+
pub fn decrypt<T: CryptoClient>(&self, trussed: &mut T, data: &[u8]) -> Option<Message> {
469469
let key_id = self.aes_key_id();
470470
let (iv, data) = match self {
471471
Self::V1 { .. } => (Default::default(), data),

0 commit comments

Comments
 (0)