mirror of
https://github.com/logos-messaging/libchat.git
synced 2026-02-09 16:33:10 +00:00
Implement double ratchet (#9)
This commit is contained in:
parent
04d6f8a84b
commit
fc76453f4c
2
.gitignore
vendored
2
.gitignore
vendored
@ -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
|
||||
|
||||
451
Cargo.lock
generated
Normal file
451
Cargo.lock
generated
Normal file
@ -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",
|
||||
]
|
||||
@ -1,9 +1,8 @@
|
||||
|
||||
[workspace]
|
||||
resolver = "3"
|
||||
|
||||
members = [
|
||||
"conversations"
|
||||
, "crypto"]
|
||||
default-members = [
|
||||
"conversations"
|
||||
"conversations",
|
||||
"double-ratchets",
|
||||
]
|
||||
|
||||
@ -4,8 +4,7 @@ version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
crate-type = ["staticlib"]
|
||||
crate-type = ["staticlib"]
|
||||
|
||||
[dependencies]
|
||||
thiserror = "2.0.17"
|
||||
|
||||
|
||||
13
double-ratchets/Cargo.toml
Normal file
13
double-ratchets/Cargo.toml
Normal file
@ -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"
|
||||
22
double-ratchets/README.md
Normal file
22
double-ratchets/README.md
Normal file
@ -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
|
||||
```
|
||||
30
double-ratchets/examples/double_ratchet_basic.rs
Normal file
30
double-ratchets/examples/double_ratchet_basic.rs
Normal file
@ -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<PrivateV1Domain> =
|
||||
RatchetState::init_sender(shared_secret, bob_dh.public().clone());
|
||||
let mut bob: RatchetState<PrivateV1Domain> = 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())
|
||||
);
|
||||
}
|
||||
162
double-ratchets/src/aead.rs
Normal file
162
double-ratchets/src/aead.rs
Normal file
@ -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<u8>, Nonce) {
|
||||
let cipher = ChaCha20Poly1305::new(Key::from_slice(message_key));
|
||||
let nonce = rand::random::<Nonce>();
|
||||
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<Vec<u8>, 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);
|
||||
}
|
||||
}
|
||||
26
double-ratchets/src/errors.rs
Normal file
26
double-ratchets/src/errors.rs
Normal file
@ -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,
|
||||
}
|
||||
205
double-ratchets/src/hkdf.rs
Normal file
205
double-ratchets/src/hkdf.rs
Normal file
@ -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<U32>;
|
||||
|
||||
/// 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<D: HkdfInfo>(root: &RootKey, dh: &SharedSecret) -> (RootKey, ChainKey) {
|
||||
let hk = SimpleHkdf::<Blake2b512>::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::<DefaultDomain>(&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::<DefaultDomain>(&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::<DefaultDomain>(&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::<DefaultDomain>(&root1, &dh1);
|
||||
let (out2, _) = kdf_root::<DefaultDomain>(&root2_modified, &dh1);
|
||||
let (out3, _) = kdf_root::<DefaultDomain>(&root1, &dh2_modified);
|
||||
|
||||
assert_ne!(out1, out2); // Changing root changes output
|
||||
assert_ne!(out1, out3); // Changing DH changes output
|
||||
}
|
||||
}
|
||||
26
double-ratchets/src/keypair.rs
Normal file
26
double-ratchets/src/keypair.rs
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
9
double-ratchets/src/lib.rs
Normal file
9
double-ratchets/src/lib.rs
Normal file
@ -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};
|
||||
491
double-ratchets/src/state.rs
Normal file
491
double-ratchets/src/state.rs
Normal file
@ -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<D: HkdfInfo = DefaultDomain> {
|
||||
pub root_key: RootKey,
|
||||
|
||||
pub sending_chain: Option<ChainKey>,
|
||||
pub receiving_chain: Option<ChainKey>,
|
||||
|
||||
pub dh_self: InstallationKeyPair,
|
||||
pub dh_remote: Option<PublicKey>,
|
||||
|
||||
pub msg_send: u32,
|
||||
pub msg_recv: u32,
|
||||
pub prev_chain_len: u32,
|
||||
|
||||
pub skipped_keys: HashMap<(PublicKey, u32), MessageKey>,
|
||||
|
||||
_domain: PhantomData<D>,
|
||||
}
|
||||
|
||||
/// 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<D: HkdfInfo> RatchetState<D> {
|
||||
/// 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::<D>(&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::<D>(&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::<D>(&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<u8>, 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<Vec<u8>, 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);
|
||||
}
|
||||
}
|
||||
10
double-ratchets/src/types.rs
Normal file
10
double-ratchets/src/types.rs
Normal file
@ -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];
|
||||
Loading…
x
Reference in New Issue
Block a user