[WIP] Security work While on holiday

This commit is contained in:
Alex 2026-08-12 22:45:28 +02:00
commit 7f0231e3f1
Signed by: alex
SSH key fingerprint: SHA256:D1+Ub8o0v4K5y1JNivW8IxEOelqLSvPmUzBbDIoZkRQ
109 changed files with 19694 additions and 5210 deletions

View file

@ -18,7 +18,7 @@ sha2 = { version = "0.11", optional = true }
zeroize = { version = "1.9", features = ["derive"] }
thiserror = "1"
base64 = "0.22"
rand_core = { version = "0.10.1" }
rand_core = { version = "0.6", features = ["getrandom"] }
rand = "0.10.2"
getrandom = "0.4.3"
mlkem-tls = { version = "0.2", optional = true }

View file

@ -6,6 +6,15 @@ use zeroize::Zeroizing;
#[cfg(any(feature = "chacha20poly1305", feature = "aes-gcm"))]
use getrandom::fill;
/// Authentication-tag length shared by the supported AEAD constructions.
pub const AUTH_TAG_LEN: usize = 16;
/// Nonce length stored at the front of an XChaCha20-Poly1305 output.
pub const XCHACHA20POLY1305_NONCE_LEN: usize = 24;
/// Nonce length stored at the front of an AES-256-GCM output.
pub const AES256GCM_NONCE_LEN: usize = 12;
pub trait AeadEncrypt {
fn encrypt(&self, plaintext: &[u8], aad: &[u8]) -> Result<Vec<u8>, CryptoError>;
}
@ -27,12 +36,12 @@ fn prepend_nonce(nonce: &[u8], ciphertext: &mut Vec<u8>) -> Vec<u8> {
}
#[cfg(feature = "chacha20poly1305")]
pub struct ChaCha20Poly1305 {
pub struct XChaCha20Poly1305 {
key: Zeroizing<[u8; 32]>,
}
#[cfg(feature = "chacha20poly1305")]
impl ChaCha20Poly1305 {
impl XChaCha20Poly1305 {
pub fn new(key: [u8; 32]) -> Self {
Self {
key: Zeroizing::new(key),
@ -41,7 +50,7 @@ impl ChaCha20Poly1305 {
}
#[cfg(feature = "chacha20poly1305")]
impl AeadEncrypt for ChaCha20Poly1305 {
impl AeadEncrypt for XChaCha20Poly1305 {
fn encrypt(&self, plaintext: &[u8], aad: &[u8]) -> Result<Vec<u8>, CryptoError> {
use chacha20poly1305::XChaCha20Poly1305;
use chacha20poly1305::XNonce;
@ -50,7 +59,7 @@ impl AeadEncrypt for ChaCha20Poly1305 {
let key = chacha20poly1305::Key::from_slice(self.key.as_ref());
let cipher = XChaCha20Poly1305::new(key);
let mut nonce = [0u8; 24];
let mut nonce = [0u8; XCHACHA20POLY1305_NONCE_LEN];
fill(&mut nonce).map_err(|_| CryptoError::EncryptionFailed)?;
let nonce_ref = XNonce::from_slice(&nonce);
@ -68,17 +77,17 @@ impl AeadEncrypt for ChaCha20Poly1305 {
}
#[cfg(feature = "chacha20poly1305")]
impl AeadDecrypt for ChaCha20Poly1305 {
impl AeadDecrypt for XChaCha20Poly1305 {
fn decrypt(&self, ciphertext: &[u8], aad: &[u8]) -> Result<Vec<u8>, CryptoError> {
use chacha20poly1305::XChaCha20Poly1305;
use chacha20poly1305::XNonce;
use chacha20poly1305::aead::{Aead, KeyInit, Payload};
if ciphertext.len() < 24 {
if ciphertext.len() < XCHACHA20POLY1305_NONCE_LEN + AUTH_TAG_LEN {
return Err(CryptoError::InvalidNonceLength);
}
let (nonce, ct) = ciphertext.split_at(24);
let (nonce, ct) = ciphertext.split_at(XCHACHA20POLY1305_NONCE_LEN);
let key = chacha20poly1305::Key::from_slice(self.key.as_ref());
let cipher = XChaCha20Poly1305::new(key);
let nonce_ref = XNonce::from_slice(nonce);
@ -92,12 +101,17 @@ impl AeadDecrypt for ChaCha20Poly1305 {
}
#[cfg(feature = "chacha20poly1305")]
impl AeadCipher for ChaCha20Poly1305 {
impl AeadCipher for XChaCha20Poly1305 {
fn key_size() -> usize {
32
}
}
/// Compatibility alias for the original public name. The implementation is
/// XChaCha20-Poly1305, including its 24-byte nonce format.
#[cfg(feature = "chacha20poly1305")]
pub type ChaCha20Poly1305 = XChaCha20Poly1305;
#[cfg(feature = "aes-gcm")]
pub struct Aes256Gcm {
key: Zeroizing<[u8; 32]>,
@ -122,7 +136,7 @@ impl AeadEncrypt for Aes256Gcm {
let key = aes_gcm::Key::<AesGcmInner>::from_slice(self.key.as_ref());
let cipher = AesGcmInner::new(key);
let mut nonce = [0u8; 12];
let mut nonce = [0u8; AES256GCM_NONCE_LEN];
fill(&mut nonce).map_err(|_| CryptoError::EncryptionFailed)?;
let nonce_ref = Nonce::from_slice(&nonce);
@ -146,11 +160,11 @@ impl AeadDecrypt for Aes256Gcm {
use aes_gcm::Nonce;
use aes_gcm::aead::{Aead, KeyInit, Payload};
if ciphertext.len() < 12 {
if ciphertext.len() < AES256GCM_NONCE_LEN + AUTH_TAG_LEN {
return Err(CryptoError::InvalidNonceLength);
}
let (nonce, ct) = ciphertext.split_at(12);
let (nonce, ct) = ciphertext.split_at(AES256GCM_NONCE_LEN);
let key = aes_gcm::Key::<AesGcmInner>::from_slice(self.key.as_ref());
let cipher = AesGcmInner::new(key);
let nonce_ref = Nonce::from_slice(nonce);

View file

@ -1,19 +1,15 @@
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
use crate::error::CryptoError;
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
use crate::kdf::derive_encryption_key;
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
#[cfg(feature = "mlkem-tls")]
use crate::kem::HybridKem;
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
use crate::keypair::{Keyring, PublicKeyBundle};
/*
* Algorithm selector for encrypted containers.
* Algorithm selector for encrypted values.
*
* Mirrors `SigAlgorithm` for signatures: a single marking byte identifies the
* key-encapsulation mechanism and the AEAD used to seal a container. The byte
* is stored as the first byte of every encrypted blob so the decryptor can pick
* is stored as the first byte of every encrypted envelope so the decryptor can pick
* the matching algorithm (and the matching keypair from a `Keyring`) without
* any out-of-band agreement.
*
@ -34,7 +30,7 @@ impl EncryptionType {
pub const ML_KEM_CHACHA20POLY1305: u8 = 0x01;
pub const ML_KEM_AES256_GCM: u8 = 0x02;
/// The marking byte written at the front of an encrypted blob.
/// The marking byte written at the front of an encrypted envelope.
pub const fn to_byte(self) -> u8 {
match self {
Self::MlKemChaCha20Poly1305 => Self::ML_KEM_CHACHA20POLY1305,
@ -50,6 +46,50 @@ impl EncryptionType {
_ => None,
}
}
/// Size of the content-encryption key wrapped for each recipient.
pub const CONTENT_ENCRYPTION_KEY_LEN: usize = 32;
/// The fixed-size ciphertext emitted by the KEM selected by this suite.
pub const fn kem_ciphertext_len(self) -> usize {
match self {
Self::MlKemChaCha20Poly1305 | Self::MlKemAes256Gcm => {
#[cfg(feature = "mlkem-tls")]
{
HybridKem::ciphertext_len()
}
#[cfg(not(feature = "mlkem-tls"))]
{
0
}
}
}
}
/// Bytes the selected AEAD prepends/appends to an encrypted payload.
pub const fn aead_overhead(self) -> usize {
match self {
Self::MlKemChaCha20Poly1305 => {
crate::aead::XCHACHA20POLY1305_NONCE_LEN + crate::aead::AUTH_TAG_LEN
}
Self::MlKemAes256Gcm => crate::aead::AES256GCM_NONCE_LEN + crate::aead::AUTH_TAG_LEN,
}
}
/// Total output length for an encrypted plaintext of `plaintext_len` bytes.
pub const fn encrypted_len(self, plaintext_len: usize) -> usize {
plaintext_len.saturating_add(self.aead_overhead())
}
/// Minimum valid AEAD output length for this suite.
pub const fn minimum_ciphertext_len(self) -> usize {
self.encrypted_len(0)
}
/// The size of a wrapped 32-byte content key for this suite.
pub const fn wrapped_key_len(self) -> usize {
self.encrypted_len(Self::CONTENT_ENCRYPTION_KEY_LEN)
}
}
/*
@ -59,7 +99,7 @@ impl EncryptionType {
*/
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
#[allow(unused_variables)]
fn aead_seal(
pub fn seal_with_key(
enc_type: EncryptionType,
key: [u8; 32],
plaintext: &[u8],
@ -70,7 +110,7 @@ fn aead_seal(
match enc_type {
#[cfg(feature = "chacha20poly1305")]
EncryptionType::MlKemChaCha20Poly1305 => {
crate::aead::ChaCha20Poly1305::new(key).encrypt(plaintext, aad)
crate::aead::XChaCha20Poly1305::new(key).encrypt(plaintext, aad)
}
#[cfg(feature = "aes-gcm")]
EncryptionType::MlKemAes256Gcm => crate::aead::Aes256Gcm::new(key).encrypt(plaintext, aad),
@ -86,7 +126,7 @@ fn aead_seal(
*/
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
#[allow(unused_variables)]
fn aead_open(
pub fn open_with_key(
enc_type: EncryptionType,
key: [u8; 32],
ciphertext: &[u8],
@ -97,7 +137,7 @@ fn aead_open(
match enc_type {
#[cfg(feature = "chacha20poly1305")]
EncryptionType::MlKemChaCha20Poly1305 => {
crate::aead::ChaCha20Poly1305::new(key).decrypt(ciphertext, aad)
crate::aead::XChaCha20Poly1305::new(key).decrypt(ciphertext, aad)
}
#[cfg(feature = "aes-gcm")]
EncryptionType::MlKemAes256Gcm => crate::aead::Aes256Gcm::new(key).decrypt(ciphertext, aad),
@ -106,76 +146,51 @@ fn aead_open(
}
}
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
const ENC_KDF_SALT: &[u8] = b"mtp-container-enc";
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
const ENC_KDF_CONTEXT: &[u8] = b"single-recipient";
/*
* Encrypt `plaintext` for a single recipient, selecting the algorithm with
* `enc_type` and the recipient's KEM public key from `recipient`.
*
* The returned, self-describing blob is laid out as:
* [1 byte EncryptionType] [2 bytes u16 kem_ct_len] [kem_ciphertext] [aead_payload]
* where `aead_payload` is the AEAD output (nonce + ciphertext + tag). The AEAD
* key is derived from the KEM shared secret via HKDF, so no separate content key
* is transmitted.
*
* Requires the `mlkem-tls` and `hkdf` features, plus the AEAD feature backing
* `enc_type`.
*/
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
pub fn encrypt_for(
enc_type: EncryptionType,
recipient: &PublicKeyBundle,
plaintext: &[u8],
aad: &[u8],
) -> Result<Vec<u8>, CryptoError> {
let enc = HybridKem::encapsulate(&recipient.kem_public_key)?;
let key = derive_encryption_key(&enc.shared_secret, ENC_KDF_SALT, ENC_KDF_CONTEXT)?;
let aead_payload = aead_seal(enc_type, key, plaintext, aad)?;
let kem_ct = enc.ciphertext;
let mut out = Vec::with_capacity(1 + 2 + kem_ct.len() + aead_payload.len());
out.push(enc_type.to_byte());
out.extend_from_slice(&(kem_ct.len() as u16).to_be_bytes());
out.extend_from_slice(&kem_ct);
out.extend_from_slice(&aead_payload);
Ok(out)
}
/*
* Decrypt a blob produced by [`encrypt_for`] using `keyring`.
*
* The leading byte selects the `EncryptionType` (and thus which keypair to use
* from the keyring); for the current ML-KEM variants that is `kem_secret_key`.
* Returns `DecryptionFailed` on any malformed input or authentication failure.
*
* Requires the `mlkem-tls` and `hkdf` features, plus the AEAD feature backing
* the blob's algorithm.
*/
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
pub fn decrypt_with(blob: &[u8], keyring: &Keyring, aad: &[u8]) -> Result<Vec<u8>, CryptoError> {
if blob.len() < 3 {
return Err(CryptoError::DecryptionFailed);
}
let enc_type = EncryptionType::from_byte(blob[0]).ok_or(CryptoError::DecryptionFailed)?;
let kem_ct_len = u16::from_be_bytes([blob[1], blob[2]]) as usize;
let kem_end = 3usize
.checked_add(kem_ct_len)
.ok_or(CryptoError::DecryptionFailed)?;
let kem_ct = blob.get(3..kem_end).ok_or(CryptoError::DecryptionFailed)?;
let aead_payload = blob.get(kem_end..).ok_or(CryptoError::DecryptionFailed)?;
let shared_secret = HybridKem::decapsulate(&keyring.kem_secret_key, kem_ct)?;
let key = derive_encryption_key(&shared_secret, ENC_KDF_SALT, ENC_KDF_CONTEXT)?;
aead_open(enc_type, key, aead_payload, aad)
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(feature = "mlkem-tls")]
#[test]
fn envelope_parser_uses_suite_dependent_fixed_widths() {
use crate::helper::{MultiEncryptedMessage, RecipientEntry};
let suites = [
EncryptionType::MlKemChaCha20Poly1305,
EncryptionType::MlKemAes256Gcm,
];
assert_ne!(suites[0].wrapped_key_len(), suites[1].wrapped_key_len());
for (index, suite) in suites.into_iter().enumerate() {
let marker = u8::try_from(index).unwrap();
let message = MultiEncryptedMessage {
encryption_type: suite,
purpose: 0xA5,
recipients: vec![RecipientEntry {
kem_ciphertext: vec![0x10 + marker; suite.kem_ciphertext_len()],
encrypted_key: vec![0x20 + marker; suite.wrapped_key_len()],
}],
ciphertext: vec![0x30 + marker; suite.minimum_ciphertext_len() + 3],
};
let encoded = message.to_bytes().expect("synthetic envelope is valid");
let kem_end = 4 + suite.kem_ciphertext_len();
let wrapped_end = kem_end + suite.wrapped_key_len();
assert_eq!(&encoded[..4], &[suite.to_byte(), 0xA5, 0, 1]);
assert_eq!(&encoded[4..kem_end], message.recipients[0].kem_ciphertext);
assert_eq!(
&encoded[kem_end..wrapped_end],
message.recipients[0].encrypted_key
);
assert_eq!(&encoded[wrapped_end..], message.ciphertext);
assert_eq!(
MultiEncryptedMessage::from_bytes(&encoded)
.expect("suite-specific envelope should parse"),
message
);
}
}
#[test]
fn encryption_type_byte_roundtrip() {
for t in [
@ -188,59 +203,19 @@ mod tests {
assert_eq!(EncryptionType::from_byte(0xFF), None);
}
#[cfg(all(feature = "mlkem-tls", feature = "hkdf", feature = "chacha20poly1305"))]
#[test]
fn encrypt_for_roundtrip() -> Result<(), CryptoError> {
let kr = Keyring::generate();
let blob = encrypt_for(
EncryptionType::MlKemChaCha20Poly1305,
&kr.public_key_bundle(),
b"secret payload",
b"aad",
)?;
assert_eq!(blob[0], EncryptionType::ML_KEM_CHACHA20POLY1305);
let pt = decrypt_with(&blob, &kr, b"aad")?;
assert_eq!(pt, b"secret payload");
Ok(())
}
#[cfg(all(feature = "mlkem-tls", feature = "hkdf", feature = "chacha20poly1305"))]
#[test]
fn decrypt_with_wrong_keyring_fails() -> Result<(), CryptoError> {
let kr = Keyring::generate();
let other = Keyring::generate();
let blob = encrypt_for(
EncryptionType::MlKemChaCha20Poly1305,
&kr.public_key_bundle(),
b"secret",
b"aad",
)?;
assert!(decrypt_with(&blob, &other, b"aad").is_err());
Ok(())
}
#[cfg(all(feature = "mlkem-tls", feature = "hkdf", feature = "chacha20poly1305"))]
#[test]
fn decrypt_with_wrong_aad_fails() -> Result<(), CryptoError> {
let kr = Keyring::generate();
let blob = encrypt_for(
EncryptionType::MlKemChaCha20Poly1305,
&kr.public_key_bundle(),
b"secret",
b"right",
)?;
assert!(decrypt_with(&blob, &kr, b"wrong").is_err());
Ok(())
}
#[cfg(all(feature = "mlkem-tls", feature = "hkdf", feature = "chacha20poly1305"))]
#[test]
fn decrypt_with_malformed_fails() {
let kr = Keyring::generate();
assert!(decrypt_with(b"", &kr, b"").is_err());
assert!(decrypt_with(&[0x01, 0x00], &kr, b"").is_err());
// Unknown algorithm byte.
assert!(decrypt_with(&[0x7F, 0x00, 0x00], &kr, b"").is_err());
fn suite_lengths_are_derived_from_the_selected_primitives() {
assert_eq!(
EncryptionType::MlKemChaCha20Poly1305.wrapped_key_len(),
EncryptionType::CONTENT_ENCRYPTION_KEY_LEN
+ crate::aead::XCHACHA20POLY1305_NONCE_LEN
+ crate::aead::AUTH_TAG_LEN
);
assert_eq!(
EncryptionType::MlKemAes256Gcm.wrapped_key_len(),
EncryptionType::CONTENT_ENCRYPTION_KEY_LEN
+ crate::aead::AES256GCM_NONCE_LEN
+ crate::aead::AUTH_TAG_LEN
);
}
}

View file

@ -6,8 +6,16 @@ pub enum CryptoError {
EncryptionFailed,
#[error("decryption failed")]
DecryptionFailed,
#[error("malformed encryption envelope")]
MalformedEnvelope,
#[error("no encryption recipients")]
NoRecipients,
#[error("no matching encryption recipient")]
NoMatchingRecipient,
#[error("invalid key length")]
InvalidKeyLength,
#[error("public and private key material do not match")]
InvalidKeyMaterial,
#[error("invalid nonce length")]
InvalidNonceLength,
#[error("invalid signature")]

View file

@ -1,208 +1,328 @@
// Canonical multi-recipient encryption envelopes.
use crate::enc::EncryptionType;
use crate::error::CryptoError;
#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))]
use crate::aead::{AeadDecrypt, AeadEncrypt, ChaCha20Poly1305};
#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))]
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
use crate::enc::{open_with_key, seal_with_key};
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
use crate::kdf::derive_encryption_key;
#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))]
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
use crate::kem::HybridKem;
#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))]
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
use crate::keypair::{Keyring, PublicKeyBundle};
#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))]
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
use rand::Rng;
#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))]
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
use zeroize::Zeroizing;
pub const ENCRYPT_DOMAIN: &[u8] = b"MTP-DATA-ENC-1";
pub const KEY_WRAP_DOMAIN: &[u8] = b"MTP-DATA-WRAP-1";
/// Operational cap for recipient entries accepted in one envelope.
///
/// The wire count remains a `u16` for format stability, but decapsulation is
/// intentionally bounded because each entry can require a KEM operation.
pub const MAX_RECIPIENTS: usize = 64;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RecipientEntry {
pub kem_ciphertext: Vec<u8>,
pub encrypted_key: Vec<u8>,
}
/*
* A payload encrypted for multiple recipients.
*
* Any recipient who possesses the corresponding `KemPrivateKey` can decrypt the message.
*/
/// The envelope body used by `DataValue::Encrypted`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MultiEncryptedMessage {
pub encryption_type: EncryptionType,
pub purpose: u8,
pub recipients: Vec<RecipientEntry>,
pub nonce: [u8; 24],
/// The AEAD output, including its nonce as defined by the selected suite.
pub ciphertext: Vec<u8>,
}
impl MultiEncryptedMessage {
/*
* Serialize into a compact byte vector.
*
* Format:
* - `num_recipients: u16`
* - for each recipient:
* - `kem_ct_len: u16` | `kem_ciphertext`
* - `ek_len: u16` | `encrypted_key`
* - `nonce: 24 bytes`
* - `ciphertext` (remaining)
*/
pub fn to_bytes(&self) -> Vec<u8> {
/// Serialize the envelope body without redundant per-recipient lengths.
pub fn to_bytes(&self) -> Result<Vec<u8>, CryptoError> {
let kem_len = self.encryption_type.kem_ciphertext_len();
let wrapped_len = self.encryption_type.wrapped_key_len();
let count =
u16::try_from(self.recipients.len()).map_err(|_| CryptoError::EncryptionFailed)?;
if self.recipients.is_empty()
|| self.recipients.len() > MAX_RECIPIENTS
|| self.ciphertext.len() < self.encryption_type.minimum_ciphertext_len()
|| self
.recipients
.iter()
.any(|r| r.kem_ciphertext.len() != kem_len || r.encrypted_key.len() != wrapped_len)
{
return Err(CryptoError::MalformedEnvelope);
}
let mut out = Vec::new();
out.extend_from_slice(&(self.recipients.len() as u16).to_be_bytes());
for r in &self.recipients {
out.extend_from_slice(&(r.kem_ciphertext.len() as u16).to_be_bytes());
out.extend_from_slice(&r.kem_ciphertext);
out.extend_from_slice(&(r.encrypted_key.len() as u16).to_be_bytes());
out.extend_from_slice(&r.encrypted_key);
out.push(self.encryption_type.to_byte());
out.push(self.purpose);
out.extend_from_slice(&count.to_be_bytes());
for recipient in &self.recipients {
out.extend_from_slice(&recipient.kem_ciphertext);
out.extend_from_slice(&recipient.encrypted_key);
}
out.extend_from_slice(&self.nonce);
out.extend_from_slice(&self.ciphertext);
out
Ok(out)
}
/// Deserialize from bytes produced by `to_bytes`.
/// Parse the canonical envelope body.
pub fn from_bytes(bytes: &[u8]) -> Result<Self, CryptoError> {
let mut offset = 0;
let read_u16 = |off: &mut usize| -> Result<u16, CryptoError> {
let slice = bytes
.get(*off..*off + 2)
.ok_or(CryptoError::DecryptionFailed)?;
let arr: [u8; 2] = slice
.try_into()
.map_err(|_| CryptoError::DecryptionFailed)?;
*off += 2;
Ok(u16::from_be_bytes(arr))
};
let num = read_u16(&mut offset)? as usize;
let mut recipients = Vec::with_capacity(num);
for _ in 0..num {
let klen = read_u16(&mut offset)? as usize;
let kem_ct = bytes
.get(offset..offset + klen)
.ok_or(CryptoError::DecryptionFailed)?
.to_vec();
offset += klen;
let elen = read_u16(&mut offset)? as usize;
let enc_key = bytes
.get(offset..offset + elen)
.ok_or(CryptoError::DecryptionFailed)?
.to_vec();
offset += elen;
recipients.push(RecipientEntry {
kem_ciphertext: kem_ct,
encrypted_key: enc_key,
});
if bytes.len() < 4 {
return Err(CryptoError::MalformedEnvelope);
}
let encryption_type =
EncryptionType::from_byte(bytes[0]).ok_or(CryptoError::UnknownAlgorithm)?;
let purpose = bytes[1];
let count = u16::from_be_bytes([bytes[2], bytes[3]]) as usize;
if count == 0 || count > MAX_RECIPIENTS {
return Err(CryptoError::MalformedEnvelope);
}
let entry_len = encryption_type.kem_ciphertext_len() + encryption_type.wrapped_key_len();
let entries_len = count
.checked_mul(entry_len)
.ok_or(CryptoError::MalformedEnvelope)?;
let start = 4usize;
let end = start
.checked_add(entries_len)
.ok_or(CryptoError::MalformedEnvelope)?;
let ciphertext_len = bytes
.len()
.checked_sub(end)
.ok_or(CryptoError::MalformedEnvelope)?;
if ciphertext_len < encryption_type.minimum_ciphertext_len() {
return Err(CryptoError::MalformedEnvelope);
}
let nonce: [u8; 24] = bytes
.get(offset..offset + 24)
.ok_or(CryptoError::DecryptionFailed)?
.try_into()
.map_err(|_| CryptoError::DecryptionFailed)?;
offset += 24;
let ciphertext = bytes
.get(offset..)
.ok_or(CryptoError::DecryptionFailed)?
.to_vec();
let mut offset = start;
let mut recipients = Vec::with_capacity(count);
for _ in 0..count {
let kem_end = offset + encryption_type.kem_ciphertext_len();
let wrapped_end = kem_end + encryption_type.wrapped_key_len();
recipients.push(RecipientEntry {
kem_ciphertext: bytes[offset..kem_end].to_vec(),
encrypted_key: bytes[kem_end..wrapped_end].to_vec(),
});
offset = wrapped_end;
}
Ok(Self {
encryption_type,
purpose,
recipients,
nonce,
ciphertext,
ciphertext: bytes[offset..].to_vec(),
})
}
}
/*
* Encrypt `plaintext` for every recipient in `entities`.
*
* Internally generates a fresh content-encryption key, encrypts the payload
* with ChaCha20-Poly1305, then KEM-encapsulates and wraps the key for each
* recipient. The returned `MultiEncryptedMessage` can be decrypted by any
* entity whose keyring contains the corresponding private KEM key.
*
* Requires the `pqc` and `chacha20poly1305` features.
*/
#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))]
pub fn encrypt_multi(
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
fn wrap_aad(encryption_type: EncryptionType, purpose: u8, kem_ciphertext: &[u8]) -> Vec<u8> {
let mut aad = Vec::with_capacity(KEY_WRAP_DOMAIN.len() + 2 + kem_ciphertext.len());
aad.extend_from_slice(KEY_WRAP_DOMAIN);
aad.push(encryption_type.to_byte());
aad.push(purpose);
aad.extend_from_slice(kem_ciphertext);
aad
}
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
fn payload_aad(message: &MultiEncryptedMessage) -> Result<Vec<u8>, CryptoError> {
let count =
u16::try_from(message.recipients.len()).map_err(|_| CryptoError::MalformedEnvelope)?;
let mut aad = Vec::new();
aad.extend_from_slice(ENCRYPT_DOMAIN);
aad.push(message.encryption_type.to_byte());
aad.push(message.purpose);
aad.extend_from_slice(&count.to_be_bytes());
for recipient in &message.recipients {
aad.extend_from_slice(&recipient.kem_ciphertext);
aad.extend_from_slice(&recipient.encrypted_key);
}
Ok(aad)
}
/// Encrypt a value for one or more recipients using the canonical envelope.
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
pub fn encrypt_multi_for(
encryption_type: EncryptionType,
purpose: u8,
plaintext: &[u8],
aad: &[u8],
entities: &[PublicKeyBundle],
) -> Result<MultiEncryptedMessage, CryptoError> {
if entities.is_empty() {
return Err(CryptoError::NoRecipients);
}
if entities.len() > MAX_RECIPIENTS {
return Err(CryptoError::EncryptionFailed);
}
let mut cek = Zeroizing::new([0u8; 32]);
rand::rng().fill_bytes(cek.as_mut());
let cipher = ChaCha20Poly1305::new(*cek);
let encrypted_payload = cipher.encrypt(plaintext, aad)?;
let nonce: [u8; 24] = encrypted_payload[..24]
.try_into()
.map_err(|_| CryptoError::EncryptionFailed)?;
let ciphertext = encrypted_payload[24..].to_vec();
let mut recipients = Vec::with_capacity(entities.len());
for entity in entities {
let enc = HybridKem::encapsulate(&entity.kem_public_key)?;
let wrap_key = Zeroizing::new(derive_encryption_key(
&enc.shared_secret,
b"mtp-multi-key-wrap",
b"multi-recipient",
KEY_WRAP_DOMAIN,
&[encryption_type.to_byte(), purpose],
)?);
let wrap_cipher = ChaCha20Poly1305::new(*wrap_key);
let encrypted_key = wrap_cipher.encrypt(cek.as_ref(), b"")?;
let aad = wrap_aad(encryption_type, purpose, &enc.ciphertext);
let encrypted_key = seal_with_key(encryption_type, *wrap_key, cek.as_ref(), &aad)?;
recipients.push(RecipientEntry {
kem_ciphertext: enc.ciphertext,
encrypted_key,
});
}
Ok(MultiEncryptedMessage {
let mut message = MultiEncryptedMessage {
encryption_type,
purpose,
recipients,
nonce,
ciphertext,
})
ciphertext: Vec::new(),
};
let aad = payload_aad(&message)?;
message.ciphertext = seal_with_key(encryption_type, *cek, plaintext, &aad)?;
Ok(message)
}
/*
* Decrypt a `MultiEncryptedMessage` using the recipient's `Keyring`.
*
* Tries each `RecipientEntry` until one succeeds with the given keyring's
* KEM secret key. Returns the original plaintext.
*/
#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))]
pub fn decrypt_multi(
msg: &MultiEncryptedMessage,
aad: &[u8],
/// Decrypt a canonical envelope for a recipient in `keyring`.
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
pub fn decrypt_multi_for(
message: &MultiEncryptedMessage,
purpose: u8,
keyring: &Keyring,
) -> Result<Vec<u8>, CryptoError> {
for entry in &msg.recipients {
let ss = match HybridKem::decapsulate(&keyring.kem_secret_key, &entry.kem_ciphertext) {
Ok(s) => s,
Err(_) => continue,
};
if message.recipients.is_empty()
|| message.recipients.len() > MAX_RECIPIENTS
|| message.purpose != purpose
|| message.ciphertext.len() < message.encryption_type.minimum_ciphertext_len()
|| message.recipients.iter().any(|recipient| {
recipient.kem_ciphertext.len() != message.encryption_type.kem_ciphertext_len()
|| recipient.encrypted_key.len() != message.encryption_type.wrapped_key_len()
})
{
return Err(CryptoError::MalformedEnvelope);
}
let payload_aad = payload_aad(message)?;
for entry in &message.recipients {
let shared_secret =
match HybridKem::decapsulate(&keyring.kem_secret_key, &entry.kem_ciphertext) {
Ok(secret) => secret,
Err(_) => continue,
};
let wrap_key = Zeroizing::new(derive_encryption_key(
&ss,
b"mtp-multi-key-wrap",
b"multi-recipient",
&shared_secret,
KEY_WRAP_DOMAIN,
&[message.encryption_type.to_byte(), purpose],
)?);
let wrap_cipher = ChaCha20Poly1305::new(*wrap_key);
let cek = match wrap_cipher.decrypt(&entry.encrypted_key, b"") {
Ok(k) => Zeroizing::new(k),
let aad = wrap_aad(message.encryption_type, purpose, &entry.kem_ciphertext);
let cek = match open_with_key(
message.encryption_type,
*wrap_key,
&entry.encrypted_key,
&aad,
) {
Ok(key) => key,
Err(_) => continue,
};
let cek_arr = Zeroizing::new(
cek.as_slice()
.try_into()
.map_err(|_| CryptoError::DecryptionFailed)?,
let cek: [u8; 32] = cek.try_into().map_err(|_| CryptoError::DecryptionFailed)?;
return open_with_key(
message.encryption_type,
cek,
&message.ciphertext,
&payload_aad,
);
}
Err(CryptoError::NoMatchingRecipient)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn recipient_count_is_operationally_bounded() {
assert!(matches!(
MultiEncryptedMessage::from_bytes(&[EncryptionType::ML_KEM_CHACHA20POLY1305, 0, 0, 65]),
Err(CryptoError::MalformedEnvelope)
));
}
#[cfg(all(feature = "mlkem-tls", feature = "hkdf", feature = "chacha20poly1305"))]
#[test]
fn authenticated_envelope_fields_reject_tampering() -> Result<(), CryptoError> {
let recipient_a = Keyring::generate();
let recipient_b = Keyring::generate();
let message = encrypt_multi_for(
EncryptionType::MlKemChaCha20Poly1305,
7,
b"authenticated payload",
&[
recipient_a.public_key_bundle(),
recipient_b.public_key_bundle(),
],
)?;
assert_eq!(
decrypt_multi_for(&message, message.purpose, &recipient_a)?,
b"authenticated payload"
);
let mut full_ct = Vec::with_capacity(24 + msg.ciphertext.len());
full_ct.extend_from_slice(&msg.nonce);
full_ct.extend_from_slice(&msg.ciphertext);
let mut wrong_purpose = message.clone();
wrong_purpose.purpose ^= 1;
assert!(
decrypt_multi_for(&wrong_purpose, wrong_purpose.purpose, &recipient_a).is_err(),
"mutating the encryption purpose must invalidate the envelope"
);
let data_cipher = ChaCha20Poly1305::new(*cek_arr);
return data_cipher.decrypt(&full_ct, aad);
let mut wrong_recipient_table = message.clone();
wrong_recipient_table.recipients[1].encrypted_key[0] ^= 1;
assert!(
decrypt_multi_for(&wrong_recipient_table, message.purpose, &recipient_a).is_err(),
"mutating another recipient's table entry must invalidate the payload"
);
let mut wrong_ciphertext = message;
let last = wrong_ciphertext.ciphertext.len() - 1;
wrong_ciphertext.ciphertext[last] ^= 1;
assert!(
decrypt_multi_for(&wrong_ciphertext, wrong_ciphertext.purpose, &recipient_a).is_err(),
"mutating the ciphertext must invalidate the envelope"
);
Ok(())
}
#[cfg(feature = "mlkem-tls")]
#[test]
fn rejects_envelopes_without_a_complete_aead_payload() {
let encryption_type = EncryptionType::MlKemChaCha20Poly1305;
let message = MultiEncryptedMessage {
encryption_type,
purpose: 1,
recipients: vec![RecipientEntry {
kem_ciphertext: vec![0; encryption_type.kem_ciphertext_len()],
encrypted_key: vec![0; encryption_type.wrapped_key_len()],
}],
ciphertext: vec![0; encryption_type.minimum_ciphertext_len() - 1],
};
assert!(matches!(
message.to_bytes(),
Err(CryptoError::MalformedEnvelope)
));
let mut encoded = vec![encryption_type.to_byte(), 1, 0, 1];
encoded.extend_from_slice(&vec![0; encryption_type.kem_ciphertext_len()]);
encoded.extend_from_slice(&vec![0; encryption_type.wrapped_key_len()]);
encoded.extend_from_slice(&vec![0; encryption_type.minimum_ciphertext_len() - 1]);
assert!(matches!(
MultiEncryptedMessage::from_bytes(&encoded),
Err(CryptoError::MalformedEnvelope)
));
}
Err(CryptoError::DecryptionFailed)
}

View file

@ -12,11 +12,13 @@ pub struct HybridKem;
#[cfg(feature = "mlkem-tls")]
impl HybridKem {
/// Fixed wire size of the KEM ciphertext used by MTP envelopes.
pub const fn ciphertext_len() -> usize {
mlkem_tls::X25519MlKem768::CIPHERTEXT_SIZE
}
pub fn generate_keypair() -> (KemPrivateKey, KemPublicKey) {
/* Obviously: cannot find module or crate rand_core06 in this scope
use of unresolved module or unlinked crate rand_core06 (rustc E0433) */
let (ek, dk) =
mlkem_tls::X25519MlKem768::keygen(&mut chacha20poly1305::aead::rand_core::OsRng);
let (ek, dk) = mlkem_tls::X25519MlKem768::keygen(&mut rand_core::OsRng);
(
KemPrivateKey::new(dk.as_bytes().to_vec()),
KemPublicKey::new(ek.as_bytes().to_vec()),
@ -26,10 +28,7 @@ impl HybridKem {
pub fn encapsulate(recipient_pk: &KemPublicKey) -> Result<Encapsulated, CryptoError> {
let ek = mlkem_tls::EncapsKey768::try_from(recipient_pk.as_bytes())
.map_err(|_| CryptoError::KemEncapsulationFailed)?;
let (ct, ss) = mlkem_tls::X25519MlKem768::encapsulate(
&ek,
&mut chacha20poly1305::aead::rand_core::OsRng,
);
let (ct, ss) = mlkem_tls::X25519MlKem768::encapsulate(&ek, &mut rand_core::OsRng);
Ok(Encapsulated {
ciphertext: ct.as_bytes().to_vec(),
shared_secret: Zeroizing::new(ss.as_bytes().to_vec()),

View file

@ -237,7 +237,91 @@ impl Keyring {
}
}
pub fn to_bytes(&self) -> Zeroizing<Vec<u8>> {
/// Validate the material required to produce classical signatures. This
/// intentionally permits a browser role-specific keyring without KEM or
/// PQ fields.
pub fn validate_ed25519_signing(&self) -> Result<(), crate::error::CryptoError> {
use crate::error::CryptoError;
if self.sig_cl_secret_key.as_bytes().len() != 32
|| self.sig_cl_public_key.as_bytes().len() != SIG_CL_PUBLIC_KEY_LEN
{
return Err(CryptoError::InvalidKeyLength);
}
#[cfg(feature = "ed25519-dalek")]
{
let signer = crate::sign::Ed25519Signer::new(&self.sig_cl_secret_key)?;
if signer.public_key().as_bytes() != self.sig_cl_public_key.as_bytes() {
return Err(CryptoError::InvalidKeyMaterial);
}
}
Ok(())
}
/// Validate material required for a hybrid Ed25519 + ML-DSA signature.
pub fn validate_dual_signing(&self) -> Result<(), crate::error::CryptoError> {
use crate::error::CryptoError;
self.validate_ed25519_signing()?;
if self.sig_pq_secret_key.as_bytes().len() != 32
|| self.sig_pq_public_key.as_bytes().len() != SIG_PQ_PUBLIC_KEY_LEN
{
return Err(CryptoError::InvalidKeyLength);
}
#[cfg(feature = "ml-dsa")]
{
let signer =
crate::sign::MlDsaSigner::new(&self.sig_pq_secret_key, &self.sig_pq_public_key)?;
if signer.public_key().as_bytes() != self.sig_pq_public_key.as_bytes() {
return Err(CryptoError::InvalidKeyMaterial);
}
}
Ok(())
}
/// Validate the KEM material required to decrypt envelopes addressed to
/// this keyring. This is intentionally separate from full identity
/// validation because browser and relay roles may use Ed25519-only
/// signing material while still needing a complete encryption key pair.
pub fn validate_encryption(&self) -> Result<(), crate::error::CryptoError> {
use crate::error::CryptoError;
if self.kem_public_key.as_bytes().is_empty() || self.kem_secret_key.as_bytes().is_empty() {
return Err(CryptoError::InvalidKeyLength);
}
#[cfg(feature = "mlkem-tls")]
{
let encapsulated = crate::kem::HybridKem::encapsulate(&self.kem_public_key)?;
let recovered =
crate::kem::HybridKem::decapsulate(&self.kem_secret_key, &encapsulated.ciphertext)?;
if recovered.as_slice() != encapsulated.shared_secret.as_slice() {
return Err(CryptoError::InvalidKeyMaterial);
}
}
Ok(())
}
/// Validate a complete identity before using it at a protocol boundary.
///
/// `Keyring` remains permissive because browser callers may intentionally
/// hold role-specific material. Protocol paths that need encryption and
/// both signing suites should call this method explicitly.
pub fn validate_full(&self) -> Result<(), crate::error::CryptoError> {
use crate::error::CryptoError;
self.public_key_bundle().validate()?;
self.validate_encryption()?;
if self.sig_pq_secret_key.as_bytes().len() != 32
|| self.sig_cl_secret_key.as_bytes().len() != 32
{
return Err(CryptoError::InvalidKeyLength);
}
#[cfg(all(feature = "mlkem-tls", feature = "ml-dsa", feature = "ed25519-dalek"))]
{
self.validate_dual_signing()?;
}
Ok(())
}
pub fn try_to_bytes(&self) -> Result<Zeroizing<Vec<u8>>, crate::error::CryptoError> {
let fields: &[&[u8]] = &[
self.kem_public_key.as_bytes(),
self.kem_secret_key.as_bytes(),
@ -248,10 +332,17 @@ impl Keyring {
];
let mut out = Zeroizing::new(Vec::new());
for f in fields {
out.extend_from_slice(&(f.len() as u16).to_be_bytes());
let length =
u16::try_from(f.len()).map_err(|_| crate::error::CryptoError::InvalidKeyLength)?;
out.extend_from_slice(&length.to_be_bytes());
out.extend_from_slice(f);
}
out
Ok(out)
}
pub fn to_bytes(&self) -> Zeroizing<Vec<u8>> {
self.try_to_bytes()
.expect("key material length exceeds wire limit")
}
pub fn from_bytes(bytes: &[u8]) -> Result<Self, crate::error::CryptoError> {
@ -283,6 +374,13 @@ impl Keyring {
sig_cl_public_key: SignaturePublicKey::new(read_key(&mut offset)?),
sig_cl_secret_key: SignaturePrivateKey::new(read_key(&mut offset)?),
})
.and_then(|keyring| {
if offset == bytes.len() {
Ok(keyring)
} else {
Err(CryptoError::InvalidKeyLength)
}
})
}
pub fn to_hex(&self) -> String {
@ -352,7 +450,6 @@ impl PublicKeyBundle {
}
}
#[cfg(all(feature = "ed25519-dalek", feature = "ml-dsa", feature = "mlkem-tls"))]
pub fn validate(&self) -> Result<(), crate::error::CryptoError> {
use crate::error::CryptoError;
if self.sig_cl_public_key.as_bytes().len() != SIG_CL_PUBLIC_KEY_LEN {
@ -364,25 +461,70 @@ impl PublicKeyBundle {
if self.kem_public_key.as_bytes().len() != KEM_PUBLIC_KEY_LEN {
return Err(CryptoError::InvalidKeyLength);
}
#[cfg(feature = "ed25519-dalek")]
{
let bytes: [u8; SIG_CL_PUBLIC_KEY_LEN] = self
.sig_cl_public_key
.as_bytes()
.try_into()
.map_err(|_| CryptoError::InvalidKeyLength)?;
ed25519_dalek::VerifyingKey::from_bytes(&bytes)
.map_err(|_| CryptoError::InvalidKeyMaterial)?;
}
#[cfg(feature = "ml-dsa")]
{
let encoded = ml_dsa::EncodedVerifyingKey::<ml_dsa::MlDsa65>::try_from(
self.sig_pq_public_key.as_bytes(),
)
.map_err(|_| CryptoError::InvalidKeyMaterial)?;
let _ = ml_dsa::VerifyingKey::<ml_dsa::MlDsa65>::decode(&encoded);
}
#[cfg(feature = "mlkem-tls")]
{
crate::kem::HybridKem::encapsulate(&self.kem_public_key)
.map_err(|_| CryptoError::InvalidKeyMaterial)?;
}
Ok(())
}
pub fn as_bytes(&self) -> Vec<u8> {
pub fn try_as_bytes(&self) -> Result<Vec<u8>, crate::error::CryptoError> {
let kem = self.kem_public_key.as_bytes();
let pq = self.sig_pq_public_key.as_bytes();
let cl = self.sig_cl_public_key.as_bytes();
let kem_len =
u16::try_from(kem.len()).map_err(|_| crate::error::CryptoError::InvalidKeyLength)?;
let pq_len =
u16::try_from(pq.len()).map_err(|_| crate::error::CryptoError::InvalidKeyLength)?;
let cl_len =
u16::try_from(cl.len()).map_err(|_| crate::error::CryptoError::InvalidKeyLength)?;
let mut out = Vec::with_capacity(kem.len() + pq.len() + cl.len() + 6);
out.extend_from_slice(&(kem.len() as u16).to_be_bytes());
out.extend_from_slice(&kem_len.to_be_bytes());
out.extend_from_slice(kem);
out.extend_from_slice(&(pq.len() as u16).to_be_bytes());
out.extend_from_slice(&pq_len.to_be_bytes());
out.extend_from_slice(pq);
out.extend_from_slice(&(cl.len() as u16).to_be_bytes());
out.extend_from_slice(&cl_len.to_be_bytes());
out.extend_from_slice(cl);
out
Ok(out)
}
pub fn as_bytes(&self) -> Vec<u8> {
self.try_as_bytes()
.expect("public key bundle field exceeds wire limit")
}
/// Parse a complete suite-compatible public bundle.
pub fn from_bytes(bytes: &[u8]) -> Result<Self, crate::error::CryptoError> {
let bundle = Self::from_bytes_unvalidated(bytes)?;
bundle.validate()?;
Ok(bundle)
}
/// Parse the canonical field layout without requiring all suite fields.
///
/// This is reserved for explicitly partial development material, such as
/// an Ed25519-only browser keyring. Callers that will encrypt or verify
/// cryptographic protocol values must use [`Self::from_bytes`].
pub fn from_bytes_unvalidated(bytes: &[u8]) -> Result<Self, crate::error::CryptoError> {
use crate::error::CryptoError;
let mut offset = 0;
@ -422,6 +564,11 @@ impl PublicKeyBundle {
.ok_or(CryptoError::InvalidKeyLength)?
.to_vec(),
);
offset += cl_len;
if offset != bytes.len() {
return Err(CryptoError::InvalidKeyLength);
}
Ok(Self {
kem_public_key: kem,
@ -430,6 +577,11 @@ impl PublicKeyBundle {
})
}
/// Parse a complete, suite-compatible public bundle.
pub fn from_bytes_validated(bytes: &[u8]) -> Result<Self, crate::error::CryptoError> {
Self::from_bytes(bytes)
}
pub fn to_base64(&self) -> String {
bytes_to_base64(&self.as_bytes())
}
@ -437,6 +589,10 @@ impl PublicKeyBundle {
pub fn from_base64(s: &str) -> Result<Self, crate::error::CryptoError> {
Self::from_bytes(&base64_to_bytes(s)?)
}
pub fn from_base64_unvalidated(s: &str) -> Result<Self, crate::error::CryptoError> {
Self::from_bytes_unvalidated(&base64_to_bytes(s)?)
}
}
impl TryFrom<&[u8]> for PublicKeyBundle {
@ -474,7 +630,7 @@ mod tests {
let bundle = PublicKeyBundle::new(kem, pq, cl);
let bytes = bundle.as_bytes();
let recovered = PublicKeyBundle::from_bytes(&bytes)?;
let recovered = PublicKeyBundle::from_bytes_unvalidated(&bytes)?;
assert_eq!(
bundle.kem_public_key.as_bytes(),
@ -491,6 +647,24 @@ mod tests {
Ok(())
}
#[cfg(all(feature = "mlkem-tls", feature = "ml-dsa", feature = "ed25519-dalek"))]
#[test]
fn full_keyring_validation_checks_key_correspondence() {
let keyring = Keyring::generate();
assert!(keyring.validate_full().is_ok());
assert!(keyring.validate_encryption().is_ok());
let mut invalid = Keyring::generate();
invalid.sig_cl_public_key = SignaturePublicKey::new(vec![0; SIG_CL_PUBLIC_KEY_LEN]);
assert!(matches!(
invalid.validate_full(),
Err(crate::error::CryptoError::InvalidKeyMaterial)
));
invalid.kem_secret_key = KemPrivateKey::new(vec![0]);
assert!(invalid.validate_encryption().is_err());
}
#[test]
fn public_key_bundle_try_from_roundtrip() -> Result<(), Box<dyn std::error::Error>> {
let bundle = PublicKeyBundle::new(
@ -499,7 +673,7 @@ mod tests {
SignaturePublicKey::new(vec![0xEFu8; 32]),
);
let bytes: Vec<u8> = Vec::from(&bundle);
let recovered = PublicKeyBundle::try_from(bytes.as_slice())?;
let recovered = PublicKeyBundle::from_bytes_unvalidated(bytes.as_slice())?;
assert_eq!(bundle.as_bytes(), recovered.as_bytes());
Ok(())
}
@ -531,6 +705,53 @@ mod tests {
Ok(())
}
#[test]
fn keyring_try_to_bytes_rejects_fields_larger_than_wire_length() {
let keyring = Keyring::new(
KemPublicKey::new(vec![0u8; 65_536]),
KemPrivateKey::new(Vec::new()),
SignaturePqPublicKey::new(Vec::new()),
SignaturePqPrivateKey::new(Vec::new()),
SignaturePublicKey::new(Vec::new()),
SignaturePrivateKey::new(Vec::new()),
);
assert!(matches!(
keyring.try_to_bytes(),
Err(crate::error::CryptoError::InvalidKeyLength)
));
}
#[test]
fn canonical_key_parsers_reject_trailing_bytes() {
let keyring = Keyring::new(
KemPublicKey::new(vec![1u8; 16]),
KemPrivateKey::new(vec![2u8; 16]),
SignaturePqPublicKey::new(vec![3u8; 16]),
SignaturePqPrivateKey::new(vec![4u8; 16]),
SignaturePublicKey::new(vec![5u8; 16]),
SignaturePrivateKey::new(vec![6u8; 16]),
);
let mut keyring_bytes = keyring.to_bytes().to_vec();
keyring_bytes.push(0xAA);
assert!(Keyring::from_bytes(&keyring_bytes).is_err());
let bundle = keyring.public_key_bundle();
let mut bundle_bytes = bundle.as_bytes();
bundle_bytes.push(0xBB);
assert!(PublicKeyBundle::from_bytes(&bundle_bytes).is_err());
}
#[test]
fn validated_bundle_rejects_partial_suite_keys() {
let bundle = PublicKeyBundle::new(
KemPublicKey::new(vec![1u8; 32]),
SignaturePqPublicKey::new(vec![2u8; 64]),
SignaturePublicKey::new(vec![3u8; 32]),
);
assert!(bundle.validate().is_err());
assert!(PublicKeyBundle::from_bytes_validated(&bundle.as_bytes()).is_err());
}
#[test]
fn keyring_try_from_roundtrip() -> Result<(), Box<dyn std::error::Error>> {
let keyring = Keyring::new(
@ -597,7 +818,7 @@ mod tests {
SignaturePublicKey::new(vec![3u8; 32]),
);
let b64 = bundle.to_base64();
let recovered = PublicKeyBundle::from_base64(&b64)?;
let recovered = PublicKeyBundle::from_base64_unvalidated(&b64)?;
assert_eq!(bundle.as_bytes(), recovered.as_bytes());
Ok(())
}

View file

@ -43,7 +43,7 @@ pub use keypair::{
};
#[cfg(feature = "chacha20poly1305")]
pub use aead::ChaCha20Poly1305;
pub use aead::{ChaCha20Poly1305, XChaCha20Poly1305};
#[cfg(feature = "aes-gcm")]
pub use aead::Aes256Gcm;
@ -55,7 +55,7 @@ pub use sign::{Ed25519Signer, SignatureScheme, verify_ed25519};
pub use sign::{MlDsaSigner, verify_ml_dsa};
#[cfg(all(feature = "ed25519-dalek", feature = "ml-dsa"))]
pub use sign::{DualSignature, sign_dual};
pub use sign::{DualSignature, DualSigner, sign_dual};
#[cfg(feature = "sha2")]
pub use hash::{Sha256Hasher, sha256, sha256_double};
@ -79,11 +79,12 @@ pub fn ensure_crypto_provider() {
});
}
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
pub use enc::{decrypt_with, encrypt_for};
pub use helper::{ENCRYPT_DOMAIN, KEY_WRAP_DOMAIN};
#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))]
pub use helper::{MultiEncryptedMessage, RecipientEntry, decrypt_multi, encrypt_multi};
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
pub use helper::{
MAX_RECIPIENTS, MultiEncryptedMessage, RecipientEntry, decrypt_multi_for, encrypt_multi_for,
};
/* ================================ TESTS ================================ */
#[cfg(test)]
@ -209,6 +210,20 @@ mod tests {
);
}
#[cfg(all(feature = "ed25519-dalek", feature = "ml-dsa"))]
#[test]
fn dual_scheme_implements_signature_trait() {
use crate::sign::SignatureScheme;
let (signer, _, _, _, _) = DualSigner::generate();
let signature = signer.sign(b"msg").expect("dual signing should succeed");
assert_eq!(signer.algorithm(), SigAlgorithm::DUAL);
signer
.verify(b"msg", &signature)
.expect("dual verification should succeed");
assert!(signer.verify(b"wrong", &signature).is_err());
}
#[cfg(feature = "hkdf")]
#[test]
fn hkdf_expand_produces_key() {
@ -343,17 +358,46 @@ mod tests {
assert_eq!(enc.shared_secret, ss);
}
#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))]
#[test]
fn encrypt_multi_roundtrip() {
use crate::helper::{decrypt_multi, encrypt_multi};
#[cfg(all(
feature = "mlkem-tls",
feature = "hkdf",
feature = "ml-dsa",
feature = "ed25519-dalek"
))]
fn multi_envelope_roundtrip(encryption_type: EncryptionType) {
use crate::helper::{decrypt_multi_for, encrypt_multi_for};
use crate::keypair::Keyring;
let kr = Keyring::generate();
let entities = vec![kr.public_key_bundle()];
let msg = b"secret data";
let ct = encrypt_multi(msg, b"aad", &entities).expect("multi encrypt should succeed");
let pt = decrypt_multi(&ct, b"aad", &kr).expect("multi decrypt should succeed");
let ct = encrypt_multi_for(encryption_type, 7, msg, &entities)
.expect("multi encrypt should succeed");
let pt = decrypt_multi_for(&ct, 7, &kr).expect("multi decrypt should succeed");
assert_eq!(pt, msg);
}
#[cfg(all(
feature = "mlkem-tls",
feature = "hkdf",
feature = "ml-dsa",
feature = "ed25519-dalek",
feature = "chacha20poly1305"
))]
#[test]
fn chacha20_multi_envelope_roundtrip() {
multi_envelope_roundtrip(EncryptionType::MlKemChaCha20Poly1305);
}
#[cfg(all(
feature = "mlkem-tls",
feature = "hkdf",
feature = "ml-dsa",
feature = "ed25519-dalek",
feature = "aes-gcm"
))]
#[test]
fn aes_gcm_multi_envelope_roundtrip() {
multi_envelope_roundtrip(EncryptionType::MlKemAes256Gcm);
}
}

View file

@ -25,6 +25,8 @@ impl SigAlgorithm {
use crate::keypair::{SignaturePqPrivateKey, SignaturePqPublicKey};
pub trait SignatureScheme {
/// The wire algorithm identifier produced by this signer.
fn algorithm(&self) -> u8;
fn sign(&self, msg: &[u8]) -> Result<Vec<u8>, CryptoError>;
fn verify(&self, msg: &[u8], signature: &[u8]) -> Result<(), CryptoError>;
}
@ -76,6 +78,10 @@ impl Ed25519Signer {
#[cfg(feature = "ed25519-dalek")]
impl SignatureScheme for Ed25519Signer {
fn algorithm(&self) -> u8 {
SigAlgorithm::ED25519
}
fn sign(&self, msg: &[u8]) -> Result<Vec<u8>, CryptoError> {
use ed25519_dalek::Signer;
let signature = self.secret.sign(msg).to_bytes().to_vec();
@ -170,6 +176,10 @@ impl MlDsaSigner {
#[cfg(feature = "ml-dsa")]
impl SignatureScheme for MlDsaSigner {
fn algorithm(&self) -> u8 {
SigAlgorithm::ML_DSA_65
}
fn sign(&self, msg: &[u8]) -> Result<Vec<u8>, CryptoError> {
use ml_dsa::Signer;
let signature = self
@ -237,6 +247,75 @@ pub fn sign_dual(
Ok(DualSignature { ed25519, mldsa })
}
/// A signer that produces the canonical concatenated Ed25519 + ML-DSA-65
/// signature represented by [`SigAlgorithm::DUAL`].
#[cfg(all(feature = "ed25519-dalek", feature = "ml-dsa"))]
pub struct DualSigner {
ed25519: Ed25519Signer,
mldsa: MlDsaSigner,
}
#[cfg(all(feature = "ed25519-dalek", feature = "ml-dsa"))]
impl DualSigner {
pub fn new(
ed25519_secret: &SignaturePrivateKey,
mldsa_secret: &SignaturePqPrivateKey,
mldsa_public: &SignaturePqPublicKey,
) -> Result<Self, CryptoError> {
Ok(Self {
ed25519: Ed25519Signer::new(ed25519_secret)?,
mldsa: MlDsaSigner::new(mldsa_secret, mldsa_public)?,
})
}
pub fn generate() -> (
Self,
SignaturePrivateKey,
SignaturePqPrivateKey,
SignaturePublicKey,
SignaturePqPublicKey,
) {
let (ed25519, ed25519_secret, ed25519_public) = Ed25519Signer::generate();
let (mldsa, mldsa_secret, mldsa_public) = MlDsaSigner::generate();
(
Self { ed25519, mldsa },
ed25519_secret,
mldsa_secret,
ed25519_public,
mldsa_public,
)
}
}
#[cfg(all(feature = "ed25519-dalek", feature = "ml-dsa"))]
impl SignatureScheme for DualSigner {
fn algorithm(&self) -> u8 {
SigAlgorithm::DUAL
}
fn sign(&self, msg: &[u8]) -> Result<Vec<u8>, CryptoError> {
let dual = sign_dual(self.ed25519.signing_key(), self.mldsa.signing_key(), msg)?;
let mut signature = Vec::with_capacity(
SigAlgorithm::length(SigAlgorithm::DUAL).expect("known signature algorithm length"),
);
signature.extend_from_slice(&dual.ed25519);
signature.extend_from_slice(&dual.mldsa);
Ok(signature)
}
fn verify(&self, msg: &[u8], signature: &[u8]) -> Result<(), CryptoError> {
let ed_len =
SigAlgorithm::length(SigAlgorithm::ED25519).expect("known signature algorithm length");
let mldsa_len = SigAlgorithm::length(SigAlgorithm::ML_DSA_65)
.expect("known signature algorithm length");
if signature.len() != ed_len + mldsa_len {
return Err(CryptoError::InvalidSignature);
}
verify_ed25519(&self.ed25519.public_key(), msg, &signature[..ed_len])?;
verify_ml_dsa(&self.mldsa.public_key(), msg, &signature[ed_len..])
}
}
impl DualSignature {
#[cfg(all(feature = "ed25519-dalek", feature = "ml-dsa"))]
pub fn verify(