From fc76453f4c44876e88aee73f43710754172fcad2 Mon Sep 17 00:00:00 2001 From: kaichao Date: Thu, 15 Jan 2026 08:47:02 +0800 Subject: [PATCH] Implement double ratchet (#9) --- .gitignore | 2 + Cargo.lock | 451 ++++++++++++++++ Cargo.toml | 7 +- conversations/Cargo.toml | 3 +- double-ratchets/Cargo.toml | 13 + double-ratchets/README.md | 22 + .../examples/double_ratchet_basic.rs | 30 ++ double-ratchets/src/aead.rs | 162 ++++++ double-ratchets/src/errors.rs | 26 + double-ratchets/src/hkdf.rs | 205 ++++++++ double-ratchets/src/keypair.rs | 26 + double-ratchets/src/lib.rs | 9 + double-ratchets/src/state.rs | 491 ++++++++++++++++++ double-ratchets/src/types.rs | 10 + 14 files changed, 1451 insertions(+), 6 deletions(-) create mode 100644 Cargo.lock create mode 100644 double-ratchets/Cargo.toml create mode 100644 double-ratchets/README.md create mode 100644 double-ratchets/examples/double_ratchet_basic.rs create mode 100644 double-ratchets/src/aead.rs create mode 100644 double-ratchets/src/errors.rs create mode 100644 double-ratchets/src/hkdf.rs create mode 100644 double-ratchets/src/keypair.rs create mode 100644 double-ratchets/src/lib.rs create mode 100644 double-ratchets/src/state.rs create mode 100644 double-ratchets/src/types.rs diff --git a/.gitignore b/.gitignore index ad67955..eb5b53f 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,5 @@ target # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ + +*/.DS_Store diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..6168294 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,451 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", + "zeroize", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core", + "typenum", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "double-ratchets" +version = "0.0.1" +dependencies = [ + "blake2", + "chacha20poly1305", + "hkdf", + "rand", + "rand_core", + "thiserror", + "x25519-dalek", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "libc" +version = "0.2.178" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" + +[[package]] +name = "logos-chat" +version = "0.1.0" +dependencies = [ + "thiserror", +] + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core", + "serde", + "zeroize", +] + +[[package]] +name = "zerocopy" +version = "0.8.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd74ec98b9250adb3ca554bdde269adf631549f51d8a8f8f0a10b50f1cb298c3" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a8d209fdf45cf5138cbb5a506f6b52522a25afccc534d1475dad8e31105c6a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/Cargo.toml b/Cargo.toml index ae71fd1..bc37360 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,9 +1,8 @@ [workspace] resolver = "3" + members = [ - "conversations" -, "crypto"] -default-members = [ - "conversations" + "conversations", + "double-ratchets", ] diff --git a/conversations/Cargo.toml b/conversations/Cargo.toml index 531fe1a..83a7e1e 100644 --- a/conversations/Cargo.toml +++ b/conversations/Cargo.toml @@ -4,8 +4,7 @@ version = "0.1.0" edition = "2024" [lib] -crate-type = ["staticlib"] +crate-type = ["staticlib"] [dependencies] thiserror = "2.0.17" - diff --git a/double-ratchets/Cargo.toml b/double-ratchets/Cargo.toml new file mode 100644 index 0000000..8832e51 --- /dev/null +++ b/double-ratchets/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "double-ratchets" +version = "0.0.1" +edition = "2024" + +[dependencies] +x25519-dalek = { version="2.0.1", features=["static_secrets"] } +chacha20poly1305 = "0.10.1" +rand_core = "0.6.4" +rand = "0.8.5" +hkdf = "0.12.4" +thiserror = "2" +blake2 = "0.10.6" diff --git a/double-ratchets/README.md b/double-ratchets/README.md new file mode 100644 index 0000000..e5e4dd9 --- /dev/null +++ b/double-ratchets/README.md @@ -0,0 +1,22 @@ +# Double Ratchet + +This library provides an implementation of the Double Ratchet algorithm. + +## Usage + +```rust +let shared_secret = [42u8; 32]; +let bob_dh = DhKeyPair::generate(); + +let mut alice = RatchetState::init_sender(shared_secret, bob_dh.public); +let mut bob = RatchetState::init_receiver(shared_secret, bob_dh); + +let (ciphertext, header) = alice.encrypt_message(b"Hello Bob!"); +let plaintext = bob.decrypt_message(&ciphertext, header); +``` + +Run examples, + +``` +cargo run --example double_ratchet_basic +``` diff --git a/double-ratchets/examples/double_ratchet_basic.rs b/double-ratchets/examples/double_ratchet_basic.rs new file mode 100644 index 0000000..297cc10 --- /dev/null +++ b/double-ratchets/examples/double_ratchet_basic.rs @@ -0,0 +1,30 @@ +use double_ratchets::{InstallationKeyPair, RatchetState, hkdf::PrivateV1Domain}; + +fn main() { + // === Initial shared secret (X3DH / prekey result in real systems) === + let shared_secret = [42u8; 32]; + + let bob_dh = InstallationKeyPair::generate(); + + let mut alice: RatchetState = + RatchetState::init_sender(shared_secret, bob_dh.public().clone()); + let mut bob: RatchetState = RatchetState::init_receiver(shared_secret, bob_dh); + + let (ciphertext, header) = alice.encrypt_message(b"Hello Bob!"); + + // === Bob receives === + let plaintext = bob.decrypt_message(&ciphertext, header); + println!( + "Bob received: {}", + String::from_utf8_lossy(&plaintext.unwrap()) + ); + + // === Bob replies (triggers DH ratchet) === + let (ciphertext, header) = bob.encrypt_message(b"Hi Alice!"); + + let plaintext = alice.decrypt_message(&ciphertext, header); + println!( + "Alice received: {}", + String::from_utf8_lossy(&plaintext.unwrap()) + ); +} diff --git a/double-ratchets/src/aead.rs b/double-ratchets/src/aead.rs new file mode 100644 index 0000000..e13e6cc --- /dev/null +++ b/double-ratchets/src/aead.rs @@ -0,0 +1,162 @@ +use chacha20poly1305::{ + ChaCha20Poly1305, Key, Nonce as ChaChaNonce, + aead::{Aead, KeyInit}, +}; + +use crate::types::{MessageKey, Nonce}; + +/// Encrypts plaintext with the given key and AAD. +/// +/// # Arguments +/// +/// * `message_key` - The message key. +/// * `plaintext` - The plaintext to encrypt. +/// * `aad` - The additional authenticated data. +/// +/// # Returns +/// +/// A tuple containing the ciphertext and the randomly generated nonce. +pub fn encrypt(message_key: &MessageKey, plaintext: &[u8], aad: &[u8]) -> (Vec, Nonce) { + let cipher = ChaCha20Poly1305::new(Key::from_slice(message_key)); + let nonce = rand::random::(); + let ciphertext = cipher + .encrypt( + ChaChaNonce::from_slice(&nonce), + chacha20poly1305::aead::Payload { + msg: plaintext, + aad, + }, + ) + .expect("encryption failure"); + (ciphertext, nonce) +} + +/// Decrypts ciphertext with the given key, nonce, and AAD. +/// +/// # Arguments +/// +/// * `message_key` - The message key. +/// * `ciphertext` - The ciphertext to decrypt. +/// * `nonce` - The nonce used for encryption. +/// * `aad` - The additional authenticated data. +/// +/// # Returns +/// +/// Ok(plaintext) on success, Err on authentication or decryption failure. +pub fn decrypt( + message_key: &MessageKey, + ciphertext: &[u8], + nonce: &Nonce, + aad: &[u8], +) -> Result, String> { + let cipher = ChaCha20Poly1305::new(Key::from_slice(message_key)); + cipher + .decrypt( + ChaChaNonce::from_slice(nonce), + chacha20poly1305::aead::Payload { + msg: ciphertext, + aad, + }, + ) + .map_err(|_| "Decryption failed: invalid ciphertext, nonce, key, or AAD".to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_encrypt_decrypt_roundtrip_no_aad() { + let message_key = rand::random::<[u8; 32]>(); + let plaintext = b"Hello, this is a test message!"; + let aad = b""; // Empty AAD + + let (ciphertext, nonce) = encrypt(&message_key, plaintext, aad); + + let decrypted = decrypt(&message_key, &ciphertext, &nonce, aad); + + assert!(decrypted.is_ok()); + assert_eq!(decrypted.unwrap(), plaintext); + } + + #[test] + fn test_encrypt_decrypt_roundtrip_with_aad() { + let message_key = rand::random::<[u8; 32]>(); + + let plaintext = b"Secret payload"; + let aad = b"public header data"; + + let (ciphertext, nonce) = encrypt(&message_key, plaintext, aad); + + let decrypted = decrypt(&message_key, &ciphertext, &nonce, aad); + + assert!(decrypted.is_ok()); + assert_eq!(decrypted.unwrap(), plaintext); + } + + #[test] + fn test_decrypt_tampered_ciphertext_fails() { + let message_key = rand::random::<[u8; 32]>(); + + let plaintext = b"Important data"; + let aad = b"metadata"; + + let (mut ciphertext, nonce) = encrypt(&message_key, plaintext, aad); + + // Tamper with the ciphertext + if !ciphertext.is_empty() { + ciphertext[0] ^= 0xFF; + } + + let result = decrypt(&message_key, &ciphertext, &nonce, aad); + assert!(result.is_err()); + } + + #[test] + fn test_decrypt_wrong_aad_fails() { + let message_key = rand::random::<[u8; 32]>(); + + let plaintext = b"Data"; + let correct_aad = b"correct AAD"; + let wrong_aad = b"wrong AAD"; + + let (ciphertext, nonce) = encrypt(&message_key, plaintext, correct_aad); + + let result = decrypt(&message_key, &ciphertext, &nonce, wrong_aad); + + assert!(result.is_err()); + } + + #[test] + fn test_decrypt_wrong_key_fails() { + let correct_key = rand::random::<[u8; 32]>(); + + let mut wrong_key = correct_key; + wrong_key[0] ^= 0xFF; // Flip one bit + + let plaintext = b"Test"; + let aad = b""; + + let (ciphertext, nonce) = encrypt(&correct_key, plaintext, aad); + + let result = decrypt(&wrong_key, &ciphertext, &nonce, aad); + + assert!(result.is_err()); + } + + #[test] + fn test_empty_plaintext() { + let message_key = [0u8; 32]; + let plaintext = b""; + let aad = b"some aad"; + + let (ciphertext, nonce) = encrypt(&message_key, plaintext, aad); + // Ciphertext should be exactly 16 bytes (the Poly1305 tag) for empty message + assert_eq!(ciphertext.len(), 16); + + let decrypted = decrypt(&message_key, &ciphertext, &nonce, aad); + + assert!(decrypted.is_ok()); + assert_eq!(decrypted.unwrap(), plaintext); + } +} diff --git a/double-ratchets/src/errors.rs b/double-ratchets/src/errors.rs new file mode 100644 index 0000000..c0e15c7 --- /dev/null +++ b/double-ratchets/src/errors.rs @@ -0,0 +1,26 @@ +use thiserror::Error; + +/// Errors produced by the Double Ratchet protocol +#[derive(Debug, Error, Clone, PartialEq, Eq)] +pub enum RatchetError { + #[error("ciphertext too short")] + CiphertextTooShort, + + #[error("invalid nonce")] + InvalidNonce, + + #[error("decryption failed")] + DecryptionFailed, + + #[error("message replay detected")] + MessageReplay, + + #[error("too many skipped messages")] + TooManySkippedMessages, + + #[error("missing remote DH key")] + MissingRemoteDhKey, + + #[error("missing receiving chain")] + MissingReceivingChain, +} diff --git a/double-ratchets/src/hkdf.rs b/double-ratchets/src/hkdf.rs new file mode 100644 index 0000000..82fbfbf --- /dev/null +++ b/double-ratchets/src/hkdf.rs @@ -0,0 +1,205 @@ +use blake2::{ + Blake2b512, Blake2bMac, + digest::{FixedOutput, consts::U32}, +}; +use hkdf::SimpleHkdf; + +use crate::types::{ChainKey, MessageKey, RootKey, SharedSecret}; + +type Blake2bMac256 = Blake2bMac; + +/// Application-level domain separation for root key derivation using HKDF. +/// This separates different applications/protocols using the same primitives. +pub trait HkdfInfo { + const ROOT_KEY: &'static [u8]; +} + +/// Default implementation for standalone Double Ratchet +#[derive(Clone, Copy)] +pub struct DefaultDomain; + +impl HkdfInfo for DefaultDomain { + const ROOT_KEY: &'static [u8] = b"DoubleRatchetRootKey"; +} + +/// Domain for PrivateV1 protocol +#[derive(Clone, Copy)] +pub struct PrivateV1Domain; + +impl HkdfInfo for PrivateV1Domain { + const ROOT_KEY: &'static [u8] = b"PrivateV1RootKey"; +} + +/// Spec-level domain separation constants for Double Ratchet chain KDF. +/// These are fixed by the Double Ratchet specification and use BLAKE2's +/// personalization parameter for domain separation. +mod chain_kdf { + /// Personalization string for deriving message keys + pub const MESSAGE_KEY_PERSONAL: &[u8] = b"mk"; + /// Personalization string for deriving chain keys + pub const CHAIN_KEY_PERSONAL: &[u8] = b"ck"; +} + +/// Derive a new root key and chain key from the given root key and Diffie-Hellman shared secret. +/// +/// # Arguments +/// +/// * `root` - The current root key. +/// * `dh` - The Diffie-Hellman shared secret. +/// +/// # Returns +/// +/// A tuple containing the new root key and chain key. +pub fn kdf_root(root: &RootKey, dh: &SharedSecret) -> (RootKey, ChainKey) { + let hk = SimpleHkdf::::new(Some(root), dh); + + let mut okm = [0u8; 64]; + hk.expand(D::ROOT_KEY, &mut okm).unwrap(); + + let new_root = okm[..32].try_into().unwrap(); + let chain = okm[32..].try_into().unwrap(); + (new_root, chain) +} + +/// Derive a new chain key and message key from the given chain key. +/// +/// # Arguments +/// +/// * `chain` - The current chain key. +/// +/// # Returns +/// +/// A tuple containing the new chain key and message key. +pub fn kdf_chain(chain: &ChainKey) -> (ChainKey, MessageKey) { + // Derive message key + let msg_key_mac = Blake2bMac256::new_with_salt_and_personal( + chain, + &[], // No salt - input already has high entropy + chain_kdf::MESSAGE_KEY_PERSONAL, + ) + .unwrap(); + let msg_key: MessageKey = msg_key_mac.finalize_fixed().into(); + + // Derive next chain key + let chain_key_mac = Blake2bMac256::new_with_salt_and_personal( + chain, + &[], // No salt - input already has high entropy + chain_kdf::CHAIN_KEY_PERSONAL, + ) + .unwrap(); + let next_chain: ChainKey = chain_key_mac.finalize_fixed().into(); + + (next_chain, msg_key) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_kdf_root_deterministic_output() { + // Fixed inputs for reproducible testing + let root = [0x11; 32]; + let dh = [0x22; 32]; + + let (new_root, chain) = kdf_root::(&root, &dh); + + // These values can be verified manually or against a reference implementation + // (e.g., Signal's spec or another HKDF test vector) + let expected_new_root = [ + 252, 149, 120, 209, 39, 209, 254, 187, 230, 101, 10, 72, 153, 242, 102, 43, 14, 175, + 152, 122, 188, 117, 116, 153, 169, 244, 84, 239, 172, 228, 75, 158, + ]; + let expected_chain = [ + 179, 178, 244, 176, 145, 144, 55, 144, 149, 119, 47, 208, 154, 230, 78, 67, 42, 200, + 218, 89, 199, 216, 138, 37, 93, 161, 78, 206, 85, 120, 52, 212, + ]; + + assert_eq!(new_root, expected_new_root); + assert_eq!(chain, expected_chain); + + // Run again to ensure determinism + let (new_root2, chain2) = kdf_root::(&root, &dh); + assert_eq!(new_root, new_root2); + assert_eq!(chain, chain2); + } + + #[test] + fn test_kdf_chain_sequence() { + let initial_chain = [0xaa; 32]; + + let (msg_key1, chain2) = kdf_chain(&initial_chain); + let (msg_key2, chain3) = kdf_chain(&chain2); + let (msg_key3, chain4) = kdf_chain(&chain3); + + // All message keys should be different + assert_ne!(msg_key1, msg_key2); + assert_ne!(msg_key2, msg_key3); + assert_ne!(msg_key1, msg_key3); + + // Chain keys should evolve + assert_ne!(initial_chain, chain2); + assert_ne!(chain2, chain3); + assert_ne!(chain3, chain4); + } + + #[test] + fn test_kdf_chain_deterministic() { + let chain = [0xff; 32]; + + let (next_chain, msg_key) = kdf_chain(&chain); + + let expected_msg_key = [ + 218, 132, 123, 191, 200, 122, 53, 45, 0, 113, 160, 14, 116, 47, 124, 193, 218, 213, 86, + 3, 71, 95, 150, 77, 148, 244, 21, 36, 218, 51, 69, 118, + ]; + let expected_next_chain = [ + 150, 245, 67, 74, 243, 9, 1, 244, 133, 19, 37, 213, 11, 72, 130, 183, 155, 1, 154, 52, + 56, 108, 193, 167, 33, 208, 190, 16, 172, 250, 168, 71, + ]; + + assert_eq!(msg_key, expected_msg_key); + assert_eq!(next_chain, expected_next_chain); + } + + #[test] + fn test_full_ratchet_step() { + // Simulate one full root update + chain step + let root = [0x01; 32]; + let dh_out = [0x02; 32]; + + let (new_root, sending_chain) = kdf_root::(&root, &dh_out); + + let (msg_key, next_chain) = kdf_chain(&sending_chain); + + // All outputs should be cryptographically distinct and non-zero + assert_ne!(new_root, root); + assert_ne!(sending_chain, [0u8; 32]); + assert_ne!(msg_key, [0u8; 32]); + assert_ne!(next_chain, sending_chain); + + // Message key should not leak chain key info + assert_ne!(msg_key, sending_chain); + assert_ne!(msg_key, next_chain); + } + + #[test] + fn test_different_inputs_produce_different_outputs() { + let root1 = [0x11; 32]; + let root2 = [0x11; 32]; + let mut root2_modified = root2; + root2_modified[0] ^= 0x01; + + let dh1 = [0x22; 32]; + let dh2 = [0x22; 32]; + let mut dh2_modified = dh2; + dh2_modified[31] ^= 0x80; + + let (out1, _) = kdf_root::(&root1, &dh1); + let (out2, _) = kdf_root::(&root2_modified, &dh1); + let (out3, _) = kdf_root::(&root1, &dh2_modified); + + assert_ne!(out1, out2); // Changing root changes output + assert_ne!(out1, out3); // Changing DH changes output + } +} diff --git a/double-ratchets/src/keypair.rs b/double-ratchets/src/keypair.rs new file mode 100644 index 0000000..26463a4 --- /dev/null +++ b/double-ratchets/src/keypair.rs @@ -0,0 +1,26 @@ +use rand_core::OsRng; +use x25519_dalek::{PublicKey, StaticSecret}; + +use crate::types::SharedSecret; + +#[derive(Clone)] +pub struct InstallationKeyPair { + secret: StaticSecret, + public: PublicKey, +} + +impl InstallationKeyPair { + pub fn generate() -> Self { + let secret = StaticSecret::random_from_rng(OsRng); + let public = PublicKey::from(&secret); + Self { secret, public } + } + + pub fn dh(&self, their_public: &PublicKey) -> SharedSecret { + self.secret.diffie_hellman(their_public).to_bytes() + } + + pub fn public(&self) -> &PublicKey { + &self.public + } +} diff --git a/double-ratchets/src/lib.rs b/double-ratchets/src/lib.rs new file mode 100644 index 0000000..8f51567 --- /dev/null +++ b/double-ratchets/src/lib.rs @@ -0,0 +1,9 @@ +pub mod aead; +pub mod errors; +pub mod hkdf; +pub mod keypair; +pub mod state; +pub mod types; + +pub use keypair::InstallationKeyPair; +pub use state::{Header, RatchetState}; diff --git a/double-ratchets/src/state.rs b/double-ratchets/src/state.rs new file mode 100644 index 0000000..b34ef46 --- /dev/null +++ b/double-ratchets/src/state.rs @@ -0,0 +1,491 @@ +use std::{collections::HashMap, marker::PhantomData}; + +use x25519_dalek::PublicKey; + +use crate::{ + aead::{decrypt, encrypt}, + errors::RatchetError, + hkdf::{DefaultDomain, HkdfInfo, kdf_chain, kdf_root}, + keypair::InstallationKeyPair, + types::{ChainKey, MessageKey, Nonce, RootKey, SharedSecret}, +}; + +/// Represents the local state of the Double Ratchet algorithm for one conversation. +/// +/// This struct maintains all keys and counters required to perform the Double Ratchet +/// as specified in the Signal protocol, providing end-to-end encryption with forward +/// secrecy and post-compromise security. +#[derive(Clone)] +pub struct RatchetState { + pub root_key: RootKey, + + pub sending_chain: Option, + pub receiving_chain: Option, + + pub dh_self: InstallationKeyPair, + pub dh_remote: Option, + + pub msg_send: u32, + pub msg_recv: u32, + pub prev_chain_len: u32, + + pub skipped_keys: HashMap<(PublicKey, u32), MessageKey>, + + _domain: PhantomData, +} + +/// Public header attached to every encrypted message (unencrypted but authenticated). +#[derive(Clone)] +pub struct Header { + pub dh_pub: PublicKey, + pub msg_num: u32, + pub prev_chain_len: u32, +} + +impl Header { + /// Serializes the full header for use as Associated Authenticated Data (AAD). + /// Format: DH public key (32 bytes) || message number (4 bytes, big-endian) || previous chain length (4 bytes, big-endian) + /// + /// # Returns + /// + /// A 40-byte slice containing the serialized header. + pub fn serialized(&self) -> [u8; 40] { + let mut aad = [0u8; 40]; + aad[0..32].copy_from_slice(self.dh_pub.as_bytes()); + aad[32..36].copy_from_slice(&self.msg_num.to_be_bytes()); + aad[36..40].copy_from_slice(&self.prev_chain_len.to_be_bytes()); + aad + } +} + +impl RatchetState { + /// Initializes the party that sends the first message. + /// + /// Performs the initial Diffie-Hellman computation with the remote public key + /// and derives the initial root and sending chain keys. + /// + /// # Arguments + /// + /// * `shared_secret` - Pre-shared secret (e.g., from X3DH). + /// * `remote_pub` - Remote party's public key for the initial DH. + /// + /// # Returns + /// + /// A new `RatchetState` ready to send the first message. + pub fn init_sender(shared_secret: SharedSecret, remote_pub: PublicKey) -> Self { + let dh_self = InstallationKeyPair::generate(); + + // Initial DH + let dh_out = dh_self.dh(&remote_pub); + let (root_key, sending_chain) = kdf_root::(&shared_secret, &dh_out); + + Self { + root_key, + + sending_chain: Some(sending_chain), + receiving_chain: None, + + dh_self, + dh_remote: Some(remote_pub), + + msg_send: 0, + msg_recv: 0, + prev_chain_len: 0, + + skipped_keys: HashMap::new(), + + _domain: PhantomData, + } + } + + /// Initializes the party that receives the first message. + /// + /// No chain keys are derived yet — they will be created upon receiving the first message. + /// + /// # Arguments + /// + /// * `shared_secret` - Pre-shared secret (e.g., from X3DH). + /// * `dh_self` - Our long-term or initial DH key pair. + /// + /// # Returns + /// + /// A new `RatchetState` ready to receive the first message. + pub fn init_receiver(shared_secret: SharedSecret, dh_self: InstallationKeyPair) -> Self { + Self { + root_key: shared_secret, + + sending_chain: None, + receiving_chain: None, // derived on first receive + + dh_self, + dh_remote: None, + + msg_send: 0, + msg_recv: 0, + prev_chain_len: 0, + + skipped_keys: HashMap::new(), + + _domain: PhantomData, + } + } + + /// Performs a receiving-side DH ratchet when a new remote DH public key is observed. + /// + /// # Arguments + /// + /// * `remote_pub` - The new DH public key from the sender. + pub fn dh_ratchet_receive(&mut self, remote_pub: PublicKey) { + let dh_out = self.dh_self.dh(&remote_pub); + let (new_root, recv_chain) = kdf_root::(&self.root_key, &dh_out); + + self.root_key = new_root; + self.receiving_chain = Some(recv_chain); + self.sending_chain = None; // 🔥 important + self.dh_remote = Some(remote_pub); + self.msg_recv = 0; + } + + /// Performs a sending-side DH ratchet (generates new key pair and advances root key). + /// Called automatically when sending but no active sending chain exists. + pub fn dh_ratchet_send(&mut self) { + let remote = self.dh_remote.expect("no remote DH key"); + + self.dh_self = InstallationKeyPair::generate(); + let dh_out = self.dh_self.dh(&remote); + let (new_root, send_chain) = kdf_root::(&self.root_key, &dh_out); + + self.root_key = new_root; + self.sending_chain = Some(send_chain); + } + + /// Encrypts a plaintext message. + /// + /// Automatically performs a DH ratchet if the sending direction has changed. + /// + /// # Arguments + /// + /// * `plaintext` - The message to encrypt. + /// + /// # Returns + /// + /// A tuple containing: + /// * The ciphertext prefixed with the nonce. + /// * The `Header` that must be sent alongside the ciphertext. + pub fn encrypt_message(&mut self, plaintext: &[u8]) -> (Vec, Header) { + if self.sending_chain.is_none() { + self.dh_ratchet_send(); + self.prev_chain_len = self.msg_send; + self.msg_send = 0; + } + + let chain = self.sending_chain.as_mut().unwrap(); + let (next_chain, message_key) = kdf_chain(chain); + *chain = next_chain; + + let header = Header { + dh_pub: self.dh_self.public().clone(), + msg_num: self.msg_send, + prev_chain_len: self.prev_chain_len, + }; + + self.msg_send += 1; + + let (ciphertext, nonce) = encrypt(&message_key, plaintext, &header.serialized()); + + let mut ciphertext_with_nonce = Vec::with_capacity(nonce.len() + ciphertext.len()); + ciphertext_with_nonce.extend_from_slice(&nonce); + ciphertext_with_nonce.extend_from_slice(&ciphertext); + + (ciphertext_with_nonce, header) + } + + /// Decrypts a received message. + /// + /// Handles DH ratcheting, skipped messages, and replay protection. + /// + /// # Arguments + /// + /// * `ciphertext_with_nonce` - Ciphertext prefixed with 12-byte nonce. + /// * `header` - The header received with the message. + /// + /// # Returns + /// + /// * `Ok(plaintext)` on success. + /// * `Err(String)` on failure (e.g., authentication error, replay, too many skipped). + pub fn decrypt_message( + &mut self, + ciphertext_with_nonce: &[u8], + header: Header, + ) -> Result, RatchetError> { + if ciphertext_with_nonce.len() < 12 { + return Err(RatchetError::CiphertextTooShort); + } + let (nonce_slice, ciphertext) = ciphertext_with_nonce.split_at(12); + let nonce: &Nonce = nonce_slice + .try_into() + .map_err(|_| RatchetError::InvalidNonce)?; + + let key_id = (header.dh_pub, header.msg_num); + if let Some(msg_key) = self.skipped_keys.remove(&key_id) { + return decrypt(&msg_key, ciphertext, nonce, &header.serialized()) + .map_err(|_| RatchetError::DecryptionFailed); + } + + if self.dh_remote.as_ref() == Some(&header.dh_pub) && header.msg_num < self.msg_recv { + return Err(RatchetError::MessageReplay); + } + + if self.dh_remote.as_ref() != Some(&header.dh_pub) { + self.skip_message_keys(header.prev_chain_len)?; + self.dh_ratchet_receive(header.dh_pub); + self.prev_chain_len = header.msg_num; // Important: update prev_chain_len after ratchet + } + + self.skip_message_keys(header.msg_num)?; + + let chain = self + .receiving_chain + .as_mut() + .ok_or(RatchetError::MissingReceivingChain)?; + let (next_chain, message_key) = kdf_chain(chain); + + *chain = next_chain; + self.msg_recv += 1; + + decrypt(&message_key, ciphertext, nonce, &header.serialized()) + .map_err(|_| RatchetError::DecryptionFailed) + } + + /// Advances the receiving chain and stores skipped message keys. + /// + /// # Arguments + /// + /// * `until` - The message number to skip up to (exclusive). + /// + /// # Returns + /// + /// * `Ok(())` on success. + /// * `Err(&'static str)` if too many messages would be skipped (DoS protection). + pub fn skip_message_keys(&mut self, until: u32) -> Result<(), RatchetError> { + const MAX_SKIP: u32 = 10; + + if self.msg_recv + MAX_SKIP < until { + return Err(RatchetError::TooManySkippedMessages); + } + + while self.msg_recv < until { + let chain = self + .receiving_chain + .as_mut() + .ok_or(RatchetError::MissingReceivingChain)?; + let (next_chain, msg_key) = kdf_chain(chain); + *chain = next_chain; + + let remote = self.dh_remote.ok_or(RatchetError::MissingRemoteDhKey)?; + let key_id = (remote, self.msg_recv); + self.skipped_keys.insert(key_id, msg_key); + self.msg_recv += 1; + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn setup_alice_bob() -> (RatchetState, RatchetState, SharedSecret) { + // Simulate pre-shared secret (e.g., from X3DH) + let shared_secret = [0x42; 32]; + + // Bob generates his long-term keypair + let bob_keypair = InstallationKeyPair::generate(); + + // Alice initializes as sender, knowing Bob's public key + let alice = RatchetState::init_sender(shared_secret, bob_keypair.public().clone()); + + // Bob initializes as receiver with his private key + let bob = RatchetState::init_receiver(shared_secret, bob_keypair); + + (alice, bob, shared_secret) + } + + #[test] + fn test_basic_roundtrip_one_message() { + let (mut alice, mut bob, _) = setup_alice_bob(); + + let plaintext = b"Hello Bob, this is Alice!"; + + let (ciphertext_with_nonce, header) = alice.encrypt_message(plaintext); + + let decrypted = bob.decrypt_message(&ciphertext_with_nonce, header).unwrap(); + + assert_eq!(decrypted, plaintext); + assert_eq!(alice.msg_send, 1); + assert_eq!(bob.msg_recv, 1); + } + + #[test] + fn test_multiple_messages_in_order() { + let (mut alice, mut bob, _) = setup_alice_bob(); + + let messages = [b"Message 1", b"Message 2", b"message 3"]; + + for msg in messages { + let (ct, header) = alice.encrypt_message(msg); + let pt = bob.decrypt_message(&ct, header).unwrap(); + assert_eq!(pt, msg); + } + + assert_eq!(alice.msg_send, 3); + assert_eq!(bob.msg_recv, 3); + } + + #[test] + fn test_out_of_order_messages_with_skipped_keys() { + let (mut alice, mut bob, _) = setup_alice_bob(); + + // Alice sends 3 messages + let mut sent = vec![]; + for i in 0..3 { + let plaintext = format!("Message {}", i + 1).into_bytes(); + let (ct, header) = alice.encrypt_message(&plaintext); + sent.push((ct, header, plaintext)); + } + + // Bob receives them out of order: 0, 2, 1 + let decrypted0 = bob.decrypt_message(&sent[0].0, sent[0].1.clone()).unwrap(); + assert_eq!(decrypted0, sent[0].2); + + let decrypted2 = bob.decrypt_message(&sent[2].0, sent[2].1.clone()).unwrap(); + assert_eq!(decrypted2, sent[2].2); + + let decrypted1 = bob.decrypt_message(&sent[1].0, sent[1].1.clone()).unwrap(); + assert_eq!(decrypted1, sent[1].2); + + assert_eq!(bob.msg_recv, 3); + } + + #[test] + fn test_sender_ratchets_after_receiving_from_other_side() { + let (mut alice, mut bob, _) = setup_alice_bob(); + + // Alice sends one message + let (ct, header) = alice.encrypt_message(b"first"); + bob.decrypt_message(&ct, header).unwrap(); + + // Bob performs DH ratchet by trying to send + let old_bob_pub = bob.dh_self.public().clone(); + let (bob_ct, bob_header) = { + let mut b = bob.clone(); + b.encrypt_message(b"reply") + }; + assert_ne!(bob_header.dh_pub, old_bob_pub); + + // Alice receives Bob's message with new DH pub → ratchets + let old_alice_pub = alice.dh_self.public().clone(); + let old_root = alice.root_key; + + // Even if decrypt fails (wrong key), ratchet should happen + alice.decrypt_message(&bob_ct, bob_header).unwrap(); + + // Now Alice sends → should do DH ratchet + let (_, final_header) = alice.encrypt_message(b"after both ratcheted"); + + assert_ne!(final_header.dh_pub, old_alice_pub); + assert_ne!(alice.root_key, old_root); + } + + #[test] + fn test_max_skip_limit_enforced() { + let (mut alice, mut bob, _) = setup_alice_bob(); + + // Alice sends message 0 + let (_, _) = alice.encrypt_message(b"First"); + + // Now Alice skips many messages (simulate lost packets) + for _ in 0..15 { + alice.encrypt_message(b"lost"); + } + + // Alice sends final message + let (ct_final, header_final) = alice.encrypt_message(b"Final"); + + // Bob tries to decrypt final — should fail because too many skipped + let result = bob.decrypt_message(&ct_final, header_final); + assert!(result.is_err()); + assert_eq!(result.unwrap_err(), RatchetError::TooManySkippedMessages); + } + + #[test] + fn test_aad_authenticates_header() { + let (mut alice, mut bob, _) = setup_alice_bob(); + + let (ct, mut header) = alice.encrypt_message(b"Sensitive data"); + + // Tamper with header (change DH pub byte) + let mut tampered_pub_bytes = header.dh_pub.to_bytes(); + tampered_pub_bytes[0] ^= 0xff; + header.dh_pub = PublicKey::from(tampered_pub_bytes); + + let result = bob.decrypt_message(&ct, header); + assert!(result.is_err()); + assert_eq!(result.unwrap_err(), RatchetError::DecryptionFailed); + } + + #[test] + fn test_full_asymmetric_ratchet_conversation() { + let (mut alice, mut bob, _) = setup_alice_bob(); + + // Alice sends first few + for i in 0..3 { + let msg = format!("A -> B {}", i).into_bytes(); + let (ct, h) = alice.encrypt_message(&msg); + let pt = bob.decrypt_message(&ct, h).unwrap(); + assert_eq!(pt, msg); + } + + // Bob now responds — this should trigger his first DH ratchet + let (ct_b, h_b) = bob.encrypt_message(b"B -> A response"); + + // Alice receives Bob's message + let pt_a = alice.decrypt_message(&ct_b, h_b).unwrap(); + assert_eq!(pt_a, b"B -> A response"); + + // Both should now have performed a DH ratchet + assert!(alice.receiving_chain.is_some()); + assert!(bob.sending_chain.is_some()); + } + + #[test] + fn test_skipped_keys_are_one_time_use() { + let (mut alice, mut bob, _) = setup_alice_bob(); + + let msgs = vec![b"msg0", b"msg1", b"msg2", b"msg3"]; + + let mut encrypted = vec![]; + for msg in msgs { + let (ct, h) = alice.encrypt_message(msg); + encrypted.push((ct, h)); + } + + // Receive msg0 and msg2 → msg1 goes to skipped + bob.decrypt_message(&encrypted[0].0, encrypted[0].1.clone()) + .unwrap(); + bob.decrypt_message(&encrypted[2].0, encrypted[2].1.clone()) + .unwrap(); + + // Now receive msg1 — should use skipped key and remove it + let pt1 = bob + .decrypt_message(&encrypted[1].0, encrypted[1].1.clone()) + .unwrap(); + assert_eq!(pt1, b"msg1"); + + // Try to decrypt msg1 again → should fail (key was removed) + let result = bob.decrypt_message(&encrypted[1].0, encrypted[1].1.clone()); + assert!(result.is_err()); + assert_eq!(result.unwrap_err(), RatchetError::MessageReplay); + } +} diff --git a/double-ratchets/src/types.rs b/double-ratchets/src/types.rs new file mode 100644 index 0000000..bbad3a8 --- /dev/null +++ b/double-ratchets/src/types.rs @@ -0,0 +1,10 @@ +/// Type alias for root keys (32 bytes). +pub type RootKey = [u8; 32]; +/// Type alias for chain keys (sending/receiving, 32 bytes). +pub type ChainKey = [u8; 32]; +/// Type alias for message keys (32 bytes). +pub type MessageKey = [u8; 32]; +/// Type alias for shared secrets/DH outputs (32 bytes). +pub type SharedSecret = [u8; 32]; +/// Type alias for a 12-byte AEAD nonce. +pub type Nonce = [u8; 12];