From 65b12ed88445674a77fdb1dea932256de60067f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D1=80=D1=82=D1=91=D0=BC=20=D0=9F=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=BE=D0=B2=20=5BArtyom=20Pavlov=5D?= Date: Sat, 7 Feb 2026 16:30:38 +0300 Subject: [PATCH 1/4] chacha20: add inherent methods for RNG state (de)serialization --- chacha20/src/lib.rs | 2 +- chacha20/src/rng.rs | 47 ++++++++++++++++++++++++ chacha20/tests/rng.rs | 84 ++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 131 insertions(+), 2 deletions(-) diff --git a/chacha20/src/lib.rs b/chacha20/src/lib.rs index 5a4ff310..a086a01b 100644 --- a/chacha20/src/lib.rs +++ b/chacha20/src/lib.rs @@ -113,7 +113,7 @@ pub use legacy::{ChaCha20Legacy, LegacyNonce}; #[cfg(feature = "rng")] pub use rand_core; #[cfg(feature = "rng")] -pub use rng::{ChaCha8Rng, ChaCha12Rng, ChaCha20Rng, Seed}; +pub use rng::{ChaCha8Rng, ChaCha12Rng, ChaCha20Rng, Seed, SerializedRngState}; #[cfg(feature = "xchacha")] pub use xchacha::{XChaCha8, XChaCha12, XChaCha20, XNonce, hchacha}; diff --git a/chacha20/src/rng.rs b/chacha20/src/rng.rs index 2aa6a2a1..f4cbba88 100644 --- a/chacha20/src/rng.rs +++ b/chacha20/src/rng.rs @@ -21,6 +21,9 @@ use cfg_if::cfg_if; /// Seed value used to initialize ChaCha-based RNGs. pub type Seed = [u8; 32]; +/// Serialized RNG state. +pub type SerializedRngState = [u8; 49]; + /// Number of 32-bit words per ChaCha block (fixed by algorithm definition). pub(crate) const BLOCK_WORDS: u8 = 16; @@ -287,6 +290,50 @@ macro_rules! impl_chacha_rng { } result } + + /// Serialize RNG state. + #[inline] + pub fn serialize_state(&self) -> SerializedRngState { + let seed = self.get_seed(); + let stream = self.get_stream().to_le_bytes(); + let word_pos = self.get_word_pos().to_le_bytes(); + + let mut res = [0u8; 49]; + let (seed_dst, res_rem) = res.split_at_mut(32); + let (stream_dst, word_pos_dst) = res_rem.split_at_mut(8); + + seed_dst.copy_from_slice(&seed); + stream_dst.copy_from_slice(&stream); + word_pos_dst.copy_from_slice(&word_pos[..9]); + + debug_assert_eq!(&word_pos[9..], &[0u8; 7]); + + res + } + + /// Deserialize RNG state. + #[inline] + pub fn deserialize_state(state: &SerializedRngState) -> Self { + let (seed, state_rem) = state.split_at(32); + let (stream, word_pos_raw) = state_rem.split_at(8); + + let seed: &[u8; 32] = seed.try_into().expect("seed.len() is equal to 32"); + let stream: &[u8; 8] = stream.try_into().expect("stream.len() is equal to 32"); + + // Note that we use only 68 bits from `word_pos_raw`, i.e. 4 remaining bits + // get ignored and should be equal to zero in practice. + let mut word_pos_buf = [0u8; 16]; + word_pos_buf[..9].copy_from_slice(word_pos_raw); + let word_pos = u128::from_le_bytes(word_pos_buf); + + let core = ChaChaCore::new_internal(seed, stream); + let mut res = Self { + core: BlockRng::new(core), + }; + + res.set_word_pos(word_pos); + res + } } }; } diff --git a/chacha20/tests/rng.rs b/chacha20/tests/rng.rs index 7f5d4400..a89a4a99 100644 --- a/chacha20/tests/rng.rs +++ b/chacha20/tests/rng.rs @@ -3,12 +3,13 @@ #![cfg(feature = "rng")] use chacha20::{ - ChaCha20Rng, + ChaCha8Rng, ChaCha12Rng, ChaCha20Rng, SerializedRngState, rand_core::{Rng, SeedableRng}, }; use hex_literal::hex; const KEY: [u8; 32] = hex!("0102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F20"); +const STREAM: u64 = 0xF0F1F2F3_F4F5F6F7; const BLOCK_WORDS: u8 = 16; #[test] @@ -467,3 +468,84 @@ fn counter_not_wrapping_at_32_bits() { ); assert_ne!(&first_blocks[0..64 * 4], &result[64..]); } + +#[test] +fn test_chacha8rng_serde_roundtrip() { + for skip_words in 0..100 { + let mut rng = ChaCha8Rng::from_seed(KEY); + rng.set_stream(STREAM); + for _ in 0..skip_words { + let _ = rng.next_u32(); + } + let state = rng.serialize_state(); + let mut rng2 = ChaCha8Rng::deserialize_state(&state); + for _ in 0..100 { + assert_eq!(rng.next_u32(), rng2.next_u32()); + } + } +} + +#[test] +fn test_chacha12rng_serde_roundtrip() { + for skip_words in 0..100 { + let mut rng = ChaCha12Rng::from_seed(KEY); + rng.set_stream(STREAM); + for _ in 0..skip_words { + let _ = rng.next_u32(); + } + let state = rng.serialize_state(); + let mut rng2 = ChaCha12Rng::deserialize_state(&state); + for _ in 0..100 { + assert_eq!(rng.next_u32(), rng2.next_u32()); + } + } +} + +#[test] +fn test_chacha20rng_serde_roundtrip() { + for skip_words in 0..100 { + let mut rng = ChaCha20Rng::from_seed(KEY); + rng.set_stream(STREAM); + for _ in 0..skip_words { + let _ = rng.next_u32(); + } + let state = rng.serialize_state(); + let mut rng2 = ChaCha20Rng::deserialize_state(&state); + for _ in 0..100 { + assert_eq!(rng.next_u32(), rng2.next_u32()); + } + } +} + +#[test] +fn test_rng_serialized_state_stability() { + const EXPECTED: SerializedRngState = hex!( + "0102030405060708090A0B0C0D0E0F10" + "1112131415161718191A1B1C1D1E1F20" + "F7F6F5F4F3F2F1F06400000000000000" + "00" + ); + let mut rng = ChaCha8Rng::from_seed(KEY); + rng.set_stream(STREAM); + for _ in 0..100 { + let _ = rng.next_u32(); + } + let state = rng.serialize_state(); + assert_eq!(state, EXPECTED); + + let mut rng = ChaCha12Rng::from_seed(KEY); + rng.set_stream(STREAM); + for _ in 0..100 { + let _ = rng.next_u32(); + } + let state = rng.serialize_state(); + assert_eq!(state, EXPECTED); + + let mut rng = ChaCha20Rng::from_seed(KEY); + rng.set_stream(STREAM); + for _ in 0..100 { + let _ = rng.next_u32(); + } + let state = rng.serialize_state(); + assert_eq!(state, EXPECTED); +} From f2696c6bda7ffb0d53d4d31b42c3f60d648168b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D1=80=D1=82=D1=91=D0=BC=20=D0=9F=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=BE=D0=B2=20=5BArtyom=20Pavlov=5D?= Date: Sat, 7 Feb 2026 16:35:53 +0300 Subject: [PATCH 2/4] tweak `get_seed` impl --- chacha20/src/rng.rs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/chacha20/src/rng.rs b/chacha20/src/rng.rs index f4cbba88..c9444e6b 100644 --- a/chacha20/src/rng.rs +++ b/chacha20/src/rng.rs @@ -280,13 +280,10 @@ macro_rules! impl_chacha_rng { #[inline] #[must_use] pub fn get_seed(&self) -> [u8; 32] { + let seed = &self.core.core.state[4..12]; let mut result = [0u8; 32]; - for (i, &big) in self.core.core.state[4..12].iter().enumerate() { - let index = i * 4; - result[index + 0] = big as u8; - result[index + 1] = (big >> 8) as u8; - result[index + 2] = (big >> 16) as u8; - result[index + 3] = (big >> 24) as u8; + for (src, dst) in seed.iter().zip(result.chunks_exact_mut(4)) { + dst.copy_from_slice(&src.to_le_bytes()) } result } From 1f1712c0475b52ed08ea0a96db2b9044efa71ddd Mon Sep 17 00:00:00 2001 From: Artyom Pavlov Date: Sat, 7 Feb 2026 16:53:43 +0300 Subject: [PATCH 3/4] Fix `expect` message --- chacha20/src/rng.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/chacha20/src/rng.rs b/chacha20/src/rng.rs index c9444e6b..66670284 100644 --- a/chacha20/src/rng.rs +++ b/chacha20/src/rng.rs @@ -315,7 +315,7 @@ macro_rules! impl_chacha_rng { let (stream, word_pos_raw) = state_rem.split_at(8); let seed: &[u8; 32] = seed.try_into().expect("seed.len() is equal to 32"); - let stream: &[u8; 8] = stream.try_into().expect("stream.len() is equal to 32"); + let stream: &[u8; 8] = stream.try_into().expect("stream.len() is equal to 8"); // Note that we use only 68 bits from `word_pos_raw`, i.e. 4 remaining bits // get ignored and should be equal to zero in practice. From 7ba1a06f89d7f12d4d5b356ea932a67b5958e4fc Mon Sep 17 00:00:00 2001 From: Artyom Pavlov Date: Sat, 7 Feb 2026 16:57:49 +0300 Subject: [PATCH 4/4] Add warning for `serialize_state` --- chacha20/src/rng.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/chacha20/src/rng.rs b/chacha20/src/rng.rs index 66670284..94fe1d1c 100644 --- a/chacha20/src/rng.rs +++ b/chacha20/src/rng.rs @@ -289,6 +289,10 @@ macro_rules! impl_chacha_rng { } /// Serialize RNG state. + /// + /// # Warning + /// Leaking serialized RNG state to an attacker defeats security properties + /// provided by the RNG. #[inline] pub fn serialize_state(&self) -> SerializedRngState { let seed = self.get_seed();