634 lines
23 KiB
Rust
634 lines
23 KiB
Rust
//! Post-quantum hybrid KEM: X25519 + ML-KEM-768.
|
|
//!
|
|
//! Wraps MLS payloads in an outer encryption layer using a hybrid key
|
|
//! encapsulation mechanism. The X25519 component provides classical
|
|
//! ECDH security; the ML-KEM-768 component (FIPS 203) provides
|
|
//! post-quantum security.
|
|
//!
|
|
//! # Wire format
|
|
//!
|
|
//! ```text
|
|
//! version(1) | x25519_eph_pk(32) | mlkem_ct(1088) | aead_nonce(12) | aead_ct(var)
|
|
//! ```
|
|
//!
|
|
//! # Key derivation
|
|
//!
|
|
//! ```text
|
|
//! ikm = X25519_shared(32) || ML-KEM_shared(32)
|
|
//! key = HKDF-SHA256(salt=[], ikm, info="quicnprotochat-hybrid-v1", L=32)
|
|
//! ```
|
|
|
|
use chacha20poly1305::{
|
|
aead::{Aead, KeyInit},
|
|
ChaCha20Poly1305, Key, Nonce,
|
|
};
|
|
use hkdf::Hkdf;
|
|
use ml_kem::{
|
|
array::Array,
|
|
kem::{Decapsulate, Encapsulate},
|
|
EncodedSizeUser, KemCore, MlKem768, MlKem768Params,
|
|
};
|
|
use rand::{rngs::OsRng, rngs::StdRng, CryptoRng, RngCore, SeedableRng};
|
|
use serde::{Deserialize, Serialize};
|
|
use sha2::Sha256;
|
|
use x25519_dalek::{EphemeralSecret, PublicKey as X25519Public, StaticSecret};
|
|
use zeroize::Zeroizing;
|
|
|
|
// Re-import the concrete key types from the kem sub-module.
|
|
use ml_kem::kem::{DecapsulationKey, EncapsulationKey};
|
|
|
|
/// Current hybrid envelope version byte.
|
|
const HYBRID_VERSION: u8 = 0x01;
|
|
|
|
/// HKDF info string for domain separation.
|
|
/// Frozen at the original project name for backward compatibility with existing
|
|
/// encrypted state files and messages. Do not change.
|
|
const HKDF_INFO: &[u8] = b"quicnprotochat-hybrid-v1";
|
|
|
|
/// HKDF salt for domain separation (defence-in-depth; IKM already has 64 bytes of entropy).
|
|
/// Frozen — see [`HKDF_INFO`].
|
|
const HKDF_SALT: &[u8] = b"quicnprotochat-hybrid-v1-salt";
|
|
|
|
/// ML-KEM-768 ciphertext size in bytes.
|
|
const MLKEM_CT_LEN: usize = 1088;
|
|
|
|
/// ML-KEM-768 encapsulation key size in bytes.
|
|
pub const MLKEM_EK_LEN: usize = 1184;
|
|
|
|
/// ML-KEM-768 decapsulation key size in bytes.
|
|
pub const MLKEM_DK_LEN: usize = 2400;
|
|
|
|
/// Envelope header: version(1) + x25519 eph pk(32) + mlkem ct(1088) + nonce(12).
|
|
const HEADER_LEN: usize = 1 + 32 + MLKEM_CT_LEN + 12;
|
|
|
|
/// KEM output length (version + x25519 eph pk + mlkem ct) for HPKE adapter.
|
|
pub const HYBRID_KEM_OUTPUT_LEN: usize = 1 + 32 + MLKEM_CT_LEN;
|
|
|
|
/// Hybrid public key length: x25519(32) + mlkem_ek(1184). Used to detect hybrid keys in MLS.
|
|
pub const HYBRID_PUBLIC_KEY_LEN: usize = 32 + MLKEM_EK_LEN;
|
|
|
|
/// Hybrid private key length: x25519(32) + mlkem_dk(2400). Used to detect hybrid keys in MLS.
|
|
pub const HYBRID_PRIVATE_KEY_LEN: usize = 32 + MLKEM_DK_LEN;
|
|
|
|
// ── Error type ──────────────────────────────────────────────────────────────
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum HybridKemError {
|
|
#[error("AEAD encryption failed")]
|
|
EncryptionFailed,
|
|
|
|
#[error("AEAD decryption failed (wrong recipient or tampered)")]
|
|
DecryptionFailed,
|
|
|
|
#[error("unsupported hybrid envelope version: {0}")]
|
|
UnsupportedVersion(u8),
|
|
|
|
#[error("envelope too short ({0} bytes, minimum {HEADER_LEN})")]
|
|
TooShort(usize),
|
|
|
|
#[error("invalid ML-KEM encapsulation key")]
|
|
InvalidMlKemKey,
|
|
|
|
#[error("ML-KEM decapsulation failed")]
|
|
MlKemDecapsFailed,
|
|
}
|
|
|
|
// ── Keypair types ───────────────────────────────────────────────────────────
|
|
|
|
/// A hybrid keypair combining X25519 (classical) + ML-KEM-768 (post-quantum).
|
|
///
|
|
/// Each peer holds one of these. The public portion is distributed so
|
|
/// senders can encrypt payloads with post-quantum protection.
|
|
pub struct HybridKeypair {
|
|
x25519_sk: StaticSecret,
|
|
x25519_pk: X25519Public,
|
|
mlkem_dk: DecapsulationKey<MlKem768Params>,
|
|
mlkem_ek: EncapsulationKey<MlKem768Params>,
|
|
}
|
|
|
|
/// Serialisable form of a [`HybridKeypair`] for persistence.
|
|
///
|
|
/// Secret fields are wrapped in [`Zeroizing`] so they are securely erased
|
|
/// when the struct is dropped.
|
|
#[derive(Serialize, Deserialize)]
|
|
pub struct HybridKeypairBytes {
|
|
pub x25519_sk: Zeroizing<[u8; 32]>,
|
|
pub mlkem_dk: Zeroizing<Vec<u8>>,
|
|
pub mlkem_ek: Vec<u8>,
|
|
}
|
|
|
|
/// The public portion of a hybrid keypair, sent to peers.
|
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
pub struct HybridPublicKey {
|
|
pub x25519_pk: [u8; 32],
|
|
pub mlkem_ek: Vec<u8>,
|
|
}
|
|
|
|
/// HKDF info for deriving HPKE keypair seed from IKM (MLS compatibility).
|
|
/// Frozen — see [`HKDF_INFO`].
|
|
const HKDF_INFO_HPKE_KEYPAIR: &[u8] = b"quicnprotochat-hybrid-hpke-keypair-v1";
|
|
|
|
impl HybridKeypair {
|
|
/// Generate a fresh hybrid keypair from OS CSPRNG.
|
|
pub fn generate() -> Self {
|
|
Self::generate_from_rng(&mut OsRng)
|
|
}
|
|
|
|
/// Generate a hybrid keypair from a seeded RNG (deterministic).
|
|
pub fn generate_from_rng<R: RngCore + CryptoRng>(rng: &mut R) -> Self {
|
|
let x25519_sk = StaticSecret::random_from_rng(&mut *rng);
|
|
let x25519_pk = X25519Public::from(&x25519_sk);
|
|
let (mlkem_dk, mlkem_ek) = MlKem768::generate(rng);
|
|
|
|
Self {
|
|
x25519_sk,
|
|
x25519_pk,
|
|
mlkem_dk,
|
|
mlkem_ek,
|
|
}
|
|
}
|
|
|
|
/// Derive a deterministic hybrid keypair from IKM (for MLS HPKE key schedule).
|
|
pub fn derive_from_ikm(ikm: &[u8]) -> Self {
|
|
let mut seed = [0u8; 32];
|
|
let hk = Hkdf::<Sha256>::new(None, ikm);
|
|
hk.expand(HKDF_INFO_HPKE_KEYPAIR, &mut seed)
|
|
.expect("32 bytes is valid HKDF output");
|
|
let mut rng = StdRng::from_seed(seed);
|
|
Self::generate_from_rng(&mut rng)
|
|
}
|
|
|
|
/// Serialise private key for MLS key store: x25519_sk(32) || mlkem_dk(2400).
|
|
///
|
|
/// The returned value is wrapped in [`Zeroizing`] so secret key material
|
|
/// is securely erased when dropped.
|
|
pub fn private_to_bytes(&self) -> Zeroizing<Vec<u8>> {
|
|
let mut out = Vec::with_capacity(HYBRID_PRIVATE_KEY_LEN);
|
|
out.extend_from_slice(self.x25519_sk.as_bytes());
|
|
out.extend_from_slice(self.mlkem_dk.as_bytes().as_slice());
|
|
Zeroizing::new(out)
|
|
}
|
|
|
|
/// Reconstruct a hybrid keypair from private key bytes (from MLS key store).
|
|
pub fn from_private_bytes(bytes: &[u8]) -> Result<Self, HybridKemError> {
|
|
if bytes.len() != HYBRID_PRIVATE_KEY_LEN {
|
|
return Err(HybridKemError::TooShort(bytes.len()));
|
|
}
|
|
let x25519_sk = StaticSecret::from(<[u8; 32]>::try_from(&bytes[0..32])
|
|
.expect("slice is exactly 32 bytes (guaranteed by HYBRID_PRIVATE_KEY_LEN check)"));
|
|
let x25519_pk = X25519Public::from(&x25519_sk);
|
|
|
|
let mlkem_dk_arr = Array::try_from(&bytes[32..32 + MLKEM_DK_LEN])
|
|
.map_err(|_| HybridKemError::InvalidMlKemKey)?;
|
|
let mlkem_dk = DecapsulationKey::<MlKem768Params>::from_bytes(&mlkem_dk_arr);
|
|
let mlkem_ek = mlkem_dk.encapsulation_key().clone();
|
|
|
|
Ok(Self {
|
|
x25519_sk,
|
|
x25519_pk,
|
|
mlkem_dk,
|
|
mlkem_ek,
|
|
})
|
|
}
|
|
|
|
/// Reconstruct from serialised bytes.
|
|
pub fn from_bytes(bytes: &HybridKeypairBytes) -> Result<Self, HybridKemError> {
|
|
let x25519_sk = StaticSecret::from(*bytes.x25519_sk);
|
|
let x25519_pk = X25519Public::from(&x25519_sk);
|
|
|
|
let mlkem_dk_arr = Array::try_from(bytes.mlkem_dk.as_slice())
|
|
.map_err(|_| HybridKemError::InvalidMlKemKey)?;
|
|
let mlkem_dk = DecapsulationKey::<MlKem768Params>::from_bytes(&mlkem_dk_arr);
|
|
|
|
let mlkem_ek_arr = Array::try_from(bytes.mlkem_ek.as_slice())
|
|
.map_err(|_| HybridKemError::InvalidMlKemKey)?;
|
|
let mlkem_ek = EncapsulationKey::<MlKem768Params>::from_bytes(&mlkem_ek_arr);
|
|
|
|
Ok(Self {
|
|
x25519_sk,
|
|
x25519_pk,
|
|
mlkem_dk,
|
|
mlkem_ek,
|
|
})
|
|
}
|
|
|
|
/// Serialise the keypair for persistence.
|
|
pub fn to_bytes(&self) -> HybridKeypairBytes {
|
|
HybridKeypairBytes {
|
|
x25519_sk: Zeroizing::new(self.x25519_sk.to_bytes()),
|
|
mlkem_dk: Zeroizing::new(self.mlkem_dk.as_bytes().to_vec()),
|
|
mlkem_ek: self.mlkem_ek.as_bytes().to_vec(),
|
|
}
|
|
}
|
|
|
|
/// Extract the public portion for distribution to peers.
|
|
pub fn public_key(&self) -> HybridPublicKey {
|
|
HybridPublicKey {
|
|
x25519_pk: self.x25519_pk.to_bytes(),
|
|
mlkem_ek: self.mlkem_ek.as_bytes().to_vec(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl HybridPublicKey {
|
|
/// Serialise to a single byte blob: x25519_pk(32) || mlkem_ek(1184).
|
|
pub fn to_bytes(&self) -> Vec<u8> {
|
|
let mut out = Vec::with_capacity(32 + self.mlkem_ek.len());
|
|
out.extend_from_slice(&self.x25519_pk);
|
|
out.extend_from_slice(&self.mlkem_ek);
|
|
out
|
|
}
|
|
|
|
/// Deserialise from a single byte blob.
|
|
pub fn from_bytes(bytes: &[u8]) -> Result<Self, HybridKemError> {
|
|
if bytes.len() < 32 + MLKEM_EK_LEN {
|
|
return Err(HybridKemError::TooShort(bytes.len()));
|
|
}
|
|
let mut x25519_pk = [0u8; 32];
|
|
x25519_pk.copy_from_slice(&bytes[..32]);
|
|
let mlkem_ek = bytes[32..32 + MLKEM_EK_LEN].to_vec();
|
|
Ok(Self {
|
|
x25519_pk,
|
|
mlkem_ek,
|
|
})
|
|
}
|
|
}
|
|
|
|
// ── Encrypt / Decrypt ───────────────────────────────────────────────────────
|
|
|
|
/// Encrypt `plaintext` to `recipient_pk` using X25519 + ML-KEM-768 hybrid KEM.
|
|
///
|
|
/// `info` is optional HPKE context info incorporated into key derivation.
|
|
/// `aad` is optional additional authenticated data bound to the AEAD ciphertext.
|
|
///
|
|
/// Returns the complete hybrid envelope as a byte vector.
|
|
pub fn hybrid_encrypt(
|
|
recipient_pk: &HybridPublicKey,
|
|
plaintext: &[u8],
|
|
info: &[u8],
|
|
aad: &[u8],
|
|
) -> Result<Vec<u8>, HybridKemError> {
|
|
// 1. Ephemeral X25519 DH
|
|
let eph_secret = EphemeralSecret::random_from_rng(OsRng);
|
|
let eph_public = X25519Public::from(&eph_secret);
|
|
let x25519_recipient = X25519Public::from(recipient_pk.x25519_pk);
|
|
let x25519_ss = eph_secret.diffie_hellman(&x25519_recipient);
|
|
|
|
// 2. ML-KEM-768 encapsulation
|
|
let mlkem_ek_arr = Array::try_from(recipient_pk.mlkem_ek.as_slice())
|
|
.map_err(|_| HybridKemError::InvalidMlKemKey)?;
|
|
let mlkem_ek = EncapsulationKey::<MlKem768Params>::from_bytes(&mlkem_ek_arr);
|
|
let (mlkem_ct, mlkem_ss) = mlkem_ek
|
|
.encapsulate(&mut OsRng)
|
|
.map_err(|_| HybridKemError::EncryptionFailed)?;
|
|
|
|
// 3. Derive AEAD key from combined shared secrets (with caller info for context binding)
|
|
let aead_key = derive_aead_key(x25519_ss.as_bytes(), mlkem_ss.as_slice(), info);
|
|
|
|
// Generate a random 12-byte nonce (not derived from HKDF).
|
|
let mut nonce_bytes = [0u8; 12];
|
|
OsRng.fill_bytes(&mut nonce_bytes);
|
|
let aead_nonce = *Nonce::from_slice(&nonce_bytes);
|
|
|
|
// 4. AEAD encrypt with caller-supplied AAD
|
|
let cipher = ChaCha20Poly1305::new(&aead_key);
|
|
let aead_payload = chacha20poly1305::aead::Payload { msg: plaintext, aad };
|
|
let ct = cipher
|
|
.encrypt(&aead_nonce, aead_payload)
|
|
.map_err(|_| HybridKemError::EncryptionFailed)?;
|
|
|
|
// 5. Assemble envelope: version || x25519_eph_pk || mlkem_ct || nonce || aead_ct
|
|
let mut out = Vec::with_capacity(HEADER_LEN + ct.len());
|
|
out.push(HYBRID_VERSION);
|
|
out.extend_from_slice(&eph_public.to_bytes());
|
|
out.extend_from_slice(mlkem_ct.as_slice());
|
|
out.extend_from_slice(aead_nonce.as_slice());
|
|
out.extend_from_slice(&ct);
|
|
|
|
Ok(out)
|
|
}
|
|
|
|
/// Decrypt a hybrid envelope using the recipient's private key.
|
|
///
|
|
/// `info` and `aad` must match what was passed to `hybrid_encrypt`.
|
|
pub fn hybrid_decrypt(
|
|
keypair: &HybridKeypair,
|
|
envelope: &[u8],
|
|
info: &[u8],
|
|
aad: &[u8],
|
|
) -> Result<Vec<u8>, HybridKemError> {
|
|
if envelope.len() < HEADER_LEN + 16 {
|
|
// 16 = minimum AEAD tag
|
|
return Err(HybridKemError::TooShort(envelope.len()));
|
|
}
|
|
|
|
let version = envelope[0];
|
|
if version != HYBRID_VERSION {
|
|
return Err(HybridKemError::UnsupportedVersion(version));
|
|
}
|
|
|
|
let mut cursor = 1;
|
|
|
|
// X25519 ephemeral public key
|
|
let mut eph_pk_bytes = [0u8; 32];
|
|
eph_pk_bytes.copy_from_slice(&envelope[cursor..cursor + 32]);
|
|
cursor += 32;
|
|
|
|
// ML-KEM ciphertext
|
|
let mlkem_ct_bytes = &envelope[cursor..cursor + MLKEM_CT_LEN];
|
|
cursor += MLKEM_CT_LEN;
|
|
|
|
// AEAD nonce
|
|
let nonce = Nonce::from_slice(&envelope[cursor..cursor + 12]);
|
|
cursor += 12;
|
|
|
|
// AEAD ciphertext
|
|
let aead_ct = &envelope[cursor..];
|
|
|
|
// 1. X25519 DH with ephemeral public key
|
|
let eph_pk = X25519Public::from(eph_pk_bytes);
|
|
let x25519_ss = keypair.x25519_sk.diffie_hellman(&eph_pk);
|
|
|
|
// 2. ML-KEM decapsulation — convert bytes to the ciphertext array type
|
|
// that `DecapsulationKey::decapsulate` expects.
|
|
let mlkem_ct_arr =
|
|
Array::try_from(mlkem_ct_bytes).map_err(|_| HybridKemError::MlKemDecapsFailed)?;
|
|
let mlkem_ss = keypair
|
|
.mlkem_dk
|
|
.decapsulate(&mlkem_ct_arr)
|
|
.map_err(|_| HybridKemError::MlKemDecapsFailed)?;
|
|
|
|
// 3. Derive AEAD key (with caller info for context binding)
|
|
let aead_key = derive_aead_key(x25519_ss.as_bytes(), mlkem_ss.as_slice(), info);
|
|
|
|
// 4. Decrypt with caller-supplied AAD
|
|
let cipher = ChaCha20Poly1305::new(&aead_key);
|
|
let aead_payload = chacha20poly1305::aead::Payload { msg: aead_ct, aad };
|
|
let plaintext = cipher
|
|
.decrypt(nonce, aead_payload)
|
|
.map_err(|_| HybridKemError::DecryptionFailed)?;
|
|
|
|
Ok(plaintext)
|
|
}
|
|
|
|
/// Encapsulate only: compute shared secret and KEM output (no AEAD).
|
|
/// Returns `(kem_output, shared_secret)` where `kem_output` is the first
|
|
/// `HYBRID_KEM_OUTPUT_LEN` bytes of the hybrid envelope and `shared_secret`
|
|
/// is the 32-byte derived key (same as used for AEAD in `hybrid_encrypt`).
|
|
/// Used by MLS HPKE exporter (setup_sender_and_export).
|
|
pub fn hybrid_encapsulate_only(
|
|
recipient_pk: &HybridPublicKey,
|
|
) -> Result<(Vec<u8>, [u8; 32]), HybridKemError> {
|
|
let eph_secret = EphemeralSecret::random_from_rng(OsRng);
|
|
let eph_public = X25519Public::from(&eph_secret);
|
|
let x25519_recipient = X25519Public::from(recipient_pk.x25519_pk);
|
|
let x25519_ss = eph_secret.diffie_hellman(&x25519_recipient);
|
|
|
|
let mlkem_ek_arr = Array::try_from(recipient_pk.mlkem_ek.as_slice())
|
|
.map_err(|_| HybridKemError::InvalidMlKemKey)?;
|
|
let mlkem_ek = EncapsulationKey::<MlKem768Params>::from_bytes(&mlkem_ek_arr);
|
|
let (mlkem_ct, mlkem_ss) = mlkem_ek
|
|
.encapsulate(&mut OsRng)
|
|
.map_err(|_| HybridKemError::EncryptionFailed)?;
|
|
|
|
let aead_key = derive_aead_key(x25519_ss.as_bytes(), mlkem_ss.as_slice(), b"");
|
|
let shared_secret: [u8; 32] = aead_key.as_slice().try_into()
|
|
.expect("AEAD key is always exactly 32 bytes");
|
|
|
|
let mut kem_output = Vec::with_capacity(HYBRID_KEM_OUTPUT_LEN);
|
|
kem_output.push(HYBRID_VERSION);
|
|
kem_output.extend_from_slice(&eph_public.to_bytes());
|
|
kem_output.extend_from_slice(mlkem_ct.as_slice());
|
|
|
|
Ok((kem_output, shared_secret))
|
|
}
|
|
|
|
/// Decapsulate only: recover shared secret from KEM output (no AEAD).
|
|
/// Used by MLS HPKE exporter (setup_receiver_and_export).
|
|
pub fn hybrid_decapsulate_only(
|
|
keypair: &HybridKeypair,
|
|
kem_output: &[u8],
|
|
) -> Result<[u8; 32], HybridKemError> {
|
|
if kem_output.len() < HYBRID_KEM_OUTPUT_LEN {
|
|
return Err(HybridKemError::TooShort(kem_output.len()));
|
|
}
|
|
if kem_output[0] != HYBRID_VERSION {
|
|
return Err(HybridKemError::UnsupportedVersion(kem_output[0]));
|
|
}
|
|
|
|
let eph_pk_bytes: [u8; 32] = kem_output[1..33].try_into()
|
|
.expect("slice is exactly 32 bytes (guaranteed by HYBRID_KEM_OUTPUT_LEN check)");
|
|
let eph_pk = X25519Public::from(eph_pk_bytes);
|
|
let x25519_ss = keypair.x25519_sk.diffie_hellman(&eph_pk);
|
|
|
|
let mlkem_ct_arr = Array::try_from(&kem_output[33..33 + MLKEM_CT_LEN])
|
|
.map_err(|_| HybridKemError::MlKemDecapsFailed)?;
|
|
let mlkem_ss = keypair
|
|
.mlkem_dk
|
|
.decapsulate(&mlkem_ct_arr)
|
|
.map_err(|_| HybridKemError::MlKemDecapsFailed)?;
|
|
|
|
let aead_key = derive_aead_key(x25519_ss.as_bytes(), mlkem_ss.as_slice(), b"");
|
|
Ok(aead_key.as_slice().try_into()
|
|
.expect("AEAD key is always exactly 32 bytes"))
|
|
}
|
|
|
|
/// Export a secret from shared secret (MLS HPKE exporter compatibility).
|
|
/// Uses HKDF-Expand(prk, exporter_context, length) with prk = HKDF-Extract(0, shared_secret).
|
|
pub fn hybrid_export(
|
|
shared_secret: &[u8; 32],
|
|
exporter_context: &[u8],
|
|
length: usize,
|
|
) -> Vec<u8> {
|
|
let hk = Hkdf::<Sha256>::new(Some(HKDF_SALT), shared_secret);
|
|
let mut out = vec![0u8; length];
|
|
hk.expand(exporter_context, &mut out).expect("valid length");
|
|
out
|
|
}
|
|
|
|
/// Derive AEAD key from the combined X25519 + ML-KEM shared secrets.
|
|
///
|
|
/// `extra_info` is optional caller-supplied context (e.g. HPKE `info`) that is
|
|
/// appended to the domain-separation label for additional binding.
|
|
///
|
|
/// The nonce is generated randomly per-encryption rather than derived from
|
|
/// HKDF, preventing nonce reuse when the same shared secret is (accidentally)
|
|
/// used more than once.
|
|
fn derive_aead_key(x25519_ss: &[u8], mlkem_ss: &[u8], extra_info: &[u8]) -> Key {
|
|
let mut ikm = Zeroizing::new(vec![0u8; x25519_ss.len() + mlkem_ss.len()]);
|
|
ikm[..x25519_ss.len()].copy_from_slice(x25519_ss);
|
|
ikm[x25519_ss.len()..].copy_from_slice(mlkem_ss);
|
|
|
|
let hk = Hkdf::<Sha256>::new(Some(HKDF_SALT), &ikm);
|
|
|
|
// Combine domain-separation label with caller-supplied context.
|
|
let mut info = Vec::with_capacity(HKDF_INFO.len() + extra_info.len());
|
|
info.extend_from_slice(HKDF_INFO);
|
|
info.extend_from_slice(extra_info);
|
|
|
|
let mut key_bytes = Zeroizing::new([0u8; 32]);
|
|
hk.expand(&info, &mut *key_bytes)
|
|
.expect("32 bytes is valid HKDF-SHA256 output length");
|
|
|
|
*Key::from_slice(&*key_bytes)
|
|
}
|
|
|
|
// ── Tests ───────────────────────────────────────────────────────────────────
|
|
|
|
#[cfg(test)]
|
|
#[allow(clippy::unwrap_used)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn keygen_produces_valid_public_key() {
|
|
let kp = HybridKeypair::generate();
|
|
let pk = kp.public_key();
|
|
assert_eq!(pk.x25519_pk.len(), 32);
|
|
assert_eq!(pk.mlkem_ek.len(), MLKEM_EK_LEN);
|
|
}
|
|
|
|
#[test]
|
|
fn encrypt_decrypt_round_trip() {
|
|
let kp = HybridKeypair::generate();
|
|
let pk = kp.public_key();
|
|
let plaintext = b"hello post-quantum world!";
|
|
|
|
let envelope = hybrid_encrypt(&pk, plaintext, b"", b"").unwrap();
|
|
let recovered = hybrid_decrypt(&kp, &envelope, b"", b"").unwrap();
|
|
|
|
assert_eq!(recovered, plaintext);
|
|
}
|
|
|
|
#[test]
|
|
fn encrypt_decrypt_with_info_aad() {
|
|
let kp = HybridKeypair::generate();
|
|
let pk = kp.public_key();
|
|
let plaintext = b"context-bound payload";
|
|
let info = b"mls epoch 42";
|
|
let aad = b"group-id-abc";
|
|
|
|
let envelope = hybrid_encrypt(&pk, plaintext, info, aad).unwrap();
|
|
let recovered = hybrid_decrypt(&kp, &envelope, info, aad).unwrap();
|
|
assert_eq!(recovered, plaintext);
|
|
|
|
// Mismatched info must fail
|
|
assert!(hybrid_decrypt(&kp, &envelope, b"wrong info", aad).is_err());
|
|
// Mismatched aad must fail
|
|
assert!(hybrid_decrypt(&kp, &envelope, info, b"wrong aad").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn wrong_key_decryption_fails() {
|
|
let kp_sender_target = HybridKeypair::generate();
|
|
let kp_wrong = HybridKeypair::generate();
|
|
|
|
let pk = kp_sender_target.public_key();
|
|
let envelope = hybrid_encrypt(&pk, b"secret", b"", b"").unwrap();
|
|
|
|
let result = hybrid_decrypt(&kp_wrong, &envelope, b"", b"");
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn tampered_aead_ciphertext_fails() {
|
|
let kp = HybridKeypair::generate();
|
|
let pk = kp.public_key();
|
|
|
|
let mut envelope = hybrid_encrypt(&pk, b"payload", b"", b"").unwrap();
|
|
let last = envelope.len() - 1;
|
|
envelope[last] ^= 0x01;
|
|
|
|
assert!(matches!(
|
|
hybrid_decrypt(&kp, &envelope, b"", b""),
|
|
Err(HybridKemError::DecryptionFailed)
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn tampered_mlkem_ct_fails() {
|
|
let kp = HybridKeypair::generate();
|
|
let pk = kp.public_key();
|
|
|
|
let mut envelope = hybrid_encrypt(&pk, b"payload", b"", b"").unwrap();
|
|
// Flip a byte in the ML-KEM ciphertext region (starts at offset 33)
|
|
envelope[40] ^= 0xFF;
|
|
|
|
assert!(hybrid_decrypt(&kp, &envelope, b"", b"").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn tampered_x25519_eph_pk_fails() {
|
|
let kp = HybridKeypair::generate();
|
|
let pk = kp.public_key();
|
|
|
|
let mut envelope = hybrid_encrypt(&pk, b"payload", b"", b"").unwrap();
|
|
// Flip a byte in the X25519 ephemeral pk region (offset 1..33)
|
|
envelope[5] ^= 0xFF;
|
|
|
|
assert!(hybrid_decrypt(&kp, &envelope, b"", b"").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn unsupported_version_rejected() {
|
|
let kp = HybridKeypair::generate();
|
|
let pk = kp.public_key();
|
|
|
|
let mut envelope = hybrid_encrypt(&pk, b"payload", b"", b"").unwrap();
|
|
envelope[0] = 0xFF;
|
|
|
|
assert!(matches!(
|
|
hybrid_decrypt(&kp, &envelope, b"", b""),
|
|
Err(HybridKemError::UnsupportedVersion(0xFF))
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn envelope_too_short_rejected() {
|
|
let kp = HybridKeypair::generate();
|
|
assert!(matches!(
|
|
hybrid_decrypt(&kp, &[0x01; 10], b"", b""),
|
|
Err(HybridKemError::TooShort(10))
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn keypair_serialisation_round_trip() {
|
|
let kp = HybridKeypair::generate();
|
|
let bytes = kp.to_bytes();
|
|
let restored = HybridKeypair::from_bytes(&bytes).unwrap();
|
|
|
|
assert_eq!(kp.x25519_pk.to_bytes(), restored.x25519_pk.to_bytes());
|
|
assert_eq!(kp.public_key().mlkem_ek, restored.public_key().mlkem_ek);
|
|
|
|
// Verify restored keypair can decrypt
|
|
let pk = kp.public_key();
|
|
let ct = hybrid_encrypt(&pk, b"test", b"", b"").unwrap();
|
|
let pt = hybrid_decrypt(&restored, &ct, b"", b"").unwrap();
|
|
assert_eq!(pt, b"test");
|
|
}
|
|
|
|
#[test]
|
|
fn public_key_serialisation_round_trip() {
|
|
let kp = HybridKeypair::generate();
|
|
let pk = kp.public_key();
|
|
let bytes = pk.to_bytes();
|
|
let restored = HybridPublicKey::from_bytes(&bytes).unwrap();
|
|
|
|
assert_eq!(pk.x25519_pk, restored.x25519_pk);
|
|
assert_eq!(pk.mlkem_ek, restored.mlkem_ek);
|
|
}
|
|
|
|
#[test]
|
|
fn large_payload_round_trip() {
|
|
let kp = HybridKeypair::generate();
|
|
let pk = kp.public_key();
|
|
let plaintext = vec![0xAB; 50_000]; // 50 KB
|
|
|
|
let envelope = hybrid_encrypt(&pk, &plaintext, b"", b"").unwrap();
|
|
let recovered = hybrid_decrypt(&kp, &envelope, b"", b"").unwrap();
|
|
|
|
assert_eq!(recovered, plaintext);
|
|
}
|
|
}
|