I'm building a peer-to-peer encrypted file transfer tool over LAN(for now). It uses a custom
handshake protocol (X25519 + ML-KEM-1024 hybrid key exchange, ML-DSA-87 for peer
identity signatures) no X.509, no PKIX, no certificate authorities. I'm using
libcrux-ml-dsa as the signing implementation.
What I ran into
My signing code looks roughly like this:
pub fn sign(&self, message: &[u8]) -> Result<[u8; SIGNATURE_LEN]> {
// self.signing_key is a Box<[u8; 4896]> that I zeroize on Drop.
let typed_sk = libcrux_ml_dsa::MLDSASigningKey::new(*self.signing_key); // <- 4896-byte stack copy
let mut randomness = Zeroizing::new([0u8; 32]);
crate::crypto::csprng().fill_bytes(&mut *randomness);
let sig = ml_dsa_87::sign(&typed_sk, message, ctx, *randomness)?;
// typed_sk drops here but without a Zeroize impl
// sit on the stack until the next frame overwrites them.
Ok(*sig.as_ref())
}
I zeroize my own Box<[u8; 4896]> on drop via the zeroize crate, so the
long-lived copy is taken care of. But MLDSASigningKey doesn't implement Zeroize,
which means the short-lived stack copy that gets created for the duration of the
sign() call isn't explicitly cleared when it drops. It'll eventually be overwritten
by normal stack reuse, but there's no guarantee on when.
Why I care
RFC 9881 §9 specifically calls out side-channel risk in ML-DSA signing:
"Some amount of side-channel leakage has been demonstrated in parts of the signing
algorithm (specifically the bit-unpacking function), from which a demonstration of
key recovery has been made over a large sample of signatures."
It also recommends randomized signing to harden against fault and hardware side-channel
attacks and I'm already doing that (hedged signing with a 256-bit RBG string). But the
complementary defense and then not leaving key material in memory longer than necessary is
something I can't currently achieve without Zeroize support on MLDSASigningKey.
In practice the window is tiny: it's the time between drop(typed_sk) and the stack
frame getting reused. I'm not claiming this is an imminent catastrophe. But in a
security-sensitive context, "tiny window" isn't the same as "no window", and I'd like
to be able to document that I've done everything possible on my side.
What would help me
Would it be feasible to add Zeroize (and ideally ZeroizeOnDrop) for
MLDSASigningKey and the equivalent types for ML-DSA-44 and ML-DSA-65 while
we're at it?
Something like:
impl zeroize::Zeroize for MLDSASigningKey {
fn zeroize(&mut self) {
self.0.zeroize(); // assuming the inner type is a plain byte array
}
}
impl zeroize::ZeroizeOnDrop for MLDSASigningKey {}
I just don't want to touch the internals blindly without knowing whether there are constraints I'm
not aware of (inner representation, etc.).
A few questions
- Is this something that's already on your radar, or intentionally left out for a
reason I'm missing?
- Does the formally verified internals story complicate adding a
Zeroize impl, or
is it straightforward at the boundary?
- Is there anything I should know for this context?
Environment
libcrux-ml-dsa: 0.0.8 (feature mldsa87)
libcrux-secrets: 0.0.5
- Rust: 1.85.1
Thanks for your time and again.
I'm building a peer-to-peer encrypted file transfer tool over LAN(for now). It uses a custom
handshake protocol (X25519 + ML-KEM-1024 hybrid key exchange, ML-DSA-87 for peer
identity signatures) no X.509, no PKIX, no certificate authorities. I'm using
libcrux-ml-dsaas the signing implementation.What I ran into
My signing code looks roughly like this:
I zeroize my own
Box<[u8; 4896]>on drop via thezeroizecrate, so thelong-lived copy is taken care of. But
MLDSASigningKeydoesn't implementZeroize,which means the short-lived stack copy that gets created for the duration of the
sign()call isn't explicitly cleared when it drops. It'll eventually be overwrittenby normal stack reuse, but there's no guarantee on when.
Why I care
RFC 9881 §9 specifically calls out side-channel risk in ML-DSA signing:
It also recommends randomized signing to harden against fault and hardware side-channel
attacks and I'm already doing that (hedged signing with a 256-bit RBG string). But the
complementary defense and then not leaving key material in memory longer than necessary is
something I can't currently achieve without
Zeroizesupport onMLDSASigningKey.In practice the window is tiny: it's the time between
drop(typed_sk)and the stackframe getting reused. I'm not claiming this is an imminent catastrophe. But in a
security-sensitive context, "tiny window" isn't the same as "no window", and I'd like
to be able to document that I've done everything possible on my side.
What would help me
Would it be feasible to add
Zeroize(and ideallyZeroizeOnDrop) forMLDSASigningKeyand the equivalent types for ML-DSA-44 and ML-DSA-65 whilewe're at it?
Something like:
I just don't want to touch the internals blindly without knowing whether there are constraints I'm
not aware of (inner representation, etc.).
A few questions
reason I'm missing?
Zeroizeimpl, oris it straightforward at the boundary?
Environment
libcrux-ml-dsa: 0.0.8 (feature mldsa87)libcrux-secrets: 0.0.5Thanks for your time and again.