Skip to main content

dstu_core/hazmat/dstu9041/
message.rs

1//! Message formatting for DSTU 9041's `l(p)=256` case (clauses 5.7/5.8/Table 1, 11 steps 2-8, 12
2//! steps 9-18) - see `docs/pseudocode/dstu9041.md` "Message formatting" for the clause citations
3//! and `crates/dstu-core/tests/vectors/dstu9041/g1-worked-example.json` for the worked example this
4//! module is verified against (`crates/dstu-core/tests/dstu9041_message.rs`).
5//!
6//! Byte layout for `l(p)=256`: `M~` is 25 bytes (200 bits, `L_MAX_P`), left-padded with zeros so
7//! the caller's own message occupies the low-order tail; `l_M~` is a 2-byte big-endian bit-length
8//! field; `M'` is `i_H(1) || H(l_M~||M~) truncated to l_H=32 bits, LOW-order end (4) || l_M~(2) ||
9//! M~(25)` = 32 bytes exactly. The Kalyna-KW input is `M' || 0x00×32` (64 bytes) - an empirical
10//! fact confirmed against this crate's own `hazmat::kalyna_kw`, not yet explained from a cited
11//! clause (`docs/pseudocode/dstu9041.md`'s "Open question", D-165).
12
13use crate::hazmat::kupyna::Kupyna256;
14use subtle::ConstantTimeEq;
15
16/// Maximum encryptable message length in bits for `l(p)=256` (Table 1).
17pub const L_MAX_P: usize = 200;
18/// `M~`'s fixed byte length (`L_MAX_P` bits, whole bytes).
19const M_TILDE_BYTES: usize = L_MAX_P / 8;
20/// Truncated hash length in bits (Table 1, `l(p)=256` row).
21const L_H_BYTES: usize = 4;
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum MessageError {
25    /// `message_bits == 0` (clause 11 step 2's `0 < l(M)` requirement).
26    ZeroLength,
27    /// `message_bits > L_MAX_P`.
28    MessageTooLong,
29    /// `message`'s byte length didn't match `message_bits.div_ceil(8)` exactly.
30    LengthMismatch,
31    /// Recomputed hash didn't match the extracted `H'` field (clause 12 step 16).
32    HashMismatch,
33    /// The zero-padding above `l(M)` bits in the recovered `M~` wasn't all-zero (clause 12 step 17).
34    PaddingNotZero,
35}
36
37/// The recovered fields from a successfully parsed `M'` (clause 12 steps 9-17).
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct Message {
40    pub hash_id: u8,
41    pub bit_length: usize,
42    pub m_tilde: [u8; M_TILDE_BYTES],
43}
44
45/// Clause 11 steps 3-4: left-pad `message` (exactly `message_bits.div_ceil(8)` bytes, big-endian)
46/// with zeros to `L_MAX_P` bits.
47///
48/// # Errors
49///
50/// See [`MessageError`]'s variants - a zero/oversized `message_bits`, or a `message` slice whose
51/// length doesn't match `message_bits.div_ceil(8)` exactly, is rejected rather than panicking.
52pub fn format_m_tilde(
53    message: &[u8],
54    message_bits: usize,
55) -> Result<[u8; M_TILDE_BYTES], MessageError> {
56    if message_bits == 0 {
57        return Err(MessageError::ZeroLength);
58    }
59    if message_bits > L_MAX_P {
60        return Err(MessageError::MessageTooLong);
61    }
62    let message_bytes = message_bits.div_ceil(8);
63    if message.len() != message_bytes {
64        return Err(MessageError::LengthMismatch);
65    }
66    let mut m_tilde = [0u8; M_TILDE_BYTES];
67    m_tilde[M_TILDE_BYTES - message_bytes..].copy_from_slice(message);
68    Ok(m_tilde)
69}
70
71/// Clause 11 steps 5-6: `l(M)` as a fixed 16-bit big-endian field.
72#[must_use]
73pub fn encode_l_m_tilde(message_bits: usize) -> [u8; 2] {
74    #[allow(clippy::cast_possible_truncation)] // message_bits <= L_MAX_P = 200, fits u16 trivially
75    (message_bits as u16).to_be_bytes()
76}
77
78/// Clause 11 steps 7-8: `M' = i_H || H(l_M~||M~) truncated to l_H bits (low-order end) || l_M~ ||
79/// M~`.
80#[must_use]
81pub fn build_m_prime(hash_id: u8, m_tilde: &[u8; M_TILDE_BYTES], l_m_tilde: &[u8; 2]) -> [u8; 32] {
82    let mut hashed_input = [0u8; 2 + M_TILDE_BYTES];
83    hashed_input[..2].copy_from_slice(l_m_tilde);
84    hashed_input[2..].copy_from_slice(m_tilde);
85    let digest = Kupyna256::digest(&hashed_input);
86
87    let mut m_prime = [0u8; 32];
88    m_prime[0] = hash_id;
89    m_prime[1..=L_H_BYTES].copy_from_slice(&digest[digest.len() - L_H_BYTES..]);
90    m_prime[1 + L_H_BYTES..3 + L_H_BYTES].copy_from_slice(l_m_tilde);
91    m_prime[3 + L_H_BYTES..].copy_from_slice(m_tilde);
92    m_prime
93}
94
95/// The empirical Kalyna-KW input quirk: `M'` padded with one additional all-zero 256-bit block -
96/// `docs/pseudocode/dstu9041.md`'s "Open question" (D-165): confirmed necessary to reproduce the
97/// standard's own worked ciphertext, not yet explained from a cited clause.
98#[must_use]
99pub fn kw_plaintext_from_m_prime(m_prime: &[u8; 32]) -> [u8; 64] {
100    let mut out = [0u8; 64];
101    out[..32].copy_from_slice(m_prime);
102    out
103}
104
105/// Inverse of [`build_m_prime`] (clause 12 steps 9-17): re-derives `hash_id`/`bit_length`/`m_tilde`
106/// from `M'`, verifying the embedded hash and the zero-padding invariant.
107///
108/// # Errors
109///
110/// See [`MessageError`]'s variants - a malformed or tampered `m_prime` is rejected, never panics.
111pub fn parse_m_prime(m_prime: &[u8; 32]) -> Result<Message, MessageError> {
112    let hash_id = m_prime[0];
113    let embedded_hash = &m_prime[1..=L_H_BYTES];
114    let mut l_m_tilde = [0u8; 2];
115    l_m_tilde.copy_from_slice(&m_prime[1 + L_H_BYTES..3 + L_H_BYTES]);
116    let mut m_tilde = [0u8; M_TILDE_BYTES];
117    m_tilde.copy_from_slice(&m_prime[3 + L_H_BYTES..]);
118
119    let bit_length = usize::from(u16::from_be_bytes(l_m_tilde));
120    if bit_length == 0 {
121        return Err(MessageError::ZeroLength);
122    }
123    if bit_length > L_MAX_P {
124        return Err(MessageError::MessageTooLong);
125    }
126
127    let mut hashed_input = [0u8; 2 + M_TILDE_BYTES];
128    hashed_input[..2].copy_from_slice(&l_m_tilde);
129    hashed_input[2..].copy_from_slice(&m_tilde);
130    let digest = Kupyna256::digest(&hashed_input);
131    // Constant-time: this compares secret-key-adjacent (KW-unwrapped, hence caller-secret-derived
132    // in the `decrypt` call path) data - `!=` on slices is not a documented constant-time
133    // primitive (`docs/SECURITY.md`'s standing rule).
134    let hash_ok: bool = digest[digest.len() - L_H_BYTES..]
135        .ct_eq(embedded_hash)
136        .into();
137    if !hash_ok {
138        return Err(MessageError::HashMismatch);
139    }
140
141    // Constant-time and fixed-iteration: `message_bytes` (hence which bytes count as "padding")
142    // is itself derived from `bit_length`, decrypted data an attacker can influence - iterating
143    // the full M_TILDE_BYTES buffer every time (rather than a `bit_length`-sized slice) keeps the
144    // number of comparisons independent of that value, not just each individual comparison.
145    let message_bytes = bit_length.div_ceil(8);
146    let padding_len = M_TILDE_BYTES - message_bytes;
147    let mut bad_padding = 0u8;
148    for (i, &byte) in m_tilde.iter().enumerate() {
149        let is_padding_position = u8::from(i < padding_len);
150        bad_padding |= is_padding_position & u8::from(byte != 0);
151    }
152    if bad_padding != 0 {
153        return Err(MessageError::PaddingNotZero);
154    }
155
156    Ok(Message {
157        hash_id,
158        bit_length,
159        m_tilde,
160    })
161}