dstu_core/crypto_box512.rs
1//! `crypto_box` equivalent at `l(p)=512` (E512/1, `docs/TASKS.md` T-193) - direct sibling of
2//! [`crate::crypto_box`] (`l(p)=256`, T-178) at this curve size's own widths. Matches this
3//! project's established per-curve-size sibling-module precedent (`hazmat::dstu9041::curve512`
4//! etc., D-181), not a generic-over-width merge.
5//!
6//! # Seed width - fixed at 32 bytes, not `l(p)=512`'s full 424-bit KEM capacity
7//!
8//! `crypto_box`'s own `embed_seed` (`l(p)=256`, `SEED_LEN = L_MAX_P/8 = 25`) does not generalize
9//! here: `l(p)=512`'s `L_MAX_P = 424` bits (53 bytes) exceeds `Kupyna256Kdf`'s 32-byte input
10//! width, so a literal copy-paste underflows. `docs/DECISIONS.md` D-182 resolved this: the seed
11//! stays a fixed 32 bytes (`Kupyna256Kdf`'s own native width, no embedding/padding step needed at
12//! all), wrapped via `dstu9041_encrypt(&seed, 256, ...)` (`message_bits` fixed at `256`, not
13//! `L_MAX_P`). `open` requires the recovered bit length to be exactly `256` before trusting the
14//! recovered seed - confirmed against `message512.rs` directly (not assumed) that `decrypt`
15//! genuinely returns the encryptor-supplied bit length, not the buffer's fixed width (D-182).
16//! Leaving most of `L_MAX_P`'s 424 bits of KEM capacity unused is deliberate: the seed only ever
17//! needs to reach `Kupyna256Kdf`'s fixed input, matching `crypto_box`'s own "asymmetric step only
18//! ever establishes key material" framing.
19//!
20//! # `PublicKey` is 64 bytes - the curve point's `x`-coordinate only
21//!
22//! Independently re-derived for E512/1 rather than assumed to carry over from `crypto_box`'s own
23//! `l(p)=256` argument (this project's standing "don't assume a security argument carries over
24//! unchecked" discipline, D-176/D-178's own precedent): the compression-safety argument is a
25//! curve-family property (twisted Edwards negation `-(x,y) = (x,-y)` in this standard's own
26//! swapped-Edwards convention), not a `p`/`d`/`n`-value-dependent one, so it holds identically for
27//! E512/1 - `x_T = x_{-T}` for any point `T`, hence `k*(-Q) = -(k*Q)` gives the same
28//! `kappa = x_{epsilon*Q}` regardless of which square-root branch
29//! [`crate::hazmat::dstu9041::curve512::point_from_x`] returns. `PublicKey::from_bytes` reuses
30//! that function's own rejection gauntlet directly, not a second copy.
31//!
32//! # Error collapsing, wire format
33//!
34//! Same posture as [`crate::crypto_box`] - [`OpenError`] deliberately does not distinguish a KEM
35//! failure from a secretstream tag failure (D-56/D-63 precedent); only [`OpenError::Truncated`]
36//! stays separate. Wire format: `dstu9041_ciphertext (256 bytes) || secretstream_header (32
37//! bytes) || ciphertext (message.len() bytes) || tag (16 bytes)`.
38//!
39//! A `box-open`-length-valid `l(p)=512` sealed blob also clears [`crate::crypto_box::open`]'s own
40//! `MIN_LEN` check (176 bytes) and vice versa - both fall through to their own
41//! `InvalidCiphertext`/authentication-failure path rather than a distinct "wrong curve size"
42//! error. Defensible under the shared error-collapsing posture (recorded here, not left to be
43//! found by surprise): a curve-size mismatch is just another way for the KEM decrypt to fail.
44//!
45//! # Provenance
46//!
47//! Not itself DSTU-specified, like `crypto_box` - no vector oracle exists or ever will for this
48//! composite; verified by property/tamper/misuse tests only (`tests/crypto_box512.rs`).
49//! `hazmat::dstu9041::encryption512` itself remains verified against Додаток Г.3 (T-192).
50
51use crate::crypto_secretstream::{Key, PullState, PushState, SecretstreamError, Tag};
52use crate::hazmat::dstu9041::curve512::{base_point, is_valid_scalar, point_from_x, Point};
53use crate::hazmat::dstu9041::encryption512::{
54 decrypt as dstu9041_decrypt, encrypt as dstu9041_encrypt,
55};
56use crate::hazmat::dstu9041::fp512::from_candidate_bytes;
57use crate::randombytes::{randombytes_buf, RandomError};
58use core::fmt;
59use zeroize::Zeroize;
60
61/// Fixed seed width - `Kupyna256Kdf`'s own native input size, see the module doc's "Seed width"
62/// section for why this, not `l(p)=512`'s full `L_MAX_P` capacity, is correct.
63const SEED_LEN: usize = 32;
64/// The fixed `message_bits` value passed to `dstu9041_encrypt`/checked on `dstu9041_decrypt` -
65/// `SEED_LEN * 8`, deliberately far below `L_MAX_P` (424).
66const SEED_BITS: usize = SEED_LEN * 8;
67const KEM_CIPHERTEXT_LEN: usize = 256;
68const HEADER_LEN: usize = 32;
69const TAG_LEN: usize = 16;
70/// Domain-separation context for the seed-to-stream-key derivation - distinct from `crypto_box`'s
71/// own `KDF_CONTEXT` (different KEM ciphertext shape, different `PublicKey`/`SecretKey` widths -
72/// a caller using both `crypto_box` and `crypto_box512` must never be able to conflate contexts).
73const KDF_CONTEXT: &[u8; 8] = b"cryptbx5";
74
75use crate::hazmat::kupyna_kdf::Kupyna256Kdf;
76
77/// `crypto_box512` can fail while sealing for one reason: the OS CSPRNG.
78#[derive(Debug)]
79pub enum SealError {
80 Random(RandomError),
81}
82
83impl fmt::Display for SealError {
84 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
85 match self {
86 SealError::Random(e) => write!(f, "{e}"),
87 }
88 }
89}
90
91impl core::error::Error for SealError {}
92
93impl From<RandomError> for SealError {
94 fn from(e: RandomError) -> Self {
95 SealError::Random(e)
96 }
97}
98
99/// `crypto_box512` can fail while opening for reasons beyond a wrong key.
100#[derive(Debug)]
101pub enum OpenError {
102 /// `sealed` is shorter than a KEM ciphertext plus a secretstream header and tag (304 bytes) -
103 /// too short to have ever been produced by [`seal`].
104 Truncated,
105 /// Any late-stage failure: wrong secret key, a tampered KEM prefix/header/ciphertext/tag, or a
106 /// recovered seed whose bit length isn't exactly `SEED_BITS` - deliberately collapsed, see the
107 /// module doc's "Error collapsing" section.
108 InvalidCiphertext,
109}
110
111impl fmt::Display for OpenError {
112 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
113 match self {
114 OpenError::Truncated => write!(f, "input too short to contain a sealed message"),
115 OpenError::InvalidCiphertext => write!(f, "authentication failed"),
116 }
117 }
118}
119
120impl core::error::Error for OpenError {}
121
122/// Rejection-samples a scalar in `{2, ..., n-2}` (`is_valid_scalar`) - shared by
123/// [`SecretKey::generate`] (a long-term key) and [`seal`] (a fresh ephemeral key per call).
124/// Mirrors `crypto_box`'s own `random_valid_scalar`: zeroize each rejected candidate immediately,
125/// never a modulo reduction (`n` is not a power of two - that would bias small residues).
126fn random_valid_scalar() -> Result<[u8; 64], RandomError> {
127 loop {
128 let mut candidate = [0u8; 64];
129 randombytes_buf(&mut candidate)?;
130 if is_valid_scalar(&candidate) {
131 return Ok(candidate);
132 }
133 candidate.zeroize();
134 }
135}
136
137/// A `crypto_box512` private key - the DSTU 9041 scalar `e`.
138pub struct SecretKey([u8; 64]);
139
140impl Drop for SecretKey {
141 fn drop(&mut self) {
142 self.0.zeroize();
143 }
144}
145
146impl SecretKey {
147 /// Builds a secret key from a big-endian 64-byte scalar. Returns `None` if it's outside the
148 /// valid range `{2, ..., n-2}` (`hazmat::dstu9041::curve512::is_valid_scalar`).
149 #[must_use]
150 pub fn from_bytes(e: &[u8; 64]) -> Option<Self> {
151 if is_valid_scalar(e) {
152 Some(SecretKey(*e))
153 } else {
154 None
155 }
156 }
157
158 /// Generates a fresh secret key from the OS CSPRNG.
159 ///
160 /// # Errors
161 ///
162 /// Returns [`RandomError`] if the OS CSPRNG fails while drawing a candidate.
163 #[cfg(any(feature = "std", feature = "getrandom"))]
164 pub fn generate() -> Result<Self, RandomError> {
165 random_valid_scalar().map(SecretKey)
166 }
167
168 /// Returns `e`'s big-endian 64-byte encoding, so a generated key can be persisted and later
169 /// reloaded via [`Self::from_bytes`]. The caller becomes responsible for zeroizing the
170 /// returned array once done with it.
171 #[must_use]
172 pub fn to_bytes(&self) -> [u8; 64] {
173 self.0
174 }
175
176 #[must_use]
177 pub fn public_key(&self) -> PublicKey {
178 PublicKey(base_point().scalar_multiply(&self.0))
179 }
180}
181
182/// A `crypto_box512` public key - a curve point's `x`-coordinate only (64 bytes), see the module
183/// doc's own section on why this compression is safe.
184#[derive(Clone, Copy)]
185pub struct PublicKey(Point);
186
187impl PublicKey {
188 /// Reconstructs a public key from its compressed 64-byte `x`-coordinate encoding. Returns
189 /// `None` if `bytes` isn't a valid field element, or doesn't reconstruct to a point inside the
190 /// base point's own prime-order subgroup (`curve512::point_from_x`'s own rejection gauntlet).
191 #[must_use]
192 pub fn from_bytes(bytes: &[u8; 64]) -> Option<Self> {
193 from_candidate_bytes(bytes)
194 .and_then(point_from_x)
195 .map(PublicKey)
196 }
197
198 #[must_use]
199 pub fn to_bytes(&self) -> [u8; 64] {
200 self.0.x.to_be_bytes()
201 }
202}
203
204/// Encrypts `message` (any length) to `recipient`, drawing a fresh random seed and ephemeral key
205/// internally - see the module doc's "Seed width" section.
206///
207/// # Errors
208///
209/// Returns [`SealError::Random`] if the OS CSPRNG fails - the only way this can fail.
210pub fn seal(message: &[u8], recipient: &PublicKey) -> Result<Vec<u8>, SealError> {
211 let mut seed = [0u8; SEED_LEN];
212 randombytes_buf(&mut seed)?;
213
214 let mut epsilon = random_valid_scalar()?;
215 // Unreachable in practice: `seed` is always exactly SEED_LEN bytes at message_bits=SEED_BITS
216 // (can't trigger `InvalidMessage`), and `epsilon` is already validated by
217 // `random_valid_scalar` (can't trigger `InvalidEphemeralKey`).
218 let Ok(kem_ciphertext) = dstu9041_encrypt(&seed, SEED_BITS, recipient.0, &epsilon) else {
219 unreachable!("seed/epsilon are always valid by construction")
220 };
221 epsilon.zeroize();
222
223 let mut stream_key_bytes = Kupyna256Kdf::derive_subkey(&seed, 0, KDF_CONTEXT);
224 seed.zeroize();
225 let key = Key::from_bytes(stream_key_bytes);
226 stream_key_bytes.zeroize();
227
228 let (mut push, header) = match PushState::init(&key) {
229 Ok(ok) => ok,
230 Err(SecretstreamError::Random(e)) => return Err(SealError::Random(e)),
231 // `PushState::init`'s only documented failure mode is the OS CSPRNG call for its header -
232 // see `crypto_secretstream::SecretstreamError`'s own doc comment.
233 Err(_) => unreachable!("PushState::init only ever fails via SecretstreamError::Random"),
234 };
235
236 let mut ciphertext = vec![0u8; message.len()];
237 let Ok(tag) = push.push(Tag::Final, message, &mut ciphertext) else {
238 unreachable!(
239 "ciphertext.len() == message.len() by construction; stream freshly initialized, \
240 never finalized yet"
241 )
242 };
243
244 let mut out = Vec::with_capacity(KEM_CIPHERTEXT_LEN + HEADER_LEN + ciphertext.len() + TAG_LEN);
245 out.extend_from_slice(&kem_ciphertext);
246 out.extend_from_slice(&header);
247 out.extend_from_slice(&ciphertext);
248 out.extend_from_slice(&tag);
249 Ok(out)
250}
251
252/// Decrypts `sealed` (as produced by [`seal`]) under `secret`.
253///
254/// # Errors
255///
256/// Returns [`OpenError::Truncated`] if `sealed` is shorter than the minimum possible length, or
257/// [`OpenError::InvalidCiphertext`] for any other failure - see the module doc's "Error collapsing"
258/// section.
259pub fn open(sealed: &[u8], secret: &SecretKey) -> Result<Vec<u8>, OpenError> {
260 const MIN_LEN: usize = KEM_CIPHERTEXT_LEN + HEADER_LEN + TAG_LEN;
261 if sealed.len() < MIN_LEN {
262 return Err(OpenError::Truncated);
263 }
264
265 let mut kem_ciphertext = [0u8; KEM_CIPHERTEXT_LEN];
266 kem_ciphertext.copy_from_slice(&sealed[..KEM_CIPHERTEXT_LEN]);
267 let mut header = [0u8; HEADER_LEN];
268 header.copy_from_slice(&sealed[KEM_CIPHERTEXT_LEN..KEM_CIPHERTEXT_LEN + HEADER_LEN]);
269 let ciphertext_len = sealed.len() - MIN_LEN;
270 let ciphertext_start = KEM_CIPHERTEXT_LEN + HEADER_LEN;
271 let ciphertext = &sealed[ciphertext_start..ciphertext_start + ciphertext_len];
272 let tag = &sealed[ciphertext_start + ciphertext_len..];
273
274 let (m_tilde, bit_len) =
275 dstu9041_decrypt(&kem_ciphertext, &secret.0).map_err(|_| OpenError::InvalidCiphertext)?;
276 // Defense in depth: an honestly-sealed ciphertext always has bit_len == SEED_BITS (the hash
277 // check inside `decrypt` already makes forging a different-but-valid bit_length as hard as a
278 // Kupyna-256 preimage), but this is never trusted blindly.
279 if bit_len != SEED_BITS {
280 let mut m_tilde = m_tilde;
281 m_tilde.zeroize();
282 return Err(OpenError::InvalidCiphertext);
283 }
284 let mut seed = [0u8; SEED_LEN];
285 seed.copy_from_slice(&m_tilde[m_tilde.len() - SEED_LEN..]);
286 let mut m_tilde = m_tilde;
287 m_tilde.zeroize();
288
289 let mut stream_key_bytes = Kupyna256Kdf::derive_subkey(&seed, 0, KDF_CONTEXT);
290 seed.zeroize();
291 let key = Key::from_bytes(stream_key_bytes);
292 stream_key_bytes.zeroize();
293
294 let mut pull = PullState::init(&key, &header);
295 let mut plaintext = vec![0u8; ciphertext_len];
296 pull.pull(Tag::Final.to_byte(), ciphertext, tag, &mut plaintext)
297 .map_err(|_| OpenError::InvalidCiphertext)?;
298
299 Ok(plaintext)
300}