Skip to main content

dstu_core/hazmat/dstu9041/
message512.rs

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