Skip to main content

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}