Skip to main content

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}