Skip to main content

dstu_core/
selftest.rs

1//! Runtime known-answer self-test (`docs/TASKS.md` T-161, `docs/DECISIONS.md` D-117): re-runs one
2//! official vector per primitive against the *live compiled* implementation, so a caller can verify
3//! their exact installed build produces correct output on their exact platform before trusting it
4//! with real data - the same "don't just trust it compiled" instinct this project already applies
5//! to itself via dual-oracle verification (`docs/SECURITY.md`, "Crypto engineering hard
6//! constraints"). This is a small, fast, embedded-in-the-binary spot check - one vector per
7//! primitive, not the full corpus `cargo test` already runs against `tests/vectors/`; it is not a
8//! substitute for that suite.
9//!
10//! Built once here; every language binding wraps this one function with an idiomatically-named
11//! thin wrapper (`dstu_core.selftest()` in Python, `selfTest()` in Node/Java/.NET, `dstu_selftest()`
12//! in the C ABI) rather than reimplementing its own KAT check per language - see D-117, following
13//! the precedent of `hazmat::tables`' shared S-box/MDS data being built once and reused rather than
14//! duplicated per algorithm (`docs/DECISIONS.md` D-10).
15//!
16//! Requires the `selftest` Cargo feature (which requires `std`, D-48's precedent): vector text is
17//! embedded via `include_str!` and parsed at runtime with a small hand-rolled string scanner (no
18//! `serde` dependency, matching every other test-vector reader in this crate's `tests/` suite),
19//! which needs `String`/`Vec`. Off by default in the bare crate; every binding's own `Cargo.toml`
20//! turns it on.
21
22use crate::hazmat::dstu4145::curve163::Point;
23use crate::hazmat::dstu4145::gf2m163::FieldElement;
24use crate::hazmat::dstu4145::signature::verify as dstu4145_verify;
25use crate::hazmat::kalyna::Kalyna128_128;
26use crate::hazmat::kupyna::Kupyna256;
27use crate::hazmat::strumok::Strumok256;
28use std::fmt;
29
30/// A primitive [`run`] checks.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum Primitive {
33    Kalyna,
34    Kupyna,
35    Strumok,
36    Dstu4145,
37}
38
39impl fmt::Display for Primitive {
40    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
41        f.write_str(match self {
42            Primitive::Kalyna => "Kalyna",
43            Primitive::Kupyna => "Kupyna",
44            Primitive::Strumok => "Strumok",
45            Primitive::Dstu4145 => "DSTU 4145",
46        })
47    }
48}
49
50/// Why a single primitive's check failed.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum FailureKind {
53    /// The live implementation's output did not match the embedded official vector - a real
54    /// correctness failure in this build.
55    Mismatch,
56    /// The vector text embedded in this binary at compile time could not be parsed. This is an
57    /// integrity bug in this crate's own release, not something a caller did - the embedded text is
58    /// a fixed file under this crate's own `tests/vectors/`, already read the same way by
59    /// `cargo test` every CI run, so this should never fire in a real build.
60    MalformedEmbeddedVector,
61}
62
63impl fmt::Display for FailureKind {
64    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
65        f.write_str(match self {
66            FailureKind::Mismatch => "output did not match the official vector",
67            FailureKind::MalformedEmbeddedVector => "embedded vector data could not be parsed",
68        })
69    }
70}
71
72/// One primitive's self-test failure.
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub struct Failure {
75    pub primitive: Primitive,
76    pub kind: FailureKind,
77}
78
79impl fmt::Display for Failure {
80    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81        write!(f, "{}: {}", self.primitive, self.kind)
82    }
83}
84
85impl std::error::Error for Failure {}
86
87/// Every primitive that failed [`run`]'s check. Never constructed empty - [`run`] returns `Ok(())`
88/// instead when nothing failed.
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub struct Report {
91    pub failures: Vec<Failure>,
92}
93
94impl fmt::Display for Report {
95    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
96        write!(f, "dstu_core self-test failed:")?;
97        for failure in &self.failures {
98            write!(f, " [{failure}]")?;
99        }
100        Ok(())
101    }
102}
103
104impl std::error::Error for Report {}
105
106/// Re-runs one official test vector per primitive (Kalyna, Kupyna, Strumok, DSTU 4145) against the
107/// live compiled implementation.
108///
109/// # Errors
110///
111/// Returns a [`Report`] naming every primitive whose live output didn't match its embedded vector.
112/// A non-empty [`Report`] means this exact compiled binary is producing wrong cryptographic output
113/// and must not be trusted with real data.
114pub fn run() -> Result<(), Report> {
115    type Check = (Primitive, fn() -> Result<(), FailureKind>);
116    let checks: [Check; 4] = [
117        (Primitive::Kalyna, check_kalyna),
118        (Primitive::Kupyna, check_kupyna),
119        (Primitive::Strumok, check_strumok),
120        (Primitive::Dstu4145, check_dstu4145),
121    ];
122
123    let failures: Vec<Failure> = checks
124        .into_iter()
125        .filter_map(|(primitive, check)| check().err().map(|kind| Failure { primitive, kind }))
126        .collect();
127
128    if failures.is_empty() {
129        Ok(())
130    } else {
131        Err(Report { failures })
132    }
133}
134
135fn check_equal(actual: &[u8], expected: &[u8]) -> Result<(), FailureKind> {
136    if actual == expected {
137        Ok(())
138    } else {
139        Err(FailureKind::Mismatch)
140    }
141}
142
143/// Finds the first `"key": "..."` occurrence at or after byte offset `start`, returning its value
144/// and the byte offset just past the closing quote (so callers can chain calls to find a *later*
145/// occurrence of a key that appears more than once in the same document, e.g. `gf2m163.json`'s
146/// `base_point.x`/`public_key_q.x` both using the bare key `"x"`).
147fn find_str_value<'a>(json: &'a str, key: &str, start: usize) -> Option<(&'a str, usize)> {
148    let pattern = std::format!("\"{key}\": \"");
149    let haystack = json.get(start..)?;
150    let rel_start = haystack.find(pattern.as_str())?;
151    let after = haystack.get(rel_start + pattern.len()..)?;
152    let end = after.find('"')?;
153    let value = after.get(..end)?;
154    let abs_end = start + rel_start + pattern.len() + end + 1;
155    Some((value, abs_end))
156}
157
158/// Decodes hex into bytes. An odd-length input is treated as missing a leading zero nibble (not
159/// rejected) - the same convention `tests/dstu4145_signature.rs`'s own `decode_hex` helper uses,
160/// needed because DSTU 4145's GF(2^163) field elements/scalars are sometimes printed one hex digit
161/// short of a full byte (the standard's worked example trims the leading zero nibble).
162fn decode_hex(hex: &str) -> Option<Vec<u8>> {
163    let owned;
164    let hex = if hex.len().is_multiple_of(2) {
165        hex
166    } else {
167        owned = std::format!("0{hex}");
168        &owned
169    };
170    let mut out = Vec::with_capacity(hex.len() / 2);
171    let mut i = 0;
172    while i < hex.len() {
173        out.push(u8::from_str_radix(hex.get(i..i + 2)?, 16).ok()?);
174        i += 2;
175    }
176    Some(out)
177}
178
179fn decode_hex_fixed<const N: usize>(hex: &str) -> Option<[u8; N]> {
180    let bytes = decode_hex(hex)?;
181    if bytes.len() != N {
182        return None;
183    }
184    let mut out = [0u8; N];
185    out.copy_from_slice(&bytes);
186    Some(out)
187}
188
189/// Left-zero-pads a decoded hex value into a fixed-size array - for DSTU 4145's `r`/`s`, whose
190/// decoded byte length can be exactly `N` or one short of it depending on the leading nibble (see
191/// [`decode_hex`]).
192fn decode_hex_padded<const N: usize>(hex: &str) -> Option<[u8; N]> {
193    let bytes = decode_hex(hex)?;
194    if bytes.len() > N {
195        return None;
196    }
197    let mut out = [0u8; N];
198    out[N - bytes.len()..].copy_from_slice(&bytes);
199    Some(out)
200}
201
202fn check_kalyna() -> Result<(), FailureKind> {
203    const JSON: &str = include_str!("../tests/vectors/kalyna/128-128.json");
204    let (key_hex, at) =
205        find_str_value(JSON, "key_hex", 0).ok_or(FailureKind::MalformedEmbeddedVector)?;
206    let (pt_hex, at) =
207        find_str_value(JSON, "plaintext_hex", at).ok_or(FailureKind::MalformedEmbeddedVector)?;
208    let (ct_hex, _) =
209        find_str_value(JSON, "ciphertext_hex", at).ok_or(FailureKind::MalformedEmbeddedVector)?;
210
211    let key: [u8; 16] = decode_hex_fixed(key_hex).ok_or(FailureKind::MalformedEmbeddedVector)?;
212    let plaintext: [u8; 16] =
213        decode_hex_fixed(pt_hex).ok_or(FailureKind::MalformedEmbeddedVector)?;
214    let ciphertext: [u8; 16] =
215        decode_hex_fixed(ct_hex).ok_or(FailureKind::MalformedEmbeddedVector)?;
216
217    check_equal(&Kalyna128_128::encrypt(&key, &plaintext), &ciphertext)?;
218    check_equal(&Kalyna128_128::decrypt(&key, &ciphertext), &plaintext)
219}
220
221fn check_kupyna() -> Result<(), FailureKind> {
222    const JSON: &str = include_str!("../tests/vectors/kupyna/kupyna-256.json");
223    let (msg_hex, at) =
224        find_str_value(JSON, "message_hex", 0).ok_or(FailureKind::MalformedEmbeddedVector)?;
225    let (hash_hex, _) =
226        find_str_value(JSON, "hash_hex", at).ok_or(FailureKind::MalformedEmbeddedVector)?;
227
228    let message = decode_hex(msg_hex).ok_or(FailureKind::MalformedEmbeddedVector)?;
229    let expected: [u8; 32] =
230        decode_hex_fixed(hash_hex).ok_or(FailureKind::MalformedEmbeddedVector)?;
231
232    check_equal(&Kupyna256::digest(&message), &expected)
233}
234
235fn check_strumok() -> Result<(), FailureKind> {
236    const JSON: &str = include_str!("../tests/vectors/strumok/keystream-256.json");
237    let (key_hex, at) =
238        find_str_value(JSON, "key_hex", 0).ok_or(FailureKind::MalformedEmbeddedVector)?;
239    let (iv_hex, at) =
240        find_str_value(JSON, "iv_hex", at).ok_or(FailureKind::MalformedEmbeddedVector)?;
241    let (ks_hex, _) =
242        find_str_value(JSON, "keystream_hex", at).ok_or(FailureKind::MalformedEmbeddedVector)?;
243
244    let key: [u8; 32] = decode_hex_fixed(key_hex).ok_or(FailureKind::MalformedEmbeddedVector)?;
245    let iv: [u8; 32] = decode_hex_fixed(iv_hex).ok_or(FailureKind::MalformedEmbeddedVector)?;
246    let expected = decode_hex(ks_hex).ok_or(FailureKind::MalformedEmbeddedVector)?;
247
248    let mut actual = std::vec![0u8; expected.len()];
249    Strumok256::new(&key, &iv).apply_keystream(&mut actual);
250
251    check_equal(&actual, &expected)
252}
253
254// `qx_hex`/`qy_hex` (and `qx`/`qy` below) trip `clippy::similar_names` - the same coordinate-pair
255// naming `tests/dstu4145_signature.rs` already uses (`qx`/`qy`), a heuristic quirk not a real
256// readability problem, same class of documented `#[allow]` CLAUDE.md's agent-discipline notes
257// already record for `needless_range_loop`.
258#[allow(clippy::similar_names)]
259fn check_dstu4145() -> Result<(), FailureKind> {
260    const JSON: &str = include_str!("../tests/vectors/dstu4145/gf2m163.json");
261    // "x"/"y" appear twice: base_point.{x,y} first, then public_key_q.{x,y} - skip the first pair.
262    let (_bp_x, at) = find_str_value(JSON, "x", 0).ok_or(FailureKind::MalformedEmbeddedVector)?;
263    let (_bp_y, at) = find_str_value(JSON, "y", at).ok_or(FailureKind::MalformedEmbeddedVector)?;
264    let (qx_hex, at) = find_str_value(JSON, "x", at).ok_or(FailureKind::MalformedEmbeddedVector)?;
265    let (qy_hex, at) = find_str_value(JSON, "y", at).ok_or(FailureKind::MalformedEmbeddedVector)?;
266    let (hash_hex, at) =
267        find_str_value(JSON, "hash_h_of_t", at).ok_or(FailureKind::MalformedEmbeddedVector)?;
268    let (r_hex, at) = find_str_value(JSON, "r", at).ok_or(FailureKind::MalformedEmbeddedVector)?;
269    let (s_hex, _) = find_str_value(JSON, "s", at).ok_or(FailureKind::MalformedEmbeddedVector)?;
270
271    let qx = decode_hex(qx_hex).ok_or(FailureKind::MalformedEmbeddedVector)?;
272    let qy = decode_hex(qy_hex).ok_or(FailureKind::MalformedEmbeddedVector)?;
273    let hash = decode_hex(hash_hex).ok_or(FailureKind::MalformedEmbeddedVector)?;
274    let r: [u8; 21] = decode_hex_padded(r_hex).ok_or(FailureKind::MalformedEmbeddedVector)?;
275    let s: [u8; 21] = decode_hex_padded(s_hex).ok_or(FailureKind::MalformedEmbeddedVector)?;
276
277    let q = Point::Affine(
278        FieldElement::from_be_bytes(&qx),
279        FieldElement::from_be_bytes(&qy),
280    );
281    let g = Point::generator();
282
283    if dstu4145_verify(&hash, &r, &s, q, g) {
284        Ok(())
285    } else {
286        Err(FailureKind::Mismatch)
287    }
288}
289
290#[cfg(test)]
291mod tests {
292    use super::{
293        check_equal, decode_hex, decode_hex_fixed, decode_hex_padded, find_str_value, Failure,
294        FailureKind, Primitive, Report,
295    };
296
297    #[test]
298    fn check_equal_detects_a_real_mismatch() {
299        assert_eq!(check_equal(b"abc", b"abc"), Ok(()));
300        assert_eq!(check_equal(b"abc", b"abd"), Err(FailureKind::Mismatch));
301    }
302
303    #[test]
304    fn find_str_value_locates_a_key_and_reports_none_when_absent() {
305        let json = r#"{"foo": "bar", "foo": "baz"}"#;
306        let Some((first, at)) = find_str_value(json, "foo", 0) else {
307            panic!("first occurrence must be found");
308        };
309        assert_eq!(first, "bar");
310        let Some((second, _)) = find_str_value(json, "foo", at) else {
311            panic!("second occurrence must be found");
312        };
313        assert_eq!(second, "baz");
314        assert_eq!(find_str_value(json, "missing", 0), None);
315    }
316
317    #[test]
318    fn decode_hex_pads_odd_length_and_rejects_non_hex_digits() {
319        assert_eq!(decode_hex("00ff"), Some(std::vec![0x00, 0xff]));
320        assert_eq!(
321            decode_hex("f"),
322            Some(std::vec![0x0f]),
323            "odd-length hex must be treated as a missing leading zero nibble, not rejected"
324        );
325        assert_eq!(decode_hex("zz"), None, "non-hex digits must be rejected");
326    }
327
328    #[test]
329    fn decode_hex_fixed_rejects_wrong_length() {
330        assert_eq!(decode_hex_fixed::<2>("00ff"), Some([0x00, 0xff]));
331        assert_eq!(decode_hex_fixed::<3>("00ff"), None);
332    }
333
334    #[test]
335    fn decode_hex_padded_left_pads_a_short_scalar() {
336        assert_eq!(
337            decode_hex_padded::<4>("ff"),
338            Some([0x00, 0x00, 0x00, 0xff]),
339            "a short hex string must be treated as missing leading zero bytes, not misaligned ones"
340        );
341    }
342
343    #[test]
344    fn report_display_lists_every_failed_primitive() {
345        let report = Report {
346            failures: std::vec![
347                Failure {
348                    primitive: Primitive::Kalyna,
349                    kind: FailureKind::Mismatch,
350                },
351                Failure {
352                    primitive: Primitive::Dstu4145,
353                    kind: FailureKind::MalformedEmbeddedVector,
354                },
355            ],
356        };
357        let text = report.to_string();
358        assert!(text.contains("Kalyna"));
359        assert!(text.contains("DSTU 4145"));
360    }
361}