dstu_core/crypto_secretbox.rs
1//! `crypto_secretbox` equivalent (`docs/dstu-crypto-project.md` "Mapping onto the libsodium API",
2//! `docs/TASKS.md` T-37, `docs/DECISIONS.md` D-51) - a single fixed `hazmat::kalyna_gcm::Kalyna256_256Gcm`
3//! construction (D-47's tie-breaker rule: no algorithm knob when one safe default exists) with an
4//! internally-generated nonce (never caller-supplied, extending the pattern `uacrypt kalyna-ccm
5//! encrypt`'s CLI layer already used, D-40/T-82) and a combined `nonce || ciphertext || tag` wire
6//! format, matching libsodium's own `crypto_secretbox_easy` ergonomics.
7//!
8//! # No message-length cap
9//!
10//! Migrated from Kalyna-CCM to Kalyna-GCM 2026-07-25 (roadmap Step 3 item 1, `docs/DECISIONS.md`
11//! D-63) - the original Kalyna-CCM construction capped plaintext/AAD at 255 bytes each (D-41,
12//! `ccm_padd`'s header encoding). GCM encodes no length into its construction at all, so that cap
13//! and `SecretboxError::MessageTooLong` are gone entirely, not just raised. This does not make
14//! disk-file encryption memory-bounded, though: an AEAD tag needs the full plaintext/ciphertext,
15//! so a large message still means a correspondingly large in-memory buffer (see `uacrypt`'s own
16//! `run_secretbox_command` doc comment for the concrete consequence at the CLI layer).
17//! `crypto_secretstream` (`docs/TASKS.md` T-40) remains the separately-tracked follow-up for a
18//! genuinely chunked/streaming construction; this module still does not attempt that.
19//!
20//! # No AAD (caller-facing) - but the nonce is bound into the tag internally
21//!
22//! libsodium's own `crypto_secretbox` has no associated-data parameter (that's `crypto_aead`'s
23//! job) - `hazmat::kalyna_gcm` takes AAD, but exposing it here would quietly turn this into a
24//! different primitive than its name promises. No caller-supplied AAD exists.
25//!
26//! Internally, though, `seal`/`open` pass the nonce itself as `kalyna_gcm`'s AAD (never empty).
27//! This is not optional: unlike NIST AES-GCM, DSTU 7624's Kalyna-GCM tag is computed purely from
28//! AAD and ciphertext (`E_K(accumulator XOR length_block)`, D-56 divergence 3) and never mixes in
29//! the IV/nonce at all - the nonce only seeds the keystream. For a combined
30//! `nonce || ciphertext || tag` blob, an unauthenticated nonce means an attacker can flip bits in
31//! the nonce prefix of a sealed message and `open` will still "succeed", just against a different
32//! (attacker-uncontrolled but unverified-as-original) keystream - a real tamper-evidence gap the
33//! previous CCM-based construction did not have (CCM's B0 formatting block ties the nonce into its
34//! CBC-MAC). Passing the nonce as AAD closes it using the construction's own designed mechanism
35//! for authenticating out-of-band data, the same way a caller would bind a header to an AEAD tag.
36//! Caught by `tampered_nonce_is_rejected` during this migration, not assumed - see `docs/DECISIONS.md`
37//! D-63.
38//!
39//! # Provenance
40//!
41//! Inherits `hazmat::kalyna_gcm`'s own provisional status (D-56): not yet confirmed against the
42//! primary DSTU 7624:2014 text, dual-oracle-cited (UAPKI + Bouncy Castle vectors) in the meantime -
43//! unchanged by the CCM-to-GCM migration. `Kalyna256_256Gcm` was chosen over the other four
44//! Kalyna-GCM variants as the sole construction here (256-bit key, matching the previous CCM
45//! construction's key/nonce width exactly) - see D-51 for the fuller reasoning behind fixing one
46//! variant rather than exposing all five, including why the `Strength`-enum precedent from
47//! `crypto_pwhash` does not apply (a Kalyna variant is exactly the knob D-47 says to delete, not a
48//! genuine per-context tradeoff the caller must make). The 16-byte tag (truncated from GCM's own
49//! full 32-byte tag, via the same prefix-comparison convention `hazmat::kalyna_gcm`/`kalyna_gmac`
50//! already support) matches the previous construction's tag length and libsodium's own
51//! `crypto_secretbox` tag size - a fixed choice, not a new knob.
52//!
53//! # Example
54//!
55//! Encrypts a whole in-memory message under a freshly generated key. `seal`/`open` protect both
56//! confidentiality (nobody without the key can read the message) and integrity (`open` rejects
57//! anything tampered with, rather than returning wrong plaintext) - see below for the "tampered
58//! ciphertext is rejected" case, `docs/TASKS.md` T-120's own required failure-path example.
59//!
60//! ```rust
61//! use dstu_core::crypto_secretbox::{seal, open, SecretKey};
62//!
63//! let key = SecretKey::generate().expect("OS CSPRNG should not fail");
64//! let sealed = seal(&key, b"message").expect("OS CSPRNG should not fail");
65//! let opened = open(&key, &sealed).expect("authentic ciphertext");
66//! assert_eq!(opened, b"message");
67//!
68//! // Tampering with the sealed blob (ciphertext, tag, or nonce) is detected, not silently
69//! // "decrypted" into wrong plaintext.
70//! let mut tampered = sealed.clone();
71//! let last = tampered.len() - 1;
72//! tampered[last] ^= 1;
73//! assert!(open(&key, &tampered).is_err());
74//! ```
75
76use crate::hazmat::kalyna_gcm::{GcmError, Kalyna256_256Gcm};
77use crate::randombytes::{randombytes_buf, RandomError};
78use core::fmt;
79use zeroize::Zeroize;
80
81const NONCE_LEN: usize = 32;
82const TAG_LEN: usize = 16;
83
84/// `crypto_secretbox` can fail for reasons beyond a wrong key.
85#[derive(Debug)]
86pub enum SecretboxError {
87 /// The input to [`open`] is shorter than a nonce plus a tag (48 bytes) - too short to have
88 /// ever been produced by [`seal`].
89 Truncated,
90 /// Authentication failed: wrong key, or `sealed` was tampered with.
91 TagMismatch,
92 /// The OS CSPRNG failed while generating a nonce (see [`crate::randombytes`]).
93 Random(RandomError),
94}
95
96impl fmt::Display for SecretboxError {
97 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
98 match self {
99 SecretboxError::Truncated => write!(f, "input too short to contain a nonce and tag"),
100 SecretboxError::TagMismatch => write!(f, "authentication failed"),
101 SecretboxError::Random(e) => write!(f, "{e}"),
102 }
103 }
104}
105
106impl core::error::Error for SecretboxError {}
107
108impl From<RandomError> for SecretboxError {
109 fn from(e: RandomError) -> Self {
110 SecretboxError::Random(e)
111 }
112}
113
114/// A `crypto_secretbox` key. Always exactly 32 bytes - `Kalyna256_256Ccm`'s key length, this
115/// module's one fixed construction (see the module doc).
116pub struct SecretKey([u8; 32]);
117
118impl Drop for SecretKey {
119 fn drop(&mut self) {
120 self.0.zeroize();
121 }
122}
123
124impl SecretKey {
125 /// Generates a fresh key from the OS CSPRNG - libsodium's `crypto_secretbox_keygen`
126 /// equivalent, so "how do I make a key" is never a caller decision.
127 ///
128 /// # Errors
129 ///
130 /// Returns [`SecretboxError::Random`] if the OS CSPRNG fails.
131 pub fn generate() -> Result<Self, SecretboxError> {
132 let mut bytes = [0u8; 32];
133 randombytes_buf(&mut bytes)?;
134 Ok(SecretKey(bytes))
135 }
136
137 #[must_use]
138 pub fn from_bytes(bytes: [u8; 32]) -> Self {
139 SecretKey(bytes)
140 }
141
142 #[must_use]
143 pub fn as_bytes(&self) -> &[u8; 32] {
144 &self.0
145 }
146}
147
148/// Encrypts and authenticates `plaintext` under `key`, drawing a fresh random nonce internally.
149/// Returns `nonce (32 bytes) || ciphertext (plaintext.len() bytes) || tag (16 bytes)` - no
150/// message-length cap (see the module doc comment).
151///
152/// # Errors
153///
154/// Returns [`SecretboxError::Random`] if the OS CSPRNG fails - the only way this can fail.
155pub fn seal(key: &SecretKey, plaintext: &[u8]) -> Result<Vec<u8>, SecretboxError> {
156 let mut nonce = [0u8; NONCE_LEN];
157 randombytes_buf(&mut nonce)?;
158
159 let cipher = Kalyna256_256Gcm::new(key.as_bytes());
160 let mut buf = vec![0u8; plaintext.len()];
161 // Nonce passed as AAD to bind it into the tag - see the module doc's "No AAD" section.
162 let Ok(full_tag) = cipher.encrypt(&nonce, &nonce, plaintext, &mut buf) else {
163 unreachable!("ciphertext_out.len() == plaintext.len() by construction")
164 };
165
166 let mut out = Vec::with_capacity(NONCE_LEN + buf.len() + TAG_LEN);
167 out.extend_from_slice(&nonce);
168 out.extend_from_slice(&buf);
169 out.extend_from_slice(&full_tag[..TAG_LEN]);
170 Ok(out)
171}
172
173/// Verifies and decrypts `sealed` (as produced by [`seal`]) under `key`.
174///
175/// # Errors
176///
177/// Returns [`SecretboxError::Truncated`] if `sealed` is shorter than a nonce plus a tag, or
178/// [`SecretboxError::TagMismatch`] if authentication fails (wrong key, or `sealed` was tampered
179/// with) - `sealed` is never partially trusted on a mismatch.
180pub fn open(key: &SecretKey, sealed: &[u8]) -> Result<Vec<u8>, SecretboxError> {
181 if sealed.len() < NONCE_LEN + TAG_LEN {
182 return Err(SecretboxError::Truncated);
183 }
184
185 let mut nonce = [0u8; NONCE_LEN];
186 nonce.copy_from_slice(&sealed[..NONCE_LEN]);
187 let ciphertext_len = sealed.len() - NONCE_LEN - TAG_LEN;
188 let ciphertext = &sealed[NONCE_LEN..NONCE_LEN + ciphertext_len];
189 let tag = &sealed[NONCE_LEN + ciphertext_len..];
190
191 let cipher = Kalyna256_256Gcm::new(key.as_bytes());
192 let mut buf = vec![0u8; ciphertext_len];
193 // Nonce passed as AAD to bind it into the tag - see the module doc's "No AAD" section.
194 cipher
195 .decrypt(&nonce, &nonce, ciphertext, tag, &mut buf)
196 .map_err(|e| match e {
197 GcmError::TagMismatch => SecretboxError::TagMismatch,
198 GcmError::InvalidLength => {
199 unreachable!(
200 "tag.len() == TAG_LEN (16, within 8..=block_bytes) and plaintext_out.len() \
201 == ciphertext.len() by construction"
202 )
203 }
204 })?;
205 Ok(buf)
206}