Skip to main content

dstu_core/hazmat/dstu9041/
encryption.rs

1//! Encrypt/decrypt composition for DSTU 9041's `l(p)=256` case (clauses 11/12 - see
2//! `docs/pseudocode/dstu9041.md`). `l(p)=256` only (E256/1) - see this module's parent's own doc
3//! comment for the scope citation.
4//!
5//! `DecryptError` is deliberately collapsed to one variant (`InvalidCiphertext`): clause 12's
6//! late-stage checks (hash mismatch, padding-not-zero, KW checksum mismatch) all depend on
7//! `κ=x_T'`, itself derived from the caller's secret `e` - returning distinguishable
8//! errors/timing here is a padding-oracle shape (Manger/Vaudenay-style), squarely in
9//! `docs/SECURITY.md`'s threat model. Documented as a deliberate safe deviation from clause 12's
10//! literal per-step error naming, same category as D-56/D-63's AEAD-binding fixes.
11//!
12//! **Security fix beyond clause 12's literal text**: step 2 rejects `r=0`, `r=1`,
13//! `r^2=a*d^-1 mod p` - but not `r=p-1`, which reconstructs to `R'=(p-1,0)`, a genuine order-2
14//! point OUTSIDE the base point's subgroup `<P>` (proved arithmetically in
15//! `tests/dstu9041_curve.rs`'s `r_equals_p_minus_1_reconstructs_the_order_2_point`). Left
16//! unrejected, a chosen-ciphertext query with `r=p-1` would leak the private key's parity bit via
17//! whether `T'=e*R'` lands on `R'` (e odd) or `NEUTRAL` (e even). Added here as a fourth
18//! rejection case, right next to `r=1`.
19//!
20//! `decrypt` additionally verifies `R'` is actually in `<P>` (`n*R' == NEUTRAL`) before computing
21//! `T'` - **not optional hardening**. `#E(F_p) = 4n` (the unique multiple of `2n` inside the Hasse
22//! interval - confirmed by checking every `k` up to 20, only `k=2` lands `2n*k` in
23//! `[p+1-2*sqrt(p), p+1+2*sqrt(p)]`). The curve equation's only `y=0` solutions are `x^2=1`, i.e.
24//! `x in {1, p-1}` - exactly `NEUTRAL` and the order-2 point, no third one. A finite abelian group
25//! of order `4n` (`n` odd prime) has its 2-Sylow subgroup either cyclic (`Z/4`, one non-trivial
26//! order-2 element) or Klein four (`Z/2 x Z/2`, three) - since there is provably only one, the
27//! 2-Sylow subgroup is `Z/4`, making `E(F_p)` cyclic of order `4n` overall. **A cyclic group of
28//! order `4n` has genuine order-4 elements** - so an `r` reconstructing to an order-4 point is a
29//! real, reachable ciphertext, not a hypothetical: it would leak `e mod 4` (not just `e`'s parity)
30//! through which of the 3 distinguishable `kappa` values (`x` of `NEUTRAL`/the order-2
31//! point/the order-4 point pair, the latter two sharing an `x` by `x_T=x_{-T}`) `T'=e*R'` lands
32//! on. The subgroup check closes this generally, independent of locating a concrete order-4 point
33//! by coordinates.
34//!
35//! Step 4 (`if !v.euler_criterion()`) is stricter than clause 12's literal `if δ==p-1`: it also
36//! rejects `δ==0` (i.e. `v==0`), which is exactly the `r=p-1` case. The explicit `r=p-1` check in
37//! step 2 is therefore not the sole enforcement for that case - kept as an early, explicit,
38//! self-documenting rejection rather than relying on step 4's incidental stricter form to carry
39//! the argument.
40
41use super::curve256::{base_point, is_valid_scalar, point_from_x, Point};
42use super::fp256::from_candidate_bytes;
43use super::message::{
44    build_m_prime, encode_l_m_tilde, format_m_tilde, kw_plaintext_from_m_prime, parse_m_prime,
45};
46use crate::hazmat::kalyna_kw::Kalyna256_256Kw;
47
48/// Hash function identifier for Kupyna-256 (clause 5.7's `i_H` registry - only one value is
49/// wired up by this module, matching the worked example's own `hash_function_id_hex: "01"`).
50const HASH_ID_KUPYNA256: u8 = 0x01;
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum EncryptError {
54    /// `message_bits` is `0`, exceeds `L_MAX_P`, or `message`'s length doesn't match it.
55    InvalidMessage,
56    /// `epsilon` is outside the valid scalar range `{2, ..., n-2}`.
57    InvalidEphemeralKey,
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum DecryptError {
62    /// Any late-stage failure (bad `r`, KW checksum mismatch, hash mismatch, bad padding) or an
63    /// invalid `e` - deliberately collapsed, see this module's own doc comment.
64    InvalidCiphertext,
65}
66
67/// Clause 11, steps 2-15. `ciphertext_C` is `r || t` (32 + 96 bytes, 128 total for `l(p)=256`).
68///
69/// # Errors
70///
71/// See [`EncryptError`]'s variants.
72pub fn encrypt(
73    message: &[u8],
74    message_bits: usize,
75    q: Point,
76    epsilon: &[u8; 32],
77) -> Result<[u8; 128], EncryptError> {
78    if !is_valid_scalar(epsilon) {
79        return Err(EncryptError::InvalidEphemeralKey);
80    }
81    let m_tilde =
82        format_m_tilde(message, message_bits).map_err(|_| EncryptError::InvalidMessage)?;
83    let l_m_tilde = encode_l_m_tilde(message_bits);
84    let m_prime = build_m_prime(HASH_ID_KUPYNA256, &m_tilde, &l_m_tilde);
85
86    let r_point = base_point().scalar_multiply(epsilon);
87    let r_bytes = r_point.x.to_be_bytes();
88
89    let t_point = q.scalar_multiply(epsilon);
90    let kappa = t_point.x.to_be_bytes();
91
92    let kw_plaintext = kw_plaintext_from_m_prime(&m_prime);
93    let mut t = [0u8; 96];
94    // Unreachable in practice: `kw_plaintext` is always exactly 64 bytes (2 Kalyna blocks) by
95    // construction, and `t` is always exactly 96 bytes - the only failure `wrap` can report
96    // (`KwError::InvalidLength`) requires a length mismatch this function's own fixed-size arrays
97    // can't produce. Handled instead of `.expect()`-ing (this crate denies `unwrap`/`expect` in
98    // library code) so this stays provably panic-free rather than "correct by inspection only."
99    Kalyna256_256Kw::wrap(&kappa, &kw_plaintext, &mut t)
100        .map_err(|_| EncryptError::InvalidMessage)?;
101
102    let mut ciphertext = [0u8; 128];
103    ciphertext[..32].copy_from_slice(&r_bytes);
104    ciphertext[32..].copy_from_slice(&t);
105    Ok(ciphertext)
106}
107
108/// Clause 12, steps 1-19. Returns the recovered `M~` (25 bytes, left-padded - caller slices the
109/// low-order `l(M)` bits out using the returned bit length) and `l(M)`. Takes no public key: `T' =
110/// e*R'` is reconstructed entirely from the private key and the ciphertext's own `r` (`R = eps*P`,
111/// `T = eps*Q = eps*(e*P) = e*(eps*P) = e*R` - the whole point of the construction is that `Q`
112/// never needs to appear on the decrypt side).
113///
114/// # Errors
115///
116/// Returns [`DecryptError::InvalidCiphertext`] for any tampered ciphertext, invalid `e`, or
117/// malformed `r` - deliberately not distinguished, see this module's own doc comment.
118pub fn decrypt(ciphertext: &[u8; 128], e: &[u8; 32]) -> Result<([u8; 25], usize), DecryptError> {
119    if !is_valid_scalar(e) {
120        return Err(DecryptError::InvalidCiphertext);
121    }
122
123    let mut r_bytes = [0u8; 32];
124    r_bytes.copy_from_slice(&ciphertext[..32]);
125    let r_field = from_candidate_bytes(&r_bytes).ok_or(DecryptError::InvalidCiphertext)?;
126
127    // Steps 2-6 (reject r in {0,1,p-1}, reject r^2=a*d^-1, compute v and reject non-residues,
128    // y=sqrt(v)) plus the subgroup check beyond clause 12's literal text are all
129    // `curve256::point_from_x` now - see that function's own doc comment, shared with
130    // `crate::crypto_box::PublicKey::from_bytes` rather than duplicated here.
131    let r_prime = point_from_x(r_field).ok_or(DecryptError::InvalidCiphertext)?;
132
133    // Steps 7-8: T' = e*R'; kappa = x_T'.
134    let t_prime = r_prime.scalar_multiply(e);
135    let kappa = t_prime.x.to_be_bytes();
136
137    // Step 9: unwrap. kalyna_kw's own checksum (Deviation 2, docs/DECISIONS.md D-55) already
138    // constant-time-compares its trailing block.
139    let mut recovered = [0u8; 64];
140    Kalyna256_256Kw::unwrap(&kappa, &ciphertext[32..], &mut recovered)
141        .map_err(|_| DecryptError::InvalidCiphertext)?;
142
143    // The empirical "M' || 0x00*32" quirk, mirrored on decrypt: verify the appended block came
144    // back all-zero, in fixed-iteration constant time (mirrors message.rs's own padding check -
145    // `recovered` is kappa-derived, hence caller-secret-adjacent).
146    let mut appended_block_bad = 0u8;
147    for &byte in &recovered[32..] {
148        appended_block_bad |= u8::from(byte != 0);
149    }
150    if appended_block_bad != 0 {
151        return Err(DecryptError::InvalidCiphertext);
152    }
153
154    let mut m_prime = [0u8; 32];
155    m_prime.copy_from_slice(&recovered[..32]);
156
157    // Steps 10-17: parsed internally (hash + zero-padding checks, both constant-time - see
158    // message.rs's own doc comment on why).
159    let parsed = parse_m_prime(&m_prime).map_err(|_| DecryptError::InvalidCiphertext)?;
160
161    Ok((parsed.m_tilde, parsed.bit_length))
162}