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