Some checks failed
CI / lint (push) Successful in 1m37s
CI / test-python (push) Successful in 1m49s
CI / test-zig (push) Successful in 1m39s
CI / test-wasm (push) Successful in 1m54s
CI / test (push) Successful in 14m44s
CI / miri (push) Successful in 14m18s
CI / build (push) Successful in 1m9s
CI / fuzz-regression (push) Successful in 9m9s
CI / publish (push) Failing after 1m10s
CI / publish-python (push) Failing after 1m46s
CI / publish-wasm (push) Has been cancelled
Signed-off-by: Kamal Tufekcic <kamal@lo.sh>
758 lines
29 KiB
Rust
758 lines
29 KiB
Rust
#![allow(deprecated)] // Tests exercise from_bytes directly for parser coverage.
|
|
|
|
use soliton::constants;
|
|
use soliton::identity::{
|
|
GeneratedIdentity, IdentityPublicKey, IdentitySecretKey, generate_identity,
|
|
};
|
|
use soliton::kex::{
|
|
PreKeyBundle, build_first_message_aad, initiate_session, receive_session, sign_prekey,
|
|
verify_bundle,
|
|
};
|
|
use soliton::primitives::xwing;
|
|
use soliton::ratchet::RatchetState;
|
|
|
|
/// Generate a full identity + signed pre-key bundle for testing.
|
|
fn setup_peer() -> (
|
|
IdentityPublicKey,
|
|
IdentitySecretKey,
|
|
xwing::PublicKey,
|
|
xwing::SecretKey,
|
|
PreKeyBundle,
|
|
) {
|
|
let GeneratedIdentity {
|
|
public_key: ik_pk,
|
|
secret_key: ik_sk,
|
|
..
|
|
} = generate_identity().unwrap();
|
|
let (spk_pk, spk_sk) = xwing::keygen().unwrap();
|
|
let spk_sig = sign_prekey(&ik_sk, &spk_pk).unwrap();
|
|
let bundle = PreKeyBundle {
|
|
ik_pub: IdentityPublicKey::from_bytes(ik_pk.as_bytes().to_vec()).unwrap(),
|
|
crypto_version: constants::CRYPTO_VERSION.to_string(),
|
|
spk_pub: spk_pk.clone(),
|
|
spk_id: 1,
|
|
spk_sig,
|
|
opk_pub: None,
|
|
opk_id: None,
|
|
};
|
|
(ik_pk, ik_sk, spk_pk, spk_sk, bundle)
|
|
}
|
|
|
|
/// Run a full KEX and return initialized ratchet states + fingerprints.
|
|
fn do_kex(with_opk: bool) -> (RatchetState, RatchetState, [u8; 32], [u8; 32]) {
|
|
let (alice_ik_pk, alice_ik_sk, _alice_spk_pk, _alice_spk_sk, _alice_bundle) = setup_peer();
|
|
let (bob_ik_pk, bob_ik_sk, _bob_spk_pk, bob_spk_sk, mut bob_bundle) = setup_peer();
|
|
|
|
let opk_sk = if with_opk {
|
|
let (pk, sk) = xwing::keygen().unwrap();
|
|
bob_bundle.opk_pub = Some(pk);
|
|
bob_bundle.opk_id = Some(42);
|
|
Some(sk)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let fp_a = alice_ik_pk.fingerprint_raw();
|
|
let fp_b = bob_ik_pk.fingerprint_raw();
|
|
|
|
// Alice verifies Bob's bundle and initiates session.
|
|
let verified = verify_bundle(bob_bundle, &bob_ik_pk).unwrap();
|
|
let mut initiated = initiate_session(&alice_ik_pk, &alice_ik_sk, &verified).unwrap();
|
|
|
|
// Build first message AAD.
|
|
let aad = build_first_message_aad(&fp_a, &fp_b, &initiated.session_init).unwrap();
|
|
|
|
// Alice encrypts first message, consuming the initial chain key.
|
|
let (first_ct, alice_ck) =
|
|
RatchetState::encrypt_first_message(initiated.take_initial_chain_key(), b"hello bob", &aad)
|
|
.unwrap();
|
|
|
|
// Bob receives the session — sender_sig proves Alice initiated (not an impersonator).
|
|
let mut received = receive_session(
|
|
&bob_ik_pk,
|
|
&bob_ik_sk,
|
|
&alice_ik_pk,
|
|
&initiated.session_init,
|
|
&initiated.sender_sig,
|
|
&bob_spk_sk,
|
|
opk_sk.as_ref(),
|
|
)
|
|
.unwrap();
|
|
|
|
// Bob decrypts the first message, consuming the initial chain key.
|
|
let (first_pt, bob_ck) =
|
|
RatchetState::decrypt_first_message(received.take_initial_chain_key(), &first_ct, &aad)
|
|
.unwrap();
|
|
assert_eq!(&*first_pt, b"hello bob");
|
|
|
|
// Chain keys must match after first message.
|
|
assert_eq!(*alice_ck, *bob_ck);
|
|
|
|
// Extract keys via take_ methods — each replaces the internal value with
|
|
// zeros, enforcing single-use. ek_pk/peer_ek are pub (non-secret).
|
|
let ek_pk = xwing::PublicKey::from_bytes(initiated.ek_pk.as_bytes().to_vec()).unwrap();
|
|
let ek_sk = xwing::SecretKey::from_bytes(initiated.ek_sk().as_bytes().to_vec()).unwrap();
|
|
let peer_ek = xwing::PublicKey::from_bytes(received.peer_ek.as_bytes().to_vec()).unwrap();
|
|
let alice = RatchetState::init_alice(
|
|
*initiated.take_root_key(),
|
|
*alice_ck,
|
|
fp_a,
|
|
fp_b,
|
|
ek_pk,
|
|
ek_sk,
|
|
)
|
|
.unwrap();
|
|
let bob =
|
|
RatchetState::init_bob(*received.take_root_key(), *bob_ck, fp_b, fp_a, peer_ek).unwrap();
|
|
|
|
(alice, bob, fp_a, fp_b)
|
|
}
|
|
|
|
/// Helper: Alice sends to Bob.
|
|
fn send_a_to_b(alice: &mut RatchetState, bob: &mut RatchetState, msg: &[u8]) -> Vec<u8> {
|
|
let enc = alice.encrypt(msg).unwrap();
|
|
let pt = bob.decrypt(&enc.header, &enc.ciphertext).unwrap();
|
|
pt.to_vec()
|
|
}
|
|
|
|
/// Helper: Bob sends to Alice.
|
|
fn send_b_to_a(alice: &mut RatchetState, bob: &mut RatchetState, msg: &[u8]) -> Vec<u8> {
|
|
let enc = bob.encrypt(msg).unwrap();
|
|
let pt = alice.decrypt(&enc.header, &enc.ciphertext).unwrap();
|
|
pt.to_vec()
|
|
}
|
|
|
|
#[test]
|
|
fn full_session_lifecycle() {
|
|
let (mut alice, mut bob, _fp_a, _fp_b) = do_kex(true);
|
|
|
|
// Exchange a few messages.
|
|
let pt = send_a_to_b(&mut alice, &mut bob, b"msg1");
|
|
assert_eq!(pt, b"msg1");
|
|
let pt = send_b_to_a(&mut alice, &mut bob, b"msg2");
|
|
assert_eq!(pt, b"msg2");
|
|
|
|
// Serialize both sides.
|
|
let alice_bytes = alice.to_bytes().unwrap().0;
|
|
let bob_bytes = bob.to_bytes().unwrap().0;
|
|
|
|
// Deserialize.
|
|
let mut alice2 = RatchetState::from_bytes(&alice_bytes).unwrap();
|
|
let mut bob2 = RatchetState::from_bytes(&bob_bytes).unwrap();
|
|
|
|
// Continue conversation after deserialization.
|
|
let pt = send_a_to_b(&mut alice2, &mut bob2, b"post-restore");
|
|
assert_eq!(pt, b"post-restore");
|
|
}
|
|
|
|
#[test]
|
|
fn basic_session_with_opk_enabled() {
|
|
let (mut alice, mut bob, _fp_a, _fp_b) = do_kex(true);
|
|
let pt = send_a_to_b(&mut alice, &mut bob, b"with opk");
|
|
assert_eq!(pt, b"with opk");
|
|
let pt = send_b_to_a(&mut alice, &mut bob, b"reply");
|
|
assert_eq!(pt, b"reply");
|
|
}
|
|
|
|
#[test]
|
|
fn full_session_without_opk() {
|
|
let (mut alice, mut bob, _fp_a, _fp_b) = do_kex(false);
|
|
let pt = send_a_to_b(&mut alice, &mut bob, b"no opk");
|
|
assert_eq!(pt, b"no opk");
|
|
let pt = send_b_to_a(&mut alice, &mut bob, b"reply");
|
|
assert_eq!(pt, b"reply");
|
|
}
|
|
|
|
#[test]
|
|
fn bidirectional_ratchet() {
|
|
let (mut alice, mut bob, _fp_a, _fp_b) = do_kex(true);
|
|
// Multiple ratchet epochs: direction changes trigger DH ratchet steps.
|
|
for i in 0..10u32 {
|
|
if i % 2 == 0 {
|
|
let msg = format!("a-to-b #{i}");
|
|
let pt = send_a_to_b(&mut alice, &mut bob, msg.as_bytes());
|
|
assert_eq!(pt, msg.as_bytes());
|
|
} else {
|
|
let msg = format!("b-to-a #{i}");
|
|
let pt = send_b_to_a(&mut alice, &mut bob, msg.as_bytes());
|
|
assert_eq!(pt, msg.as_bytes());
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn out_of_order_across_ratchet_steps() {
|
|
let (mut alice, mut bob, _fp_a, _fp_b) = do_kex(true);
|
|
|
|
// Alice sends 3 messages in the same epoch.
|
|
let enc0 = alice.encrypt(b"msg0").unwrap();
|
|
let enc1 = alice.encrypt(b"msg1").unwrap();
|
|
let enc2 = alice.encrypt(b"msg2").unwrap();
|
|
|
|
// Bob receives msg2 first (skipping 0 and 1).
|
|
let pt2 = bob.decrypt(&enc2.header, &enc2.ciphertext).unwrap();
|
|
assert_eq!(&*pt2, b"msg2");
|
|
|
|
// Bob sends back (triggers ratchet step on both sides when Alice receives).
|
|
let enc_b = bob.encrypt(b"bob reply").unwrap();
|
|
let pt_b = alice.decrypt(&enc_b.header, &enc_b.ciphertext).unwrap();
|
|
assert_eq!(&*pt_b, b"bob reply");
|
|
|
|
// Bob can still decrypt the earlier messages via prev_recv_epoch_key grace period.
|
|
let pt0 = bob.decrypt(&enc0.header, &enc0.ciphertext).unwrap();
|
|
assert_eq!(&*pt0, b"msg0");
|
|
let pt1 = bob.decrypt(&enc1.header, &enc1.ciphertext).unwrap();
|
|
assert_eq!(&*pt1, b"msg1");
|
|
}
|
|
|
|
#[test]
|
|
fn serialize_with_out_of_order_then_consume() {
|
|
let (mut alice, mut bob, _fp_a, _fp_b) = do_kex(true);
|
|
|
|
// Alice sends 3 messages.
|
|
let enc0 = alice.encrypt(b"msg0").unwrap();
|
|
let enc1 = alice.encrypt(b"msg1").unwrap();
|
|
let enc2 = alice.encrypt(b"msg2").unwrap();
|
|
|
|
// Bob receives only msg2 (out of order).
|
|
bob.decrypt(&enc2.header, &enc2.ciphertext).unwrap();
|
|
|
|
// Serialize and deserialize Bob.
|
|
let bob_bytes = bob.to_bytes().unwrap().0;
|
|
let mut bob2 = RatchetState::from_bytes(&bob_bytes).unwrap();
|
|
|
|
// Consume the out-of-order messages via counter-mode derivation.
|
|
let pt0 = bob2.decrypt(&enc0.header, &enc0.ciphertext).unwrap();
|
|
assert_eq!(&*pt0, b"msg0");
|
|
let pt1 = bob2.decrypt(&enc1.header, &enc1.ciphertext).unwrap();
|
|
assert_eq!(&*pt1, b"msg1");
|
|
}
|
|
|
|
#[test]
|
|
fn session_reset_reestablishment() {
|
|
let (mut alice, mut bob, _fp_a, _fp_b) = do_kex(true);
|
|
send_a_to_b(&mut alice, &mut bob, b"before reset");
|
|
|
|
// Reset both sides.
|
|
alice.reset();
|
|
bob.reset();
|
|
|
|
// Old states are unusable.
|
|
assert!(alice.encrypt(b"test").is_err());
|
|
assert!(bob.encrypt(b"test").is_err());
|
|
|
|
// Establish a completely new session.
|
|
let (mut alice2, mut bob2, _fp_a2, _fp_b2) = do_kex(false);
|
|
let pt = send_a_to_b(&mut alice2, &mut bob2, b"new session");
|
|
assert_eq!(pt, b"new session");
|
|
}
|
|
|
|
#[test]
|
|
fn long_conversation_stress() {
|
|
let (mut alice, mut bob, _fp_a, _fp_b) = do_kex(true);
|
|
for i in 0..120u32 {
|
|
let pt = if i % 5 < 3 {
|
|
send_a_to_b(&mut alice, &mut bob, &i.to_be_bytes())
|
|
} else {
|
|
send_b_to_a(&mut alice, &mut bob, &i.to_be_bytes())
|
|
};
|
|
// Serialize mid-conversation.
|
|
if i == 60 {
|
|
let a_bytes = alice.to_bytes().unwrap().0;
|
|
let b_bytes = bob.to_bytes().unwrap().0;
|
|
alice = RatchetState::from_bytes(&a_bytes).unwrap();
|
|
bob = RatchetState::from_bytes(&b_bytes).unwrap();
|
|
}
|
|
// Verify plaintext content for the first message after the serialization
|
|
// round-trip to confirm state survives serialization without corrupting
|
|
// message content (not just AEAD authentication).
|
|
if i == 61 {
|
|
assert_eq!(
|
|
pt,
|
|
(61u32).to_be_bytes(),
|
|
"plaintext must survive serialization round-trip"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn ratchet_derive_call_keys_delegates() {
|
|
let (alice, bob, fp_a, fp_b) = do_kex(true);
|
|
let kem_ss: [u8; 32] = soliton::primitives::random::random_array();
|
|
let call_id: [u8; 16] = soliton::primitives::random::random_array();
|
|
|
|
// Cross-party agreement: alice's send == bob's recv and vice versa.
|
|
let alice_keys = alice.derive_call_keys(&kem_ss, &call_id).unwrap();
|
|
let bob_keys = bob.derive_call_keys(&kem_ss, &call_id).unwrap();
|
|
assert_eq!(alice_keys.send_key(), bob_keys.recv_key());
|
|
assert_eq!(alice_keys.recv_key(), bob_keys.send_key());
|
|
|
|
// White-box: verify RatchetState::derive_call_keys truly delegates to
|
|
// call::derive_call_keys with self.root_key and stored fingerprints.
|
|
// Both parties share the same root key after KEX, so verify with Alice's side.
|
|
#[allow(deprecated)]
|
|
let root_key = alice.root_key_bytes();
|
|
let direct =
|
|
soliton::call::derive_call_keys(root_key, &kem_ss, &call_id, &fp_a, &fp_b).unwrap();
|
|
assert_eq!(alice_keys.send_key(), direct.send_key());
|
|
assert_eq!(alice_keys.recv_key(), direct.recv_key());
|
|
}
|
|
|
|
#[test]
|
|
fn ratchet_mismatched_fingerprints_returns_aead_failed() {
|
|
// Fingerprints are bound at init time. If Alice and Bob are initialized
|
|
// with inconsistent fingerprint orderings, the AAD won't match and
|
|
// decryption fails with AeadFailed.
|
|
use soliton::error::Error;
|
|
|
|
let (ek_pk, ek_sk) = xwing::keygen().unwrap();
|
|
let rk = [0x11u8; 32];
|
|
let ck = [0x22u8; 32];
|
|
let fp_a = [0xAAu8; 32];
|
|
let fp_b = [0xBBu8; 32];
|
|
|
|
// Alice: local=fp_a, remote=fp_b (correct)
|
|
let mut alice = RatchetState::init_alice(rk, ck, fp_a, fp_b, ek_pk.clone(), ek_sk).unwrap();
|
|
// Bob initialized with WRONG fingerprint ordering (swapped local/remote)
|
|
let mut bad_bob = RatchetState::init_bob(rk, ck, fp_a, fp_b, ek_pk).unwrap();
|
|
|
|
let enc = alice.encrypt(b"secret").unwrap();
|
|
|
|
// Bad Bob's AAD uses remote_fp=fp_b as sender, local_fp=fp_a as recipient,
|
|
// producing AAD = fp_b || fp_a. Alice used fp_a || fp_b → mismatch.
|
|
let result = bad_bob.decrypt(&enc.header, &enc.ciphertext);
|
|
assert!(
|
|
matches!(result, Err(Error::AeadFailed)),
|
|
"mismatched fingerprint ordering must return AeadFailed, got: {:?}",
|
|
result,
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn reflection_attack_rejected() {
|
|
// Abstract.md Theorem 12: A→B ciphertext reflected back to A as B→A must
|
|
// fail. The fingerprint ordering in AAD differs between encrypt (local_fp
|
|
// || remote_fp) and decrypt (remote_fp || local_fp), so the AEAD tag
|
|
// cannot authenticate. In practice, the reflection may fail earlier (e.g.,
|
|
// InvalidData from KEM decapsulation of a ciphertext encapsulated to the
|
|
// wrong key) — the exact error variant depends on ratchet state. The
|
|
// security property is that decryption never succeeds.
|
|
|
|
let (mut alice, _bob, _fp_a, _fp_b) = do_kex(true);
|
|
let enc = alice.encrypt(b"hello bob").unwrap();
|
|
|
|
// Alice tries to decrypt her own ciphertext (reflection).
|
|
let result = alice.decrypt(&enc.header, &enc.ciphertext);
|
|
assert!(
|
|
result.is_err(),
|
|
"reflected ciphertext must be rejected, got: Ok(...)",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn bob_sends_before_receiving() {
|
|
// Bob comes online and sends before any of Alice's messages arrive.
|
|
// This exercises init_bob → immediate encrypt with ratchet_pending=true
|
|
// and recv_ratchet_pk = pk_EK from KEX (the realistic network-delay scenario).
|
|
let (mut alice, mut bob, _fp_a, _fp_b) = do_kex(true);
|
|
|
|
// Alice sends messages Bob hasn't received yet.
|
|
let enc_a0 = alice.encrypt(b"alice msg 0").unwrap();
|
|
let enc_a1 = alice.encrypt(b"alice msg 1").unwrap();
|
|
|
|
// Bob sends first — triggers KEM ratchet from his pending state.
|
|
let pt = send_b_to_a(&mut alice, &mut bob, b"bob first");
|
|
assert_eq!(pt, b"bob first");
|
|
|
|
// Alice's messages still decrypt: both sides start counters at 1 (post-KEX),
|
|
// so enc_a0 has n=1 matching Bob's recv_count=1 — no skipping needed.
|
|
let pt0 = bob.decrypt(&enc_a0.header, &enc_a0.ciphertext).unwrap();
|
|
assert_eq!(&*pt0, b"alice msg 0");
|
|
let pt1 = bob.decrypt(&enc_a1.header, &enc_a1.ciphertext).unwrap();
|
|
assert_eq!(&*pt1, b"alice msg 1");
|
|
|
|
// Conversation continues normally.
|
|
let pt = send_a_to_b(&mut alice, &mut bob, b"after catch-up");
|
|
assert_eq!(pt, b"after catch-up");
|
|
}
|
|
|
|
#[test]
|
|
fn ratchet_new_epoch_without_kem_ct_returns_invalid_data() {
|
|
use soliton::error::Error;
|
|
use soliton::ratchet::RatchetHeader;
|
|
|
|
// Alice's initial state has recv_ratchet_pk = None. Any incoming ratchet_pk
|
|
// is treated as a new epoch (KEM ratchet step). Without a KEM ciphertext,
|
|
// the ratchet step cannot proceed → InvalidData.
|
|
let (ek_pk, ek_sk) = xwing::keygen().unwrap();
|
|
let rk = [0x11u8; 32];
|
|
let ck = [0x22u8; 32];
|
|
let fp_a = [0xAAu8; 32];
|
|
let fp_b = [0xBBu8; 32];
|
|
let mut alice = RatchetState::init_alice(rk, ck, fp_a, fp_b, ek_pk, ek_sk).unwrap();
|
|
|
|
let dummy_pk = xwing::PublicKey::from_bytes(vec![0u8; 1216]).unwrap();
|
|
let header = RatchetHeader {
|
|
ratchet_pk: dummy_pk,
|
|
kem_ct: None,
|
|
n: 0,
|
|
pn: 1,
|
|
};
|
|
|
|
let result = alice.decrypt(&header, &[]);
|
|
assert!(
|
|
matches!(result, Err(Error::InvalidData)),
|
|
"new-epoch message without kem_ct must return InvalidData, got: {:?}",
|
|
result,
|
|
);
|
|
}
|
|
|
|
// ── Security property tests (RT-540, RT-541, RT-542) ──────────────────
|
|
|
|
#[test]
|
|
fn forward_secrecy_old_epoch_keys_unrecoverable() {
|
|
// RT-540: After a KEM ratchet step, message keys from the old epoch
|
|
// must be unrecoverable. Verify by capturing a ciphertext, advancing
|
|
// the ratchet, then confirming the old ciphertext cannot be replayed
|
|
// against the new state.
|
|
let (mut alice, mut bob, _fp_a, _fp_b) = do_kex(true);
|
|
|
|
// Epoch 0: Alice sends, Bob receives.
|
|
let enc0 = alice.encrypt(b"epoch0 msg").unwrap();
|
|
bob.decrypt(&enc0.header, &enc0.ciphertext).unwrap();
|
|
|
|
// Trigger KEM ratchet: Bob replies, Alice receives → new epoch.
|
|
let enc_b = bob.encrypt(b"reply").unwrap();
|
|
alice.decrypt(&enc_b.header, &enc_b.ciphertext).unwrap();
|
|
|
|
// Another ratchet step: Alice sends again → epoch advances again.
|
|
let enc_a2 = alice.encrypt(b"epoch2 msg").unwrap();
|
|
bob.decrypt(&enc_a2.header, &enc_a2.ciphertext).unwrap();
|
|
|
|
// Bob replies again → second ratchet step. prev_recv_epoch_key from
|
|
// epoch 0 is now gone (only one-epoch grace period).
|
|
let enc_b2 = bob.encrypt(b"reply2").unwrap();
|
|
alice.decrypt(&enc_b2.header, &enc_b2.ciphertext).unwrap();
|
|
|
|
// Serialize Bob to get a snapshot of current state.
|
|
let bob_bytes = bob.to_bytes().unwrap().0;
|
|
let mut bob_replay = RatchetState::from_bytes(&bob_bytes).unwrap();
|
|
|
|
// Attempt to decrypt the epoch-0 message — must fail. The epoch-0
|
|
// epoch key has been overwritten by two subsequent KEM ratchet steps.
|
|
// The replayed message's ratchet_pk matches prev_recv_ratchet_pk
|
|
// (one-epoch grace period), so AEAD succeeds with the retained key.
|
|
// Counter n=0 is already in prev_recv_seen → DuplicateMessage.
|
|
let result = bob_replay.decrypt(&enc0.header, &enc0.ciphertext);
|
|
assert!(
|
|
matches!(result, Err(soliton::error::Error::DuplicateMessage)),
|
|
"old epoch replay must be caught as duplicate"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn cross_session_isolation() {
|
|
// RT-541: Two independent KEX sessions from the same identities must
|
|
// produce completely independent key material.
|
|
let (mut alice1, mut bob1, _fp_a1, _fp_b1) = do_kex(true);
|
|
let (mut alice2, mut bob2, _fp_a2, _fp_b2) = do_kex(true);
|
|
|
|
// Encrypt the same plaintext in both sessions.
|
|
let enc1 = alice1.encrypt(b"same plaintext").unwrap();
|
|
let enc2 = alice2.encrypt(b"same plaintext").unwrap();
|
|
|
|
// Ciphertexts must differ (different keys, different nonces from
|
|
// independent ratchet states).
|
|
assert_ne!(
|
|
enc1.ciphertext, enc2.ciphertext,
|
|
"two sessions must produce different ciphertexts for the same plaintext"
|
|
);
|
|
|
|
// Cross-decrypt must fail: session 1's ciphertext cannot decrypt
|
|
// under session 2's state.
|
|
let result = bob2.decrypt(&enc1.header, &enc1.ciphertext);
|
|
assert!(result.is_err(), "cross-session decryption must fail");
|
|
|
|
// Verify session 1 still works independently.
|
|
let pt1 = bob1.decrypt(&enc1.header, &enc1.ciphertext).unwrap();
|
|
assert_eq!(&*pt1, b"same plaintext");
|
|
}
|
|
|
|
#[test]
|
|
fn break_in_recovery_via_kem_ratchet() {
|
|
// RT-542: After a KEM ratchet step heals a compromised state, the
|
|
// attacker's snapshot becomes useless for future messages.
|
|
let (mut alice, mut bob, _fp_a, _fp_b) = do_kex(true);
|
|
|
|
// Exchange initial messages to establish a working session.
|
|
send_a_to_b(&mut alice, &mut bob, b"setup1");
|
|
send_b_to_a(&mut alice, &mut bob, b"setup2");
|
|
|
|
// Attacker captures Bob's state (simulating key compromise).
|
|
let compromised_bob_bytes = bob.to_bytes().unwrap().0;
|
|
|
|
// Legitimate conversation continues — triggers KEM ratchet steps.
|
|
let mut bob = RatchetState::from_bytes(&compromised_bob_bytes).unwrap();
|
|
send_a_to_b(&mut alice, &mut bob, b"heal1");
|
|
send_b_to_a(&mut alice, &mut bob, b"heal2");
|
|
// One more round to push the compromised epoch out of grace period.
|
|
send_a_to_b(&mut alice, &mut bob, b"heal3");
|
|
send_b_to_a(&mut alice, &mut bob, b"heal4");
|
|
|
|
// New message after healing ratchet steps.
|
|
let enc_post_heal = alice.encrypt(b"secret after healing").unwrap();
|
|
bob.decrypt(&enc_post_heal.header, &enc_post_heal.ciphertext)
|
|
.unwrap();
|
|
|
|
// Attacker's compromised snapshot cannot decrypt the post-heal message.
|
|
let mut attacker_bob = RatchetState::from_bytes(&compromised_bob_bytes).unwrap();
|
|
let result = attacker_bob.decrypt(&enc_post_heal.header, &enc_post_heal.ciphertext);
|
|
assert!(
|
|
result.is_err(),
|
|
"compromised state must not decrypt messages after KEM ratchet healing"
|
|
);
|
|
}
|
|
|
|
// ── Ratchet property tests (RT-546, RT-547, RT-548, RT-557, RT-558) ───
|
|
|
|
#[test]
|
|
fn replay_rejected_across_serialization_boundary() {
|
|
// RT-546: Replay detection must survive serialization round-trip.
|
|
use soliton::error::Error;
|
|
|
|
let (mut alice, mut bob, _fp_a, _fp_b) = do_kex(true);
|
|
let enc = alice.encrypt(b"unique msg").unwrap();
|
|
|
|
// Bob decrypts once.
|
|
bob.decrypt(&enc.header, &enc.ciphertext).unwrap();
|
|
|
|
// Serialize and deserialize Bob.
|
|
let bob_bytes = bob.to_bytes().unwrap().0;
|
|
let mut bob2 = RatchetState::from_bytes(&bob_bytes).unwrap();
|
|
|
|
// Replay the same message — must be rejected as duplicate.
|
|
let result = bob2.decrypt(&enc.header, &enc.ciphertext);
|
|
assert!(
|
|
matches!(result, Err(Error::DuplicateMessage)),
|
|
"replay after serialization must return DuplicateMessage, got: {:?}",
|
|
result,
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn recv_seen_persists_across_serialization() {
|
|
// RT-547: recv_seen set must survive serialization round-trip.
|
|
// Send messages out of order, serialize, then verify the gaps are
|
|
// still tracked and late messages still decrypt.
|
|
let (mut alice, mut bob, _fp_a, _fp_b) = do_kex(true);
|
|
|
|
let enc0 = alice.encrypt(b"msg0").unwrap();
|
|
let enc1 = alice.encrypt(b"msg1").unwrap();
|
|
let enc2 = alice.encrypt(b"msg2").unwrap();
|
|
|
|
// Bob receives 0 and 2, skipping 1.
|
|
bob.decrypt(&enc0.header, &enc0.ciphertext).unwrap();
|
|
bob.decrypt(&enc2.header, &enc2.ciphertext).unwrap();
|
|
|
|
// Serialize/deserialize.
|
|
let bob_bytes = bob.to_bytes().unwrap().0;
|
|
let mut bob2 = RatchetState::from_bytes(&bob_bytes).unwrap();
|
|
|
|
// msg1 (the gap) must still be decryptable.
|
|
let pt1 = bob2.decrypt(&enc1.header, &enc1.ciphertext).unwrap();
|
|
assert_eq!(&*pt1, b"msg1");
|
|
|
|
// msg0 and msg2 must be rejected as duplicates.
|
|
use soliton::error::Error;
|
|
assert!(matches!(
|
|
bob2.decrypt(&enc0.header, &enc0.ciphertext),
|
|
Err(Error::DuplicateMessage)
|
|
));
|
|
assert!(matches!(
|
|
bob2.decrypt(&enc2.header, &enc2.ciphertext),
|
|
Err(Error::DuplicateMessage)
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn prev_recv_epoch_key_grace_period() {
|
|
// RT-548: Messages from the previous epoch decrypt after one KEM
|
|
// ratchet step (grace period) but fail after two (grace period expired).
|
|
let (mut alice, mut bob, _fp_a, _fp_b) = do_kex(true);
|
|
|
|
// Alice sends messages in epoch 0.
|
|
let enc_old = alice.encrypt(b"old epoch msg").unwrap();
|
|
|
|
// Trigger one KEM ratchet step: Bob replies → new epoch.
|
|
let enc_b = bob.encrypt(b"trigger ratchet").unwrap();
|
|
alice.decrypt(&enc_b.header, &enc_b.ciphertext).unwrap();
|
|
|
|
// Bob has NOT received enc_old yet. One ratchet step has occurred.
|
|
// Grace period: old-epoch messages still decrypt.
|
|
let pt = bob.decrypt(&enc_old.header, &enc_old.ciphertext).unwrap();
|
|
assert_eq!(&*pt, b"old epoch msg");
|
|
|
|
// Now trigger a SECOND ratchet step.
|
|
let (mut alice2, mut bob2, _, _) = do_kex(true);
|
|
let enc_old2 = alice2.encrypt(b"old epoch 2").unwrap();
|
|
|
|
// First ratchet step.
|
|
let enc_b2 = bob2.encrypt(b"ratchet1").unwrap();
|
|
alice2.decrypt(&enc_b2.header, &enc_b2.ciphertext).unwrap();
|
|
let enc_a3 = alice2.encrypt(b"ratchet1 reply").unwrap();
|
|
bob2.decrypt(&enc_a3.header, &enc_a3.ciphertext).unwrap();
|
|
|
|
// Second ratchet step.
|
|
let enc_b3 = bob2.encrypt(b"ratchet2").unwrap();
|
|
alice2.decrypt(&enc_b3.header, &enc_b3.ciphertext).unwrap();
|
|
let enc_a4 = alice2.encrypt(b"ratchet2 reply").unwrap();
|
|
bob2.decrypt(&enc_a4.header, &enc_a4.ciphertext).unwrap();
|
|
|
|
// Now try to decrypt the epoch-0 message — grace period expired.
|
|
let result = bob2.decrypt(&enc_old2.header, &enc_old2.ciphertext);
|
|
assert!(
|
|
result.is_err(),
|
|
"old epoch message must fail after two ratchet steps (grace period expired), got: {:?}",
|
|
result,
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn high_counter_encrypt_decrypt_roundtrip() {
|
|
// RT-557: Verify many messages can be sent and received within a single
|
|
// epoch, including out-of-order delivery at various counter values.
|
|
// The exact u32::MAX-1 edge case is tested by the unit test
|
|
// `encrypt_at_send_count_max_minus_one_succeeds` which has direct
|
|
// field access. This integration test verifies the contract end-to-end.
|
|
let (mut alice, mut bob, _fp_a, _fp_b) = do_kex(true);
|
|
|
|
// Send many messages in one direction (same epoch, counter climbs).
|
|
let mut pending = Vec::new();
|
|
for i in 0..50u32 {
|
|
let msg = format!("msg-{i}");
|
|
pending.push((alice.encrypt(msg.as_bytes()).unwrap(), msg));
|
|
}
|
|
|
|
// Deliver out of order: last first, then remaining.
|
|
let (last_enc, last_msg) = pending.pop().unwrap();
|
|
let pt = bob.decrypt(&last_enc.header, &last_enc.ciphertext).unwrap();
|
|
assert_eq!(&*pt, last_msg.as_bytes());
|
|
|
|
// Deliver rest in forward order (counter-mode key derivation for each).
|
|
for (enc, msg) in &pending {
|
|
let pt = bob.decrypt(&enc.header, &enc.ciphertext).unwrap();
|
|
assert_eq!(&*pt, msg.as_bytes());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn multi_epoch_stress_test() {
|
|
// RT-558: Exercise >3 consecutive KEM ratchet steps in both directions.
|
|
let (mut alice, mut bob, _fp_a, _fp_b) = do_kex(true);
|
|
|
|
for epoch in 0..8u32 {
|
|
let msg_ab = format!("a→b epoch {epoch}");
|
|
let pt = send_a_to_b(&mut alice, &mut bob, msg_ab.as_bytes());
|
|
assert_eq!(pt, msg_ab.as_bytes());
|
|
|
|
let msg_ba = format!("b→a epoch {epoch}");
|
|
let pt = send_b_to_a(&mut alice, &mut bob, msg_ba.as_bytes());
|
|
assert_eq!(pt, msg_ba.as_bytes());
|
|
}
|
|
|
|
// Verify serialization still works after many epochs.
|
|
let alice_bytes = alice.to_bytes().unwrap().0;
|
|
let bob_bytes = bob.to_bytes().unwrap().0;
|
|
let mut alice2 = RatchetState::from_bytes(&alice_bytes).unwrap();
|
|
let mut bob2 = RatchetState::from_bytes(&bob_bytes).unwrap();
|
|
|
|
let pt = send_a_to_b(&mut alice2, &mut bob2, b"post-multi-epoch");
|
|
assert_eq!(pt, b"post-multi-epoch");
|
|
}
|
|
|
|
#[test]
|
|
fn epoch_isolation_survives_serialization() {
|
|
// RT-985: Verify that epoch isolation holds across a serialization boundary.
|
|
// Serialize Bob at epoch N, advance to epoch N+2, deserialize the epoch-N
|
|
// snapshot, and verify it cannot decrypt epoch-N+2 messages.
|
|
|
|
let (mut alice, mut bob, _fp_a, _fp_b) = do_kex(true);
|
|
|
|
// Epoch 0: exchange messages, then serialize Bob.
|
|
send_a_to_b(&mut alice, &mut bob, b"epoch0");
|
|
// to_bytes consumes self — save the snapshot, then reload Bob to continue.
|
|
let bob_epoch0_bytes = bob.to_bytes().unwrap().0;
|
|
let mut bob = RatchetState::from_bytes(&bob_epoch0_bytes).unwrap();
|
|
|
|
// Advance to epoch 1: Bob sends → Alice receives → Alice sends → Bob receives.
|
|
let enc_b = bob.encrypt(b"trigger ratchet 1").unwrap();
|
|
alice.decrypt(&enc_b.header, &enc_b.ciphertext).unwrap();
|
|
let enc_a = alice.encrypt(b"epoch1 reply").unwrap();
|
|
bob.decrypt(&enc_a.header, &enc_a.ciphertext).unwrap();
|
|
|
|
// Advance to epoch 2: Bob sends → Alice receives → Alice sends → Bob receives.
|
|
let enc_b2 = bob.encrypt(b"trigger ratchet 2").unwrap();
|
|
alice.decrypt(&enc_b2.header, &enc_b2.ciphertext).unwrap();
|
|
let enc_a2 = alice.encrypt(b"epoch2 msg").unwrap();
|
|
bob.decrypt(&enc_a2.header, &enc_a2.ciphertext).unwrap();
|
|
|
|
// Deserialize the epoch-0 snapshot and try to decrypt the epoch-2 message.
|
|
let mut bob_old = RatchetState::from_bytes(&bob_epoch0_bytes).unwrap();
|
|
let result = bob_old.decrypt(&enc_a2.header, &enc_a2.ciphertext);
|
|
assert!(
|
|
result.is_err(),
|
|
"epoch-0 snapshot must not decrypt epoch-2 message, got: {:?}",
|
|
result,
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn prev_recv_seen_survives_serialization() {
|
|
// RT-990: Verify prev_recv_seen duplicate detection survives serialization.
|
|
// A previous-epoch message that was already decrypted must be rejected as
|
|
// duplicate after serialize/deserialize.
|
|
use soliton::error::Error;
|
|
|
|
let (mut alice, mut bob, _fp_a, _fp_b) = do_kex(true);
|
|
|
|
// Alice sends two messages in epoch 0.
|
|
let enc0 = alice.encrypt(b"prev-epoch msg0").unwrap();
|
|
let enc1 = alice.encrypt(b"prev-epoch msg1").unwrap();
|
|
|
|
// Bob decrypts msg0 in epoch 0 (msg1 held back).
|
|
bob.decrypt(&enc0.header, &enc0.ciphertext).unwrap();
|
|
|
|
// Trigger Bob's receive-side KEM ratchet: Bob sends → Alice receives →
|
|
// Alice sends with new ratchet_pk → Bob receives (triggers KEM ratchet,
|
|
// moving recv_seen → prev_recv_seen).
|
|
let enc_b = bob.encrypt(b"trigger ratchet").unwrap();
|
|
alice.decrypt(&enc_b.header, &enc_b.ciphertext).unwrap();
|
|
let enc_a_new = alice.encrypt(b"new epoch reply").unwrap();
|
|
bob.decrypt(&enc_a_new.header, &enc_a_new.ciphertext)
|
|
.unwrap();
|
|
|
|
// Bob now decrypts msg1 from the previous epoch (grace period).
|
|
// msg1's counter is added to prev_recv_seen.
|
|
bob.decrypt(&enc1.header, &enc1.ciphertext).unwrap();
|
|
|
|
// Serialize/deserialize Bob — prev_recv_seen should contain msg0 and msg1.
|
|
let bob_bytes = bob.to_bytes().unwrap().0;
|
|
let mut bob2 = RatchetState::from_bytes(&bob_bytes).unwrap();
|
|
|
|
// Replay msg0 from the previous epoch — must be rejected as duplicate.
|
|
let result = bob2.decrypt(&enc0.header, &enc0.ciphertext);
|
|
assert!(
|
|
matches!(result, Err(Error::DuplicateMessage)),
|
|
"prev-epoch replay after serialization must return DuplicateMessage, got: {:?}",
|
|
result,
|
|
);
|
|
|
|
// Replay msg1 from the previous epoch — also duplicate.
|
|
let result = bob2.decrypt(&enc1.header, &enc1.ciphertext);
|
|
assert!(
|
|
matches!(result, Err(Error::DuplicateMessage)),
|
|
"prev-epoch replay after serialization must return DuplicateMessage, got: {:?}",
|
|
result,
|
|
);
|
|
}
|