dstu_core/crypto_stream.rs
1//! `crypto_stream` equivalent (`docs/dstu-crypto-project.md` "Mapping onto the libsodium API",
2//! `docs/TASKS.md` roadmap Step 3 item 3, `docs/DECISIONS.md` D-67) - a libsodium-ergonomics wrapper over
3//! [`hazmat::strumok::Strumok256`](crate::hazmat::strumok::Strumok256).
4//!
5//! # No authentication whatsoever
6//!
7//! Strumok is a bare keystream generator - XOR-ing it into a message provides confidentiality
8//! only, never integrity (`hazmat::strumok`'s own module doc, `docs/release-readiness.md`'s
9//! "Streaming audio, confidentiality only" use-case row). Unlike [`crate::crypto_secretbox`],
10//! [`decrypt`] **never fails on tampered input** - it has no tag to check, so a modified
11//! `sealed` value decrypts to different, silently-wrong plaintext instead of an error, the same
12//! documented no-integrity-by-design property `hazmat::kalyna_xts` already has
13//! (`tests/kalyna_xts.rs`'s `tampered_ciphertext_does_not_error_but_produces_garbage`). This is
14//! why this module's functions are named `encrypt`/`decrypt`, not `seal`/`open` -
15//! `crypto_secretbox` reserves `seal`/`open` specifically to signal "this authenticates," and
16//! this primitive does not. Callers needing integrity must wrap each message in
17//! [`crate::crypto_secretbox`] (or a chunked `crypto_secretstream`, once T-40 exists) instead of,
18//! or on top of, this module - never rely on this module alone where tamper-detection matters.
19//!
20//! # Hidden IV
21//!
22//! Confirmed with the project owner (roadmap Step 3 item 3 was left as an explicit open fork,
23//! unlike this roadmap's other named forks): the IV is generated internally from the OS CSPRNG,
24//! the same choice `crypto_secretbox` made for its nonce (D-51), never caller-supplied. This
25//! matters more here than for most primitives: `hazmat::strumok`'s own module doc carries a
26//! "never reuse the same key+IV pair" warning, backed by a dedicated test
27//! (`reusing_key_and_iv_leaks_plaintext_xor`, `docs/TASKS.md` T-103) pinning the catastrophic two-time-
28//! pad property directly - reusing a key+IV pair XORs the two plaintexts together, recoverable
29//! without ever breaking the cipher itself. Hiding IV generation removes that footgun from the
30//! caller's surface entirely, at the cost of matching libsodium's own lower-level
31//! `crypto_stream_xor(c, m, mlen, n, k)` C signature (`n` is a caller-supplied parameter there) -
32//! a deliberate divergence, not an oversight, matching `crypto_secretbox`'s own precedent of
33//! prioritizing misuse-resistance over raw-API parity (D-47's tie-breaker).
34//!
35//! # Variant
36//!
37//! Only `Strumok256` is exposed here (D-47's "delete the knob", matching `crypto_auth`/
38//! `crypto_kdf`'s single-256-bit-variant choice, D-66) - `Strumok512` stays `hazmat`-only.
39//!
40//! # Provenance
41//!
42//! Inherits `hazmat::strumok`'s own D-18 status: vectors are UAPKI-attributed, not confirmed
43//! against the primary DSTU 8845:2019 text.
44//!
45//! # Example
46//!
47//! Confidentiality only, **no integrity** (see the "No authentication whatsoever" section above) -
48//! prefer [`crate::crypto_secretbox`]/[`crate::crypto_secretstream`] unless you specifically need a
49//! bare keystream cipher and are handling authentication yourself. Note what tampering does here,
50//! in contrast to `crypto_secretbox`'s example above: `decrypt` never errors, it just returns
51//! different, silently-wrong plaintext.
52//!
53//! ```rust
54//! use dstu_core::crypto_stream::{encrypt, decrypt, Key};
55//!
56//! let key = Key::generate().expect("OS CSPRNG should not fail");
57//! let sealed = encrypt(&key, b"message").expect("OS CSPRNG should not fail");
58//! let opened = decrypt(&key, &sealed).expect("sealed is at least IV-length");
59//! assert_eq!(opened, b"message");
60//!
61//! // Tampering is not detected - decrypt "succeeds" with garbage plaintext instead of erroring.
62//! let mut tampered = sealed.clone();
63//! let last = tampered.len() - 1;
64//! tampered[last] ^= 1;
65//! let garbage = decrypt(&key, &tampered).expect("still at least IV-length, so still Ok");
66//! assert_ne!(garbage, b"message");
67//! ```
68
69use crate::hazmat::strumok::Strumok256;
70use crate::randombytes::{randombytes_buf, RandomError};
71use core::fmt;
72use zeroize::Zeroize;
73
74const IV_LEN: usize = 32;
75
76/// `crypto_stream` can fail only if the OS CSPRNG fails while generating a fresh IV - there is no
77/// tag to mismatch (see the module doc's "No authentication" section).
78#[derive(Debug)]
79pub enum StreamError {
80 /// The input to [`decrypt`] is shorter than an IV (32 bytes) - too short to have ever been
81 /// produced by [`encrypt`].
82 Truncated,
83 /// The OS CSPRNG failed while generating an IV (see [`crate::randombytes`]).
84 Random(RandomError),
85}
86
87impl fmt::Display for StreamError {
88 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
89 match self {
90 StreamError::Truncated => write!(f, "input too short to contain an IV"),
91 StreamError::Random(e) => write!(f, "{e}"),
92 }
93 }
94}
95
96impl core::error::Error for StreamError {}
97
98impl From<RandomError> for StreamError {
99 fn from(e: RandomError) -> Self {
100 StreamError::Random(e)
101 }
102}
103
104/// A `crypto_stream` key. Always exactly 32 bytes - `Strumok256`'s key length (see the module
105/// doc).
106pub struct Key([u8; 32]);
107
108impl Drop for Key {
109 fn drop(&mut self) {
110 self.0.zeroize();
111 }
112}
113
114impl Key {
115 /// Generates a fresh key from the OS CSPRNG - libsodium's `crypto_stream_keygen` equivalent.
116 ///
117 /// # Errors
118 ///
119 /// Returns [`RandomError`] if the OS CSPRNG fails.
120 pub fn generate() -> Result<Self, RandomError> {
121 let mut bytes = [0u8; 32];
122 randombytes_buf(&mut bytes)?;
123 Ok(Key(bytes))
124 }
125
126 #[must_use]
127 pub fn from_bytes(bytes: [u8; 32]) -> Self {
128 Key(bytes)
129 }
130
131 #[must_use]
132 pub fn as_bytes(&self) -> &[u8; 32] {
133 &self.0
134 }
135}
136
137/// XORs `plaintext` with a fresh keystream under `key`, drawing a random IV internally. Returns
138/// `iv (32 bytes) || ciphertext (plaintext.len() bytes)` - no authentication (see the module
139/// doc's "No authentication" section).
140///
141/// # Errors
142///
143/// Returns [`StreamError::Random`] if the OS CSPRNG fails - the only way this can fail.
144pub fn encrypt(key: &Key, plaintext: &[u8]) -> Result<Vec<u8>, StreamError> {
145 let mut iv = [0u8; IV_LEN];
146 randombytes_buf(&mut iv)?;
147
148 let mut buf = plaintext.to_vec();
149 let mut cipher = Strumok256::new(key.as_bytes(), &iv);
150 cipher.apply_keystream(&mut buf);
151
152 let mut out = Vec::with_capacity(IV_LEN + buf.len());
153 out.extend_from_slice(&iv);
154 out.extend_from_slice(&buf);
155 Ok(out)
156}
157
158/// Reverses [`encrypt`] under `key` - XOR is its own inverse
159/// ([`hazmat::strumok`](crate::hazmat::strumok)'s `apply_keystream`), so this recovers the
160/// original plaintext bit-for-bit when `sealed` is exactly [`encrypt`]'s own output. **Never
161/// fails on tampered input** - see the module doc's "No authentication" section; a modified
162/// `sealed` decrypts to different, silently-wrong plaintext, not an error.
163///
164/// # Errors
165///
166/// Returns [`StreamError::Truncated`] if `sealed` is shorter than an IV (32 bytes) - the only
167/// possible error, since there is no tag to fail.
168pub fn decrypt(key: &Key, sealed: &[u8]) -> Result<Vec<u8>, StreamError> {
169 if sealed.len() < IV_LEN {
170 return Err(StreamError::Truncated);
171 }
172
173 let mut iv = [0u8; IV_LEN];
174 iv.copy_from_slice(&sealed[..IV_LEN]);
175
176 let mut buf = sealed[IV_LEN..].to_vec();
177 let mut cipher = Strumok256::new(key.as_bytes(), &iv);
178 cipher.apply_keystream(&mut buf);
179 Ok(buf)
180}