phantom_protocol/crypto/self_tests.rs
1//! Power-on + conditional self-tests for Phantom Protocol's cryptographic
2//! primitives (FIPS 140-3 §7.7).
3//!
4//! FIPS 140-3 requires that **every approved algorithm pass a known-answer
5//! or pairwise-consistency test before it can be used for the first time
6//! after module power-up**. This module exposes [`run_post`] — call it
7//! once at process start (typically from the embedder's bootstrap before
8//! the first [`crate::api::PhantomSession::connect_with_transport`] or
9//! [`crate::api::PhantomListener::bind`]) to satisfy that requirement.
10//! Failure means a primitive returned a wrong answer or refused to
11//! initialize at all; in that case **abort** rather than serve traffic
12//! with a broken cryptographic module.
13//!
14//! On the default (non-FIPS) build the library does *not* auto-invoke
15//! `run_post` — embedders for non-FIPS deployments shouldn't pay the (~ms)
16//! startup cost, so they call it manually if they want it. Under
17//! `--features fips` the bind/connect bootstrap auto-invokes it (once per
18//! process) via [`ensure_post_passed`]; see Security Invariant 11. The
19//! CAVP-style canonical vectors live in `core/tests/cavp.rs`
20//! (Phase 5.4); this module re-tests the same primitives via pairwise
21//! consistency + a fixed HKDF KAT, sufficient for a §7.7 POST without
22//! pulling the full CAVP corpus into the production binary.
23//!
24//! Phase 5.5 (per `docs/PROGRESS.md` / `docs/compliance/fips-readiness.md`).
25
26use crate::crypto::adaptive_crypto::{CipherSuite, CryptoSession};
27use crate::crypto::hybrid_kem::HybridSecretKey;
28use crate::crypto::hybrid_sign::HybridSigningKey;
29use hkdf::Hkdf;
30use sha2::Sha256;
31use std::sync::OnceLock;
32
33/// Process-global cache for [`run_post`]'s result. The fips
34/// bootstrap (`PhantomListener::bind*` / `PhantomSession::connect*`)
35/// calls [`ensure_post_passed`] which lazily runs the POST exactly
36/// once per process and caches the verdict. Subsequent calls return
37/// the cached `Result` without re-running the test battery.
38///
39/// Production only — `cfg(test)` builds re-run on every call so the
40/// `FORCE_POST_FAIL` fault-injection switch is observable. The
41/// `dead_code` suppression covers the test build.
42#[cfg_attr(test, allow(dead_code))]
43static POST_RESULT: OnceLock<Result<(), SelfTestError>> = OnceLock::new();
44
45/// Test-only fault-injection switch. When `true`, [`ensure_post_passed`]
46/// pretends the AEAD self-test failed (regardless of what the actual
47/// POST would have returned) — used by integration tests covering the
48/// `bind`/`connect` reject path. Production builds compile without
49/// this field at all.
50#[cfg(test)]
51static FORCE_POST_FAIL: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
52
53/// Process-global single-shot wrapper around [`run_post`]. The first
54/// call runs the POST and caches the verdict; subsequent calls return
55/// the cached verdict. Designed for the fips bootstrap path
56/// (`PhantomListener::bind*`, `PhantomSession::connect*`) which calls
57/// this before doing any cryptographic work — a failure short-circuits
58/// to `CoreError::FipsSelfTestFailure` instead of standing up
59/// a listener / session over broken primitives.
60///
61/// Production cost: amortised to zero after the first call.
62///
63/// Under `cfg(test)` the POST is **re-run on every call** so a test
64/// can flip `FORCE_POST_FAIL` and observe the new verdict without
65/// having to reset a process-global cache (`OnceLock::take` is
66/// MSRV 1.81 — too new for this crate). The cache is exercised by
67/// production builds, not by tests.
68pub fn ensure_post_passed() -> Result<(), SelfTestError> {
69 #[cfg(test)]
70 {
71 if FORCE_POST_FAIL.load(std::sync::atomic::Ordering::SeqCst) {
72 return Err(SelfTestError::Aead {
73 algorithm: "AES-256-GCM",
74 stage: AeadStage::Decrypt,
75 });
76 }
77 return run_post();
78 }
79 #[cfg(not(test))]
80 {
81 *POST_RESULT.get_or_init(run_post)
82 }
83}
84
85/// Test-only — flip the [`FORCE_POST_FAIL`] switch. Tests that flip
86/// it MUST `set_force_post_fail(false)` again in their teardown to
87/// avoid poisoning sibling tests in the same binary.
88#[cfg(test)]
89pub fn set_force_post_fail(enable: bool) {
90 FORCE_POST_FAIL.store(enable, std::sync::atomic::Ordering::SeqCst);
91}
92
93/// Test-only — shared serial guard for every test that touches
94/// [`FORCE_POST_FAIL`]. Tests in sibling modules (e.g. the bind
95/// reject-path test in `api::listener::tests`) acquire this mutex
96/// for the duration of their fault injection so parallel runners
97/// do not interleave flips.
98#[cfg(test)]
99pub fn tests_serial_guard() -> &'static std::sync::Mutex<()> {
100 static G: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
101 G.get_or_init(|| std::sync::Mutex::new(()))
102}
103
104/// Stage at which a per-algorithm self-test failed. Lets the caller log
105/// "AES-GCM encrypt failed" vs "AES-GCM decrypt mismatch" instead of an
106/// opaque "self-test failed".
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108pub enum AeadStage {
109 /// `CryptoSession::with_suite` / `from_shared_secret` rejected the
110 /// fixed shared-secret input. Indicates a broken key schedule.
111 Init,
112 /// `encrypt` returned an error on a well-formed plaintext.
113 Encrypt,
114 /// `decrypt` returned an error on a freshly-encrypted ciphertext.
115 Decrypt,
116 /// Decrypt succeeded but produced the wrong plaintext.
117 Mismatch,
118}
119
120/// Stage at which the hybrid KEM round-trip failed.
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub enum KemStage {
123 /// `HybridSecretKey::generate` produced an unusable keypair (or panicked
124 /// — caught at the call site).
125 Generate,
126 /// `HybridKeyPackage::encapsulate` returned an error.
127 Encapsulate,
128 /// `HybridSecretKey::decapsulate` returned an error.
129 Decapsulate,
130 /// Decapsulated shared-secret did not match the encapsulator's.
131 Mismatch,
132}
133
134/// Stage at which the hybrid signature round-trip failed.
135#[derive(Debug, Clone, Copy, PartialEq, Eq)]
136pub enum SignStage {
137 /// `HybridSigningKey::generate` produced an unusable keypair.
138 Generate,
139 /// `verify` rejected a signature this build just produced.
140 Verify,
141}
142
143/// Top-level error surface. Each variant carries enough context for an
144/// operator to know which primitive misbehaved without pulling in
145/// long-form error types.
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147pub enum SelfTestError {
148 /// AEAD round-trip failed. The algorithm name is `&'static str`
149 /// (`"AES-256-GCM"` or `"ChaCha20-Poly1305"`).
150 Aead {
151 algorithm: &'static str,
152 stage: AeadStage,
153 },
154 /// HKDF-SHA256 produced output that did not match the bundled KAT.
155 Hkdf,
156 /// Hybrid KEM (X25519 + ML-KEM-768) round-trip failed.
157 HybridKem { stage: KemStage },
158 /// Hybrid signature (Ed25519 + ML-DSA-65) round-trip failed.
159 HybridSign { stage: SignStage },
160 /// Verification accepted a deliberately-tampered signature. Either
161 /// AEAD authenticity is broken or the verifier's reject path is dead.
162 NegativeVerify,
163}
164
165/// Run every per-algorithm self-test once and return `Ok(())` only if all
166/// pass. Aborts at the first failure (do not continue with a broken
167/// cryptographic module). Designed to be called once at process start.
168///
169/// Cost: a single hybrid KEM keygen + encap/decap + a single hybrid
170/// signature gen + sign + verify, plus one or two AEAD round-trips
171/// and one HKDF expansion. Around 1-5 ms on a modern host;
172/// FIPS-mandated regardless.
173///
174/// Under `--features fips` only the FIPS-approved AEAD (AES-256-GCM)
175/// is exercised; `CryptoSession::with_suite` rejects ChaCha20-Poly1305
176/// in that configuration, and the POST refuses to run a primitive the
177/// production build cannot use.
178pub fn run_post() -> Result<(), SelfTestError> {
179 test_aead(CipherSuite::Aes256Gcm, "AES-256-GCM")?;
180 #[cfg(not(feature = "fips"))]
181 test_aead(CipherSuite::ChaCha20Poly1305, "ChaCha20-Poly1305")?;
182 test_hkdf_sha256()?;
183 test_hybrid_kem()?;
184 test_hybrid_sign()?;
185 test_negative_verify()?;
186 Ok(())
187}
188
189/// Round-trip a fixed plaintext through the given suite and assert
190/// authenticated equality. `CryptoSession` is direction-asymmetric in
191/// production — the local side's `send_key` is the peer side's
192/// `recv_key`, mirrored — so the test wires up a local + peer pair from
193/// the same shared secret and round-trips local→peer, matching the
194/// production handshake.
195fn test_aead(suite: CipherSuite, name: &'static str) -> Result<(), SelfTestError> {
196 let shared_secret = [0x42u8; 32];
197 let aad = b"phantom-self-test-aad";
198 let plaintext = b"phantom-protocol self-test payload";
199
200 let local =
201 CryptoSession::with_suite(&shared_secret, suite).map_err(|_| SelfTestError::Aead {
202 algorithm: name,
203 stage: AeadStage::Init,
204 })?;
205 let peer =
206 CryptoSession::with_suite_peer(&shared_secret, suite).map_err(|_| SelfTestError::Aead {
207 algorithm: name,
208 stage: AeadStage::Init,
209 })?;
210
211 let ciphertext = local
212 .encrypt(aad, plaintext)
213 .map_err(|_| SelfTestError::Aead {
214 algorithm: name,
215 stage: AeadStage::Encrypt,
216 })?;
217
218 let recovered = peer
219 .decrypt(aad, &ciphertext)
220 .map_err(|_| SelfTestError::Aead {
221 algorithm: name,
222 stage: AeadStage::Decrypt,
223 })?;
224
225 if recovered != plaintext {
226 return Err(SelfTestError::Aead {
227 algorithm: name,
228 stage: AeadStage::Mismatch,
229 });
230 }
231 Ok(())
232}
233
234/// HKDF-SHA256 KAT. The IKM / salt / info triple below was derived from
235/// the Phantom Protocol rekey path's exact construction (label
236/// `phantom-rekey-v1`) over a fixed 32-byte traffic secret of `0x11..`.
237/// Output is a 32-byte expansion. A mismatch here is a regression in
238/// the underlying `hkdf` / `sha2` crates or in their wiring at the
239/// crate boundary.
240fn test_hkdf_sha256() -> Result<(), SelfTestError> {
241 let ikm = [0x11u8; 32];
242 let hk = Hkdf::<Sha256>::new(None, &ikm);
243 let mut output = [0u8; 32];
244 hk.expand(b"phantom-rekey-v1", &mut output)
245 .map_err(|_| SelfTestError::Hkdf)?;
246
247 // KAT computed from `Hkdf::<Sha256>::new(None, &[0x11; 32])` then
248 // `expand(b"phantom-rekey-v1", &mut [0u8; 32])` against `hkdf = "0.12"` +
249 // `sha2 = "0.10"`. Regenerate if either dependency bumps to a major
250 // version with a behavior change — a mismatch on a clean build
251 // means the upstream crate's output shape moved under us.
252 const KAT: [u8; 32] = [
253 0x41, 0x90, 0x72, 0xe4, 0xca, 0x1b, 0xa9, 0xca, 0xdc, 0x1b, 0x02, 0xd3, 0x75, 0xb0, 0xf8,
254 0x84, 0x70, 0xa7, 0x0f, 0xe9, 0x57, 0x13, 0x1d, 0x7b, 0x5b, 0x35, 0xe5, 0x74, 0x14, 0x34,
255 0xe4, 0x10,
256 ];
257 if output != KAT {
258 return Err(SelfTestError::Hkdf);
259 }
260 Ok(())
261}
262
263/// Hybrid KEM (X25519 + ML-KEM-768) pairwise-consistency test. Generates
264/// a fresh keypair, encapsulates against the published half, decapsulates
265/// with the secret half, and asserts both sides derived the same
266/// 32-byte shared secret. This is the FIPS pairwise-consistency check
267/// for ML-KEM-768 plus the classical X25519 half of the hybrid.
268fn test_hybrid_kem() -> Result<(), SelfTestError> {
269 let (sk, pk) = HybridSecretKey::generate();
270 let (ss_encap, ct) = pk.encapsulate().map_err(|_| SelfTestError::HybridKem {
271 stage: KemStage::Encapsulate,
272 })?;
273 let ss_decap = sk.decapsulate(&ct).map_err(|_| SelfTestError::HybridKem {
274 stage: KemStage::Decapsulate,
275 })?;
276 if ss_encap != ss_decap {
277 return Err(SelfTestError::HybridKem {
278 stage: KemStage::Mismatch,
279 });
280 }
281 Ok(())
282}
283
284/// Hybrid signature (Ed25519 + ML-DSA-65) pairwise-consistency test.
285/// Generates a fresh keypair, signs a fixed message, verifies — both
286/// halves must verify for the hybrid to succeed.
287fn test_hybrid_sign() -> Result<(), SelfTestError> {
288 let (sk, pk) = HybridSigningKey::generate();
289 let message = b"phantom-protocol self-test signature input";
290 let sig = sk.sign(message);
291 pk.verify(message, &sig)
292 .map_err(|_| SelfTestError::HybridSign {
293 stage: SignStage::Verify,
294 })?;
295 Ok(())
296}
297
298/// Confirms the verifier actually rejects tampered signatures. Without
299/// this, a fault-injected verify-always-accepts implementation would
300/// silently pass [`test_hybrid_sign`] (verification of a valid sig also
301/// passes there). Flip one byte in the signature payload and assert
302/// `verify` returns `Err`.
303fn test_negative_verify() -> Result<(), SelfTestError> {
304 let (sk, pk) = HybridSigningKey::generate();
305 let message = b"phantom-protocol self-test negative input";
306 let sig = sk.sign(message);
307 let mut sig_bytes = sig.to_bytes();
308 // Pick a byte deep in the signature payload (not a length prefix at
309 // the start) and flip its low bit. Any non-trivial tamper that
310 // changes signature bytes should fail verification.
311 let idx = sig_bytes.len() / 2;
312 sig_bytes[idx] ^= 0x01;
313
314 let tampered = match crate::crypto::hybrid_sign::HybridSignature::from_bytes(&sig_bytes) {
315 Ok(s) => s,
316 // A decode error is also acceptable rejection — the tampered
317 // bytes don't round-trip as a valid signature wire format.
318 Err(_) => return Ok(()),
319 };
320 if pk.verify(message, &tampered).is_ok() {
321 return Err(SelfTestError::NegativeVerify);
322 }
323 Ok(())
324}
325
326#[cfg(test)]
327mod tests {
328 use super::*;
329
330 #[test]
331 fn post_succeeds_on_clean_build() {
332 run_post().expect("self-tests must pass on a clean build");
333 }
334
335 #[test]
336 fn aead_test_passes_for_both_suites() {
337 test_aead(CipherSuite::Aes256Gcm, "AES-256-GCM").unwrap();
338 // ChaCha20-Poly1305 is rejected under `--features fips`;
339 // only exercise it on non-fips builds.
340 #[cfg(not(feature = "fips"))]
341 test_aead(CipherSuite::ChaCha20Poly1305, "ChaCha20-Poly1305").unwrap();
342 }
343
344 #[test]
345 fn hkdf_kat_locks_the_construction() {
346 test_hkdf_sha256().unwrap();
347 }
348
349 #[test]
350 fn hybrid_kem_round_trip_consistent() {
351 test_hybrid_kem().unwrap();
352 }
353
354 #[test]
355 fn hybrid_sign_round_trip_consistent() {
356 test_hybrid_sign().unwrap();
357 }
358
359 #[test]
360 fn negative_verify_rejects_tampered_signature() {
361 test_negative_verify().unwrap();
362 }
363
364 #[test]
365 fn full_post_under_a_loop_is_stable() {
366 // Self-tests must be repeatable — no internal mutable state that
367 // poisons a second invocation. Useful as a smoke check that
368 // future refactors don't introduce a one-shot init.
369 for _ in 0..3 {
370 run_post().unwrap();
371 }
372 }
373
374 /// `ensure_post_passed()` runs the POST and returns its
375 /// result. On a clean build, `Ok(())`.
376 #[test]
377 fn ensure_post_passed_succeeds_on_clean_build() {
378 let _guard = tests_serial_guard().lock().unwrap();
379 set_force_post_fail(false);
380 assert!(ensure_post_passed().is_ok());
381 }
382
383 /// `FORCE_POST_FAIL` flips `ensure_post_passed` to return
384 /// the fault-injected error variant. Used by the
385 /// `listener::bind*` / `session::connect*` reject-path tests.
386 #[test]
387 fn force_post_fail_returns_error_via_ensure_post_passed() {
388 let _guard = tests_serial_guard().lock().unwrap();
389 set_force_post_fail(true);
390 let result = ensure_post_passed();
391 set_force_post_fail(false);
392 match result {
393 Err(SelfTestError::Aead {
394 algorithm: "AES-256-GCM",
395 stage: AeadStage::Decrypt,
396 }) => {}
397 other => panic!("expected fault-injected AEAD Decrypt failure, got {other:?}"),
398 }
399 // Cleared; next call should succeed again.
400 assert!(ensure_post_passed().is_ok());
401 }
402}