b8ce58ddf0
aura-crypto: X25519 + ML-KEM-768 (FIPS 203) hybrid KEM, HKDF-SHA256 session key derivation, ChaCha20-Poly1305 AeadSession with counter nonces; genuine NIST ACVP ML-KEM-768 KAT (decapsulation vector). 16 tests green, clippy clean. aura-pki: self-signed CA, server/client cert issuance (rcgen 0.14), mutual X.509 chain verification via rustls-webpki, CRL revocation. 8 tests green. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
250 lines
8.9 KiB
Rust
250 lines
8.9 KiB
Rust
//! Integration tests for the hybrid KEM, HKDF key derivation, and AEAD session.
|
|
//!
|
|
//! These exercise the public API exactly as a downstream crate (e.g. the Aura handshake) would.
|
|
|
|
use aura_crypto::{derive_session_keys, AeadSession, HybridPrivateKey, HybridSharedSecret};
|
|
|
|
/// keygen -> encapsulate -> decapsulate; both shared-secret halves must agree.
|
|
#[test]
|
|
fn test_hybrid_roundtrip() {
|
|
let (private, public) = HybridPrivateKey::generate();
|
|
|
|
let (ciphertext, ss_server) = public.encapsulate();
|
|
let ss_client = private
|
|
.decapsulate(&ciphertext)
|
|
.expect("decapsulation succeeds");
|
|
|
|
assert_eq!(
|
|
ss_server.x25519_ss, ss_client.x25519_ss,
|
|
"x25519 shared secret halves must match"
|
|
);
|
|
assert_eq!(
|
|
ss_server.kyber_ss, ss_client.kyber_ss,
|
|
"ML-KEM shared secret halves must match"
|
|
);
|
|
// Sanity: the two halves are independent 32-byte values.
|
|
assert_eq!(ss_server.x25519_ss.len(), 32);
|
|
assert_eq!(ss_server.kyber_ss.len(), 32);
|
|
}
|
|
|
|
/// Run the hybrid roundtrip many times to catch any rare encode/decode mismatch.
|
|
#[test]
|
|
fn test_hybrid_roundtrip_property() {
|
|
for _ in 0..50 {
|
|
let (private, public) = HybridPrivateKey::generate();
|
|
let (ct, ss_server) = public.encapsulate();
|
|
let ss_client = private.decapsulate(&ct).expect("decapsulation succeeds");
|
|
assert_eq!(ss_server.x25519_ss, ss_client.x25519_ss);
|
|
assert_eq!(ss_server.kyber_ss, ss_client.kyber_ss);
|
|
}
|
|
}
|
|
|
|
/// Two independent keypairs must (overwhelmingly) not produce colliding ciphertexts/secrets, and
|
|
/// decapsulating someone else's ciphertext with the wrong key must NOT yield the server's secret.
|
|
#[test]
|
|
fn test_hybrid_wrong_key_disagrees() {
|
|
let (private_a, _public_a) = HybridPrivateKey::generate();
|
|
let (_private_b, public_b) = HybridPrivateKey::generate();
|
|
|
|
// Encapsulate to B, then try to decapsulate with A's key.
|
|
let (ct_for_b, ss_b) = public_b.encapsulate();
|
|
let ss_a = private_a
|
|
.decapsulate(&ct_for_b)
|
|
.expect("decapsulation is infallible on a well-formed ciphertext");
|
|
|
|
// x25519 half must differ (different static keys).
|
|
assert_ne!(ss_a.x25519_ss, ss_b.x25519_ss);
|
|
// ML-KEM half differs too: implicit rejection yields an unrelated pseudo-random secret.
|
|
assert_ne!(ss_a.kyber_ss, ss_b.kyber_ss);
|
|
}
|
|
|
|
/// Helper: build a `HybridSharedSecret` from raw halves for KDF tests.
|
|
fn shared_from(x: [u8; 32], k: [u8; 32]) -> HybridSharedSecret {
|
|
// We cannot construct `HybridSharedSecret` field-by-field from outside via a constructor, but
|
|
// its fields are public, so build it directly.
|
|
HybridSharedSecret {
|
|
x25519_ss: x,
|
|
kyber_ss: k.to_vec(),
|
|
}
|
|
}
|
|
|
|
/// Same (shared, nonces) -> identical keys; changing a nonce -> different keys.
|
|
#[test]
|
|
fn test_kdf_deterministic() {
|
|
let shared = shared_from([7u8; 32], [9u8; 32]);
|
|
let client_nonce = [1u8; 32];
|
|
let server_nonce = [2u8; 32];
|
|
|
|
let k1 = derive_session_keys(&shared, &client_nonce, &server_nonce);
|
|
let k2 = derive_session_keys(&shared, &client_nonce, &server_nonce);
|
|
|
|
assert_eq!(k1.client_to_server, k2.client_to_server);
|
|
assert_eq!(k1.server_to_client, k2.server_to_client);
|
|
|
|
// The two directional keys are distinct (different halves of the HKDF output).
|
|
assert_ne!(k1.client_to_server, k1.server_to_client);
|
|
|
|
// Changing the client nonce changes the keys.
|
|
let mut other_client = client_nonce;
|
|
other_client[0] ^= 0xFF;
|
|
let k3 = derive_session_keys(&shared, &other_client, &server_nonce);
|
|
assert_ne!(k1.client_to_server, k3.client_to_server);
|
|
assert_ne!(k1.server_to_client, k3.server_to_client);
|
|
|
|
// Changing the server nonce changes the keys.
|
|
let mut other_server = server_nonce;
|
|
other_server[31] ^= 0x01;
|
|
let k4 = derive_session_keys(&shared, &client_nonce, &other_server);
|
|
assert_ne!(k1.client_to_server, k4.client_to_server);
|
|
|
|
// Changing the shared secret changes the keys.
|
|
let shared2 = shared_from([8u8; 32], [9u8; 32]);
|
|
let k5 = derive_session_keys(&shared2, &client_nonce, &server_nonce);
|
|
assert_ne!(k1.client_to_server, k5.client_to_server);
|
|
}
|
|
|
|
/// End-to-end: derive keys from a real handshake, then check the directional keys actually
|
|
/// protect traffic.
|
|
#[test]
|
|
fn test_kdf_from_real_handshake() {
|
|
let (private, public) = HybridPrivateKey::generate();
|
|
let (ct, ss_server) = public.encapsulate();
|
|
let ss_client = private.decapsulate(&ct).expect("decapsulate");
|
|
|
|
let client_nonce = [0x11u8; 32];
|
|
let server_nonce = [0x22u8; 32];
|
|
|
|
let server_keys = derive_session_keys(&ss_server, &client_nonce, &server_nonce);
|
|
let client_keys = derive_session_keys(&ss_client, &client_nonce, &server_nonce);
|
|
|
|
// Both sides derive the same key material.
|
|
assert_eq!(server_keys.client_to_server, client_keys.client_to_server);
|
|
assert_eq!(server_keys.server_to_client, client_keys.server_to_client);
|
|
}
|
|
|
|
/// seal then open returns the plaintext when AAD matches.
|
|
#[test]
|
|
fn test_aead_roundtrip() {
|
|
let key = [0x42u8; 32];
|
|
let mut sender = AeadSession::new(key);
|
|
let mut receiver = AeadSession::new(key);
|
|
|
|
let plaintext = b"hybrid post-quantum VPN payload";
|
|
let aad = b"aura-header-v1";
|
|
|
|
let ct = sender.seal(plaintext, aad);
|
|
// Ciphertext is plaintext length + 16-byte Poly1305 tag.
|
|
assert_eq!(ct.len(), plaintext.len() + 16);
|
|
|
|
let recovered = receiver
|
|
.open(&ct, aad)
|
|
.expect("open succeeds with matching AAD");
|
|
assert_eq!(recovered, plaintext);
|
|
}
|
|
|
|
/// Multiple sequential messages stay aligned between a sender and receiver session.
|
|
#[test]
|
|
fn test_aead_sequential_messages() {
|
|
let key = [0x01u8; 32];
|
|
let mut sender = AeadSession::new(key);
|
|
let mut receiver = AeadSession::new(key);
|
|
|
|
for i in 0u32..100 {
|
|
let msg = format!("message number {i}");
|
|
let aad = i.to_le_bytes();
|
|
let ct = sender.seal(msg.as_bytes(), &aad);
|
|
let pt = receiver.open(&ct, &aad).expect("aligned open succeeds");
|
|
assert_eq!(pt, msg.as_bytes());
|
|
}
|
|
}
|
|
|
|
/// Flipping a ciphertext byte, changing AAD, or using the wrong key must fail authentication.
|
|
#[test]
|
|
fn test_aead_tamper_detection() {
|
|
let key = [0x42u8; 32];
|
|
let aad = b"aura-header-v1";
|
|
let plaintext = b"top secret";
|
|
|
|
// 1. Flip a ciphertext byte.
|
|
{
|
|
let mut sender = AeadSession::new(key);
|
|
let mut receiver = AeadSession::new(key);
|
|
let mut ct = sender.seal(plaintext, aad);
|
|
ct[0] ^= 0x01;
|
|
assert!(
|
|
receiver.open(&ct, aad).is_err(),
|
|
"tampered ciphertext must fail"
|
|
);
|
|
}
|
|
|
|
// 2. Flip a tag byte (last byte).
|
|
{
|
|
let mut sender = AeadSession::new(key);
|
|
let mut receiver = AeadSession::new(key);
|
|
let mut ct = sender.seal(plaintext, aad);
|
|
let last = ct.len() - 1;
|
|
ct[last] ^= 0x80;
|
|
assert!(receiver.open(&ct, aad).is_err(), "tampered tag must fail");
|
|
}
|
|
|
|
// 3. Change the AAD.
|
|
{
|
|
let mut sender = AeadSession::new(key);
|
|
let mut receiver = AeadSession::new(key);
|
|
let ct = sender.seal(plaintext, aad);
|
|
assert!(
|
|
receiver.open(&ct, b"different-aad").is_err(),
|
|
"mismatched AAD must fail"
|
|
);
|
|
}
|
|
|
|
// 4. Wrong key.
|
|
{
|
|
let mut sender = AeadSession::new(key);
|
|
let mut receiver = AeadSession::new([0x00u8; 32]);
|
|
let ct = sender.seal(plaintext, aad);
|
|
assert!(receiver.open(&ct, aad).is_err(), "wrong key must fail");
|
|
}
|
|
}
|
|
|
|
/// A failed `open` still advances the counter, keeping a (sender, receiver) pair aligned for the
|
|
/// next message (so a single dropped/tampered frame does not desynchronize the stream here).
|
|
#[test]
|
|
fn test_aead_counter_advances_on_failure() {
|
|
let key = [0x55u8; 32];
|
|
let mut sender = AeadSession::new(key);
|
|
let mut receiver = AeadSession::new(key);
|
|
|
|
// Message 0: tamper -> fails, but receiver counter advances to 1.
|
|
let mut ct0 = sender.seal(b"first", b"a");
|
|
ct0[0] ^= 0x01;
|
|
assert!(receiver.open(&ct0, b"a").is_err());
|
|
|
|
// Message 1: both at counter 1 now -> succeeds.
|
|
let ct1 = sender.seal(b"second", b"b");
|
|
let pt1 = receiver.open(&ct1, b"b").expect("counters re-aligned at 1");
|
|
assert_eq!(pt1, b"second");
|
|
}
|
|
|
|
/// 10_000 seal calls must use 10_000 distinct nonces. We verify this behaviorally: encrypting the
|
|
/// *same* plaintext+AAD under the same key 10_000 times yields 10_000 distinct ciphertexts, which
|
|
/// can only happen if the (counter-derived) nonce never repeats.
|
|
#[test]
|
|
fn test_nonce_no_repeat() {
|
|
use std::collections::HashSet;
|
|
|
|
let mut session = AeadSession::new([0x7Au8; 32]);
|
|
let plaintext = b"constant";
|
|
let aad = b"constant-aad";
|
|
|
|
let mut seen: HashSet<Vec<u8>> = HashSet::with_capacity(10_000);
|
|
for _ in 0..10_000 {
|
|
let ct = session.seal(plaintext, aad);
|
|
assert!(
|
|
seen.insert(ct),
|
|
"nonce reuse produced a duplicate ciphertext"
|
|
);
|
|
}
|
|
assert_eq!(seen.len(), 10_000);
|
|
}
|