Skip to main content

dstu_core/
crypto_box.rs

1//! `crypto_box` equivalent (`docs/dstu-crypto-project.md` "Mapping onto the libsodium API",
2//! `docs/TASKS.md` T-178) - public-key encryption over `hazmat::dstu9041` (`l(p)=256`, E256/1
3//! only, T-177).
4//!
5//! # Hybrid via KDF - why, not just "how"
6//!
7//! `hazmat::dstu9041`'s `l(p)=256` variant caps a single ciphertext's payload at `L_MAX_P` = 200
8//! bits (25 bytes, Table 1) - far below this project's existing 32-byte symmetric keys
9//! (`crypto_secretbox::SecretKey`, `crypto_secretstream::Key`). This is not a defect: every
10//! asymmetric-encryption standard of this shape (RSA-OAEP, ECIES, RSA-KEM) is a **KEM**, meant to
11//! wrap a short secret, not to encrypt bulk data directly - OpenSSL's own `EVP_Seal*`/`EVP_Open*`
12//! ("digital envelope") and libsodium's `crypto_box_seal` both follow exactly this shape: the
13//! asymmetric step only ever establishes key material, a symmetric cipher does the actual work.
14//! `seal` draws a fresh random 25-byte (200-bit, `L_MAX_P` exactly) seed, wraps it to the
15//! recipient with `hazmat::dstu9041::encryption::encrypt`, then derives a 32-byte
16//! `crypto_secretstream::Key` from that seed via `hazmat::kupyna_kdf::Kupyna256Kdf::derive_subkey`
17//! (embedding the 25-byte seed into the low-order bytes of a zero-padded 32-byte buffer -
18//! `crypto_sign::derive_nonce`'s own "an embedding, not a truncation, no information lost"
19//! precedent - rather than calling `hazmat::kupyna_kdf` on an already-32-byte key, which this
20//! seed isn't). `crypto_secretstream` then encrypts the actual message, of any length, in one
21//! `Tag::Final` chunk (`seal`/`open` are one-shot, matching `crypto_secretbox`'s own `Vec<u8>`
22//! convention - a later genuinely multi-chunk `seal_stream`/`open_stream` pair could reuse this
23//! same KEM-prefix format without changing it).
24//!
25//! Wire format: `dstu9041_ciphertext (128 bytes) || secretstream_header (32 bytes) ||
26//! ciphertext (message.len() bytes) || tag (16 bytes)`.
27//!
28//! # `PublicKey` is 32 bytes - the curve point's `x`-coordinate only
29//!
30//! Not `x || y` (64 bytes). This is safe by an explicit group-theory argument, not an assumption:
31//! this curve's negation is `-(x,y) = (x,-y)` (the swapped-Edwards form, `docs/pseudocode/
32//! dstu9041.md`), so `x` never distinguishes a point `Q` from its negation `-Q`, and `x_T = x_{-T}`
33//! holds for any point `T` on this curve. Since `k*(-Q) = -(k*Q)` for any scalar `k`, the two
34//! possible reconstructions of `Q` from just `x_Q` give the *same* `kappa = x_{epsilon*Q}` on
35//! `seal`'s own encrypt step, regardless of which square-root branch
36//! [`crate::hazmat::dstu9041::curve256::point_from_x`] happens to return - see that function's own
37//! doc comment, and `tests/dstu9041_curve.rs`'s `point_from_x_gives_same_kappa_regardless_of_sqrt_branch`
38//! for the arithmetic proof. `PublicKey::from_bytes` runs the exact same reconstruction gauntlet
39//! `hazmat::dstu9041::encryption::decrypt` already runs (reject `x in {0,1,p-1}`, reject
40//! `x^2=a*d^-1`, `euler_criterion` before `sqrt`, subgroup check) via that shared helper - not a
41//! second, independently-maintained copy of a security-critical check.
42//!
43//! # Error collapsing
44//!
45//! [`OpenError`] deliberately does not distinguish a KEM failure from a secretstream tag failure
46//! from a recovered-but-wrong-length seed - same padding-oracle-avoidance posture as
47//! `hazmat::dstu9041::encryption::DecryptError` (D-56/D-63 precedent). Only [`OpenError::Truncated`]
48//! (a public wire-length check, no secret-dependent data involved) stays a separate variant.
49//!
50//! # Provenance
51//!
52//! This composite construction (KEM + KDF + secretstream) is not itself DSTU-specified - like
53//! `crypto_secretstream` (D-68), there is no vector oracle for it, ever; verified by
54//! property/tamper/misuse tests only. `hazmat::dstu9041::encryption` itself remains verified
55//! against the standard's own worked example (T-177).
56//!
57//! # Example
58//!
59//! ```rust
60//! use dstu_core::crypto_box::{seal, open, SecretKey};
61//!
62//! # if cfg!(miri) { return; } // several 256-iteration curve256::Point::scalar_multiply calls
63//! # // (keygen, KEM encrypt/decrypt) - minutes each under Miri's interpreter, same reasoning as
64//! # // crypto_sign's own doctest guard; type-checked normally, just not executed there.
65//! let secret = SecretKey::generate().expect("OS CSPRNG should not fail");
66//! let public = secret.public_key(); // safe to share/publish
67//!
68//! let sealed = seal(b"a message for the public key's holder only", &public)
69//!     .expect("OS CSPRNG should not fail");
70//! let opened = open(&sealed, &secret).expect("authentic ciphertext under the matching key");
71//! assert_eq!(opened, b"a message for the public key's holder only");
72//!
73//! // Tampering with the sealed blob (KEM prefix, header, ciphertext, or tag) is detected.
74//! let mut tampered = sealed.clone();
75//! let last = tampered.len() - 1;
76//! tampered[last] ^= 1;
77//! assert!(open(&tampered, &secret).is_err());
78//! ```
79
80use crate::crypto_secretstream::{Key, PullState, PushState, SecretstreamError, Tag};
81use crate::hazmat::dstu9041::curve256::{base_point, is_valid_scalar, point_from_x, Point};
82use crate::hazmat::dstu9041::encryption::{
83    decrypt as dstu9041_decrypt, encrypt as dstu9041_encrypt,
84};
85use crate::hazmat::dstu9041::fp256::from_candidate_bytes;
86use crate::hazmat::dstu9041::message::L_MAX_P;
87use crate::hazmat::kupyna_kdf::Kupyna256Kdf;
88use crate::randombytes::{randombytes_buf, RandomError};
89use core::fmt;
90use zeroize::Zeroize;
91
92const SEED_LEN: usize = L_MAX_P / 8;
93const KEM_CIPHERTEXT_LEN: usize = 128;
94const HEADER_LEN: usize = 32;
95const TAG_LEN: usize = 16;
96/// Domain-separation context for the seed-to-stream-key derivation - distinct from every other
97/// `Kupyna256Kdf::derive_subkey` call site in this crate (`crypto_kdf`'s own callers choose their
98/// own contexts; this one is fixed since `crypto_box` has exactly one derivation to make).
99const KDF_CONTEXT: &[u8; 8] = b"cryptbox";
100
101/// `crypto_box` can fail while sealing for one reason: the OS CSPRNG.
102#[derive(Debug)]
103pub enum SealError {
104    Random(RandomError),
105}
106
107impl fmt::Display for SealError {
108    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
109        match self {
110            SealError::Random(e) => write!(f, "{e}"),
111        }
112    }
113}
114
115impl core::error::Error for SealError {}
116
117impl From<RandomError> for SealError {
118    fn from(e: RandomError) -> Self {
119        SealError::Random(e)
120    }
121}
122
123/// `crypto_box` can fail while opening for reasons beyond a wrong key.
124#[derive(Debug)]
125pub enum OpenError {
126    /// `sealed` is shorter than a KEM ciphertext plus a secretstream header and tag (176 bytes) -
127    /// too short to have ever been produced by [`seal`].
128    Truncated,
129    /// Any late-stage failure: wrong secret key, a tampered KEM prefix/header/ciphertext/tag, or a
130    /// recovered seed whose bit length isn't exactly `L_MAX_P` - deliberately collapsed, see the
131    /// module doc's "Error collapsing" section.
132    InvalidCiphertext,
133}
134
135impl fmt::Display for OpenError {
136    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
137        match self {
138            OpenError::Truncated => write!(f, "input too short to contain a sealed message"),
139            OpenError::InvalidCiphertext => write!(f, "authentication failed"),
140        }
141    }
142}
143
144impl core::error::Error for OpenError {}
145
146/// Rejection-samples a scalar in `{2, ..., n-2}` (`is_valid_scalar`) - shared by [`SecretKey::generate`]
147/// (a long-term key) and [`seal`] (a fresh ephemeral key per call). Mirrors
148/// `crypto_sign::SigningKey::generate`'s own pattern: zeroize each rejected candidate immediately,
149/// never a modulo reduction (`n` is not a power of two - that would bias small residues).
150fn random_valid_scalar() -> Result<[u8; 32], RandomError> {
151    loop {
152        let mut candidate = [0u8; 32];
153        randombytes_buf(&mut candidate)?;
154        if is_valid_scalar(&candidate) {
155            return Ok(candidate);
156        }
157        candidate.zeroize();
158    }
159}
160
161/// Embeds a 25-byte seed into the low-order bytes of a zero-padded 32-byte buffer - see the module
162/// doc's "Hybrid via KDF" section for why this, not a fresh KDF variant, is the right fix for the
163/// length mismatch.
164fn embed_seed(seed: &[u8; SEED_LEN]) -> [u8; 32] {
165    let mut embedded = [0u8; 32];
166    embedded[32 - SEED_LEN..].copy_from_slice(seed);
167    embedded
168}
169
170/// A `crypto_box` private key - the DSTU 9041 scalar `e`.
171pub struct SecretKey([u8; 32]);
172
173impl Drop for SecretKey {
174    fn drop(&mut self) {
175        self.0.zeroize();
176    }
177}
178
179impl SecretKey {
180    /// Builds a secret key from a big-endian 32-byte scalar. Returns `None` if it's outside the
181    /// valid range `{2, ..., n-2}` (`hazmat::dstu9041::curve256::is_valid_scalar`).
182    #[must_use]
183    pub fn from_bytes(e: &[u8; 32]) -> Option<Self> {
184        if is_valid_scalar(e) {
185            Some(SecretKey(*e))
186        } else {
187            None
188        }
189    }
190
191    /// Generates a fresh secret key from the OS CSPRNG - libsodium's `crypto_box_keypair()`
192    /// equivalent (its public-key half is [`Self::public_key`]).
193    ///
194    /// # Errors
195    ///
196    /// Returns [`RandomError`] if the OS CSPRNG fails while drawing a candidate.
197    #[cfg(any(feature = "std", feature = "getrandom"))]
198    pub fn generate() -> Result<Self, RandomError> {
199        random_valid_scalar().map(SecretKey)
200    }
201
202    /// Returns `e`'s big-endian 32-byte encoding, so a generated key can be persisted and later
203    /// reloaded via [`Self::from_bytes`]. The caller becomes responsible for zeroizing the
204    /// returned array once done with it.
205    #[must_use]
206    pub fn to_bytes(&self) -> [u8; 32] {
207        self.0
208    }
209
210    #[must_use]
211    pub fn public_key(&self) -> PublicKey {
212        PublicKey(base_point().scalar_multiply(&self.0))
213    }
214}
215
216/// A `crypto_box` public key - a curve point's `x`-coordinate only (32 bytes), see the module
217/// doc's own section on why this compression is safe.
218#[derive(Clone, Copy)]
219pub struct PublicKey(Point);
220
221impl PublicKey {
222    /// Reconstructs a public key from its compressed 32-byte `x`-coordinate encoding. Returns
223    /// `None` if `bytes` isn't a valid field element, or doesn't reconstruct to a point inside the
224    /// base point's own prime-order subgroup (`curve256::point_from_x`'s own rejection gauntlet).
225    #[must_use]
226    pub fn from_bytes(bytes: &[u8; 32]) -> Option<Self> {
227        from_candidate_bytes(bytes)
228            .and_then(point_from_x)
229            .map(PublicKey)
230    }
231
232    #[must_use]
233    pub fn to_bytes(&self) -> [u8; 32] {
234        self.0.x.to_be_bytes()
235    }
236}
237
238/// Encrypts `message` (any length) to `recipient`, drawing a fresh random seed and ephemeral key
239/// internally - see the module doc's "Hybrid via KDF" section.
240///
241/// # Errors
242///
243/// Returns [`SealError::Random`] if the OS CSPRNG fails - the only way this can fail.
244pub fn seal(message: &[u8], recipient: &PublicKey) -> Result<Vec<u8>, SealError> {
245    let mut seed = [0u8; SEED_LEN];
246    randombytes_buf(&mut seed)?;
247
248    let mut epsilon = random_valid_scalar()?;
249    // Unreachable: `seed` is always exactly SEED_LEN bytes at message_bits=L_MAX_P (can't trigger
250    // `InvalidMessage`), and `epsilon` is already validated by `random_valid_scalar` (can't
251    // trigger `InvalidEphemeralKey`).
252    let Ok(kem_ciphertext) = dstu9041_encrypt(&seed, L_MAX_P, recipient.0, &epsilon) else {
253        unreachable!("seed/epsilon are always valid by construction")
254    };
255    epsilon.zeroize();
256
257    let mut embedded = embed_seed(&seed);
258    seed.zeroize();
259    let mut stream_key_bytes = Kupyna256Kdf::derive_subkey(&embedded, 0, KDF_CONTEXT);
260    embedded.zeroize();
261    let key = Key::from_bytes(stream_key_bytes);
262    stream_key_bytes.zeroize();
263
264    let (mut push, header) = match PushState::init(&key) {
265        Ok(ok) => ok,
266        Err(SecretstreamError::Random(e)) => return Err(SealError::Random(e)),
267        // `PushState::init`'s only documented failure mode is the OS CSPRNG call for its header -
268        // see `crypto_secretstream::SecretstreamError`'s own doc comment.
269        Err(_) => unreachable!("PushState::init only ever fails via SecretstreamError::Random"),
270    };
271
272    let mut ciphertext = vec![0u8; message.len()];
273    let Ok(tag) = push.push(Tag::Final, message, &mut ciphertext) else {
274        unreachable!(
275            "ciphertext.len() == message.len() by construction; stream freshly initialized, \
276             never finalized yet"
277        )
278    };
279
280    let mut out = Vec::with_capacity(KEM_CIPHERTEXT_LEN + HEADER_LEN + ciphertext.len() + TAG_LEN);
281    out.extend_from_slice(&kem_ciphertext);
282    out.extend_from_slice(&header);
283    out.extend_from_slice(&ciphertext);
284    out.extend_from_slice(&tag);
285    Ok(out)
286}
287
288/// Decrypts `sealed` (as produced by [`seal`]) under `secret`.
289///
290/// # Errors
291///
292/// Returns [`OpenError::Truncated`] if `sealed` is shorter than the minimum possible length, or
293/// [`OpenError::InvalidCiphertext`] for any other failure - see the module doc's "Error collapsing"
294/// section.
295pub fn open(sealed: &[u8], secret: &SecretKey) -> Result<Vec<u8>, OpenError> {
296    const MIN_LEN: usize = KEM_CIPHERTEXT_LEN + HEADER_LEN + TAG_LEN;
297    if sealed.len() < MIN_LEN {
298        return Err(OpenError::Truncated);
299    }
300
301    let mut kem_ciphertext = [0u8; KEM_CIPHERTEXT_LEN];
302    kem_ciphertext.copy_from_slice(&sealed[..KEM_CIPHERTEXT_LEN]);
303    let mut header = [0u8; HEADER_LEN];
304    header.copy_from_slice(&sealed[KEM_CIPHERTEXT_LEN..KEM_CIPHERTEXT_LEN + HEADER_LEN]);
305    let ciphertext_len = sealed.len() - MIN_LEN;
306    let ciphertext_start = KEM_CIPHERTEXT_LEN + HEADER_LEN;
307    let ciphertext = &sealed[ciphertext_start..ciphertext_start + ciphertext_len];
308    let tag = &sealed[ciphertext_start + ciphertext_len..];
309
310    let (mut seed_padded, bit_len) =
311        dstu9041_decrypt(&kem_ciphertext, &secret.0).map_err(|_| OpenError::InvalidCiphertext)?;
312    // Defense in depth: an honestly-sealed ciphertext always has bit_len == L_MAX_P (the hash
313    // check inside `decrypt` already makes forging a different-but-valid bit_length as hard as a
314    // Kupyna-256 preimage), but this is never trusted blindly.
315    if bit_len != L_MAX_P {
316        seed_padded.zeroize();
317        return Err(OpenError::InvalidCiphertext);
318    }
319
320    let mut embedded = embed_seed(&seed_padded);
321    seed_padded.zeroize();
322    let mut stream_key_bytes = Kupyna256Kdf::derive_subkey(&embedded, 0, KDF_CONTEXT);
323    embedded.zeroize();
324    let key = Key::from_bytes(stream_key_bytes);
325    stream_key_bytes.zeroize();
326
327    let mut pull = PullState::init(&key, &header);
328    let mut plaintext = vec![0u8; ciphertext_len];
329    pull.pull(Tag::Final.to_byte(), ciphertext, tag, &mut plaintext)
330        .map_err(|_| OpenError::InvalidCiphertext)?;
331
332    Ok(plaintext)
333}