Skip to main content

phantom_protocol/crypto/
adaptive_crypto.rs

1//! Adaptive Crypto Engine
2//!
3//! Picks the AEAD cipher automatically from the host's hardware capabilities:
4//! - AES-256-GCM (ring asm) → Apple Silicon (FEAT_AES), x86_64 (AES-NI)
5//! - ChaCha20-Poly1305 (ring asm) → ARM without AES, MIPS, RISC-V, IoT
6//!
7//! Without HW AES, ChaCha20 is ~3-4x faster; with HW AES, AES-GCM is ~1.3x
8//! faster than ChaCha20.
9//!
10//! Under `--features fips` the AEAD backend swaps to `aws-lc-rs`
11//! (FIPS-validated AWS-LC). The Rust API surface is identical to
12//! `ring::aead`, so the rest of this module is untouched. The cipher
13//! suite enum keeps `ChaCha20Poly1305` (wire-format stability) but the
14//! negotiation/build paths reject it under `fips`.
15
16use crate::errors::CoreError;
17#[cfg(feature = "fips")]
18use aws_lc_rs::aead::{self, Aad, LessSafeKey, Nonce, UnboundKey, AES_256_GCM, CHACHA20_POLY1305};
19#[cfg(not(feature = "fips"))]
20use ring::aead::{self, Aad, LessSafeKey, Nonce, UnboundKey, AES_256_GCM, CHACHA20_POLY1305};
21use std::sync::atomic::{AtomicU64, Ordering};
22use std::sync::Arc;
23
24/// Overhead bytes: both AES-GCM and ChaCha20-Poly1305 produce a 16-byte tag
25pub const AEAD_OVERHEAD: usize = 16;
26
27/// Hard upper bound on per-direction AEAD invocations before forcing a key
28/// rotation (or, in the absence of rekey, failing the operation).
29///
30/// AES-GCM's safety margins under deterministic-counter nonces are governed
31/// by NIST SP 800-38D: with this construction the key may be used for up to
32/// 2^48 invocations before the security level meaningfully degrades. We pick
33/// 2^48 as a defensive ceiling.  At 10^6 packets/sec it is ~9 years away —
34/// effectively unreachable for any real session — but the explicit check
35/// prevents catastrophic key abuse if a counter ever rolls back or a callsite
36/// loops pathologically.
37///
38/// Mid-session key rotation (`Session::rekey`, label `"phantom-rekey-v1"`) is
39/// implemented and fires its rekey trigger well below this limit, so in normal
40/// operation this error path is a backstop, not a reachable failure mode. It is
41/// pinned by Security Invariant #8.
42pub const AEAD_MAX_INVOCATIONS: u64 = 1u64 << 48;
43
44/// Supported cipher suites.
45///
46/// The wire byte for each variant is stable across feature configurations —
47/// `Aes256Gcm = 1`, `ChaCha20Poly1305 = 2` — so a peer's `CipherSuite`
48/// offer round-trips through `to_byte` / `from_byte` regardless of which
49/// build the peer is running. Under `--features fips` only `Aes256Gcm`
50/// is actually selectable; `ChaCha20Poly1305` is reserved for
51/// wire-format stability and is rejected at `negotiate_cipher` /
52/// `CryptoSession::with_suite{_peer}` with
53/// `CoreError::CipherSuiteUnavailable`.
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55#[repr(u8)]
56pub enum CipherSuite {
57    /// AES-256-GCM — optimal on HW-accelerated platforms.
58    /// FIPS-approved; the only suite selectable under `--features fips`.
59    Aes256Gcm = 1,
60    /// ChaCha20-Poly1305 — optimal on SW-only platforms (IoT, old ARM).
61    ///
62    /// **Reserved for wire-format stability under `--features fips`.**
63    /// The variant remains in the enum so a peer's offer can still be
64    /// parsed and a clear `CipherSuiteUnavailable` error returned;
65    /// construction via `CryptoSession::with_suite{_peer}` and selection
66    /// in `negotiate_cipher` are explicitly rejected under fips. Not
67    /// FIPS-approved (RFC 7539 / 8439 — outside FIPS 140-3 Annex A).
68    ChaCha20Poly1305 = 2,
69}
70
71impl CipherSuite {
72    /// Byte representation for handshake negotiation
73    pub fn to_byte(self) -> u8 {
74        self as u8
75    }
76
77    /// Parse from byte
78    pub fn from_byte(b: u8) -> Option<Self> {
79        match b {
80            1 => Some(Self::Aes256Gcm),
81            2 => Some(Self::ChaCha20Poly1305),
82            _ => None,
83        }
84    }
85
86    /// AEAD algorithm reference for ring
87    fn algorithm(&self) -> &'static aead::Algorithm {
88        match self {
89            Self::Aes256Gcm => &AES_256_GCM,
90            Self::ChaCha20Poly1305 => &CHACHA20_POLY1305,
91        }
92    }
93}
94
95/// Hardware capabilities report
96#[derive(Debug, Clone, Copy)]
97pub struct HwCaps {
98    pub has_hw_aes: bool,
99}
100
101impl HwCaps {
102    /// Detect hardware capabilities on the current platform
103    pub fn detect() -> Self {
104        Self {
105            has_hw_aes: Self::detect_hw_aes(),
106        }
107    }
108
109    #[cfg(target_arch = "aarch64")]
110    fn detect_hw_aes() -> bool {
111        std::arch::is_aarch64_feature_detected!("aes")
112    }
113
114    #[cfg(target_arch = "x86_64")]
115    fn detect_hw_aes() -> bool {
116        std::is_x86_feature_detected!("aes")
117    }
118
119    #[cfg(target_arch = "x86")]
120    fn detect_hw_aes() -> bool {
121        std::is_x86_feature_detected!("aes")
122    }
123
124    #[cfg(not(any(target_arch = "aarch64", target_arch = "x86_64", target_arch = "x86")))]
125    fn detect_hw_aes() -> bool {
126        false // MIPS, RISC-V, ARM32 without crypto extension → no HW AES
127    }
128
129    /// Recommend best cipher for this hardware.
130    ///
131    /// Under `--features fips` the only FIPS-approved AEAD on the wire
132    /// is `Aes256Gcm`, so we pin the recommendation regardless of
133    /// hardware capability — software AES is still allowed; only the
134    /// cipher choice is restricted.
135    pub fn recommended_cipher(&self) -> CipherSuite {
136        #[cfg(feature = "fips")]
137        {
138            let _ = self.has_hw_aes;
139            CipherSuite::Aes256Gcm
140        }
141        #[cfg(not(feature = "fips"))]
142        {
143            if self.has_hw_aes {
144                CipherSuite::Aes256Gcm
145            } else {
146                CipherSuite::ChaCha20Poly1305
147            }
148        }
149    }
150}
151
152/// Negotiate best cipher suite between client and server.
153///
154/// Returns `Err(CoreError::CipherSuiteUnavailable)` under `--features
155/// fips` when the client does not offer a FIPS-approved suite
156/// (today: `Aes256Gcm`). On non-fips builds this never fails; the
157/// return type is `Result` so the API shape is feature-stable.
158pub fn negotiate_cipher(
159    client_preferred: &[CipherSuite],
160    server_caps: &HwCaps,
161) -> Result<CipherSuite, CoreError> {
162    #[cfg(feature = "fips")]
163    {
164        let _ = server_caps;
165        if client_preferred.contains(&CipherSuite::Aes256Gcm) {
166            Ok(CipherSuite::Aes256Gcm)
167        } else {
168            Err(CoreError::CipherSuiteUnavailable(
169                "no FIPS-approved cipher suite in client offer (only AES-256-GCM is approved under fips)"
170                    .into(),
171            ))
172        }
173    }
174    #[cfg(not(feature = "fips"))]
175    {
176        let server_pref = server_caps.recommended_cipher();
177        // If server's preference is in client's list, use it
178        if client_preferred.contains(&server_pref) {
179            return Ok(server_pref);
180        }
181        // Otherwise use client's first choice
182        Ok(client_preferred
183            .first()
184            .copied()
185            .unwrap_or(CipherSuite::ChaCha20Poly1305))
186    }
187}
188
189/// Unified crypto session — works with any supported cipher suite.
190///
191/// Drop-in replacement for `AesSession` with auto cipher selection.
192#[derive(Clone)]
193pub struct CryptoSession {
194    inner: Arc<CryptoSessionInner>,
195}
196
197struct CryptoSessionInner {
198    suite: CipherSuite,
199    send_key: LessSafeKey,
200    recv_key: LessSafeKey,
201    send_counter: AtomicU64,
202    recv_counter: AtomicU64,
203    nonce_prefix: [u8; 4],
204}
205
206impl CryptoSession {
207    /// Auto-detect best cipher and create session from shared secret.
208    /// Initiator side.
209    pub fn from_shared_secret(shared_secret: &[u8; 32]) -> Result<Self, CoreError> {
210        let suite = HwCaps::detect().recommended_cipher();
211        Self::build(shared_secret, suite, false)
212    }
213
214    /// Auto-detect, peer (responder) side — keys swapped.
215    pub fn from_shared_secret_peer(shared_secret: &[u8; 32]) -> Result<Self, CoreError> {
216        let suite = HwCaps::detect().recommended_cipher();
217        Self::build(shared_secret, suite, true)
218    }
219
220    /// Create with explicit cipher suite (for negotiation scenarios).
221    /// Initiator side.
222    ///
223    /// Under `--features fips`, requesting [`CipherSuite::ChaCha20Poly1305`]
224    /// returns [`CoreError::CipherSuiteUnavailable`] — the wire-format
225    /// variant is preserved (enum stable across feature configurations)
226    /// but the primitive is not FIPS-approved.
227    pub fn with_suite(shared_secret: &[u8; 32], suite: CipherSuite) -> Result<Self, CoreError> {
228        Self::guard_suite_under_fips(suite)?;
229        Self::build(shared_secret, suite, false)
230    }
231
232    /// Create with explicit cipher suite. Peer side.
233    ///
234    /// Mirrors the `fips` guard of [`Self::with_suite`].
235    pub fn with_suite_peer(
236        shared_secret: &[u8; 32],
237        suite: CipherSuite,
238    ) -> Result<Self, CoreError> {
239        Self::guard_suite_under_fips(suite)?;
240        Self::build(shared_secret, suite, true)
241    }
242
243    #[inline]
244    fn guard_suite_under_fips(suite: CipherSuite) -> Result<(), CoreError> {
245        #[cfg(feature = "fips")]
246        {
247            if suite == CipherSuite::ChaCha20Poly1305 {
248                return Err(CoreError::CipherSuiteUnavailable(
249                    "ChaCha20-Poly1305 is not FIPS-approved; only AES-256-GCM is permitted under --features fips"
250                        .into(),
251                ));
252            }
253        }
254        #[cfg(not(feature = "fips"))]
255        {
256            let _ = suite;
257        }
258        Ok(())
259    }
260
261    fn build(shared_secret: &[u8; 32], suite: CipherSuite, swap: bool) -> Result<Self, CoreError> {
262        let ctx = match suite {
263            CipherSuite::Aes256Gcm => "phantom-aes-",
264            CipherSuite::ChaCha20Poly1305 => "phantom-cc20-",
265        };
266        let send_label = format!("{}send-v1", ctx);
267        let recv_label = format!("{}recv-v1", ctx);
268
269        // `crypto::kdf::derive_key_32` cfg-dispatches between
270        // `blake3::derive_key` (default) and HKDF-SHA256 (fips). The
271        // 32-byte output and label-string API are identical.
272        // CRYPTO-3: the per-direction AEAD key bytes are wiped on every exit
273        // path once copied into ring's opaque `UnboundKey` (the public
274        // `nonce_prefix` below is not secret and stays plain).
275        let key_a = zeroize::Zeroizing::new(crate::crypto::kdf::derive_key_32(
276            &send_label,
277            shared_secret,
278        ));
279        let key_b = zeroize::Zeroizing::new(crate::crypto::kdf::derive_key_32(
280            &recv_label,
281            shared_secret,
282        ));
283
284        let (send_bytes, recv_bytes) = if swap { (key_b, key_a) } else { (key_a, key_b) };
285
286        let algo = suite.algorithm();
287        let send_unbound = UnboundKey::new(algo, &*send_bytes)
288            .map_err(|_| CoreError::CryptoError("Failed to create send key".into()))?;
289        let recv_unbound = UnboundKey::new(algo, &*recv_bytes)
290            .map_err(|_| CoreError::CryptoError("Failed to create recv key".into()))?;
291
292        let prefix_bytes = crate::crypto::kdf::derive_key_32("phantom-nonce-pfx-v1", shared_secret);
293        let mut nonce_prefix = [0u8; 4];
294        nonce_prefix.copy_from_slice(&prefix_bytes[..4]);
295
296        Ok(Self {
297            inner: Arc::new(CryptoSessionInner {
298                suite,
299                send_key: LessSafeKey::new(send_unbound),
300                recv_key: LessSafeKey::new(recv_unbound),
301                send_counter: AtomicU64::new(0),
302                recv_counter: AtomicU64::new(0),
303                nonce_prefix,
304            }),
305        })
306    }
307
308    /// Which cipher suite is active
309    #[inline]
310    pub fn cipher_suite(&self) -> CipherSuite {
311        self.inner.suite
312    }
313
314    /// Encrypt in place: appends 16-byte tag.
315    #[inline]
316    pub fn encrypt_in_place(&self, aad: &[u8], buf: &mut Vec<u8>) -> Result<(), CryptoError> {
317        let counter = self.inner.send_counter.fetch_add(1, Ordering::Relaxed);
318        if counter >= AEAD_MAX_INVOCATIONS {
319            return Err(CryptoError::NonceExhausted);
320        }
321        let nonce = self.make_nonce(counter);
322        self.inner
323            .send_key
324            .seal_in_place_append_tag(nonce, Aad::from(aad), buf)
325            .map_err(|_| CryptoError::EncryptionFailed)?;
326        Ok(())
327    }
328
329    /// Encrypt in place with offset: leaves `offset` bytes untouched at the start
330    /// (for prepending frame headers). Encrypts buf[offset..] in place, appends tag.
331    /// Returns ciphertext length (data + tag).
332    #[inline]
333    pub fn encrypt_in_place_offset(
334        &self,
335        aad: &[u8],
336        buf: &mut Vec<u8>,
337        offset: usize,
338    ) -> Result<usize, CryptoError> {
339        let counter = self.inner.send_counter.fetch_add(1, Ordering::Relaxed);
340        if counter >= AEAD_MAX_INVOCATIONS {
341            return Err(CryptoError::NonceExhausted);
342        }
343        let nonce = self.make_nonce(counter);
344        // seal_in_place_separate_tag works on &mut [u8] (no Extend needed)
345        let tag = self
346            .inner
347            .send_key
348            .seal_in_place_separate_tag(nonce, Aad::from(aad), &mut buf[offset..])
349            .map_err(|_| CryptoError::EncryptionFailed)?;
350        // Manually append the 16-byte auth tag
351        buf.extend_from_slice(tag.as_ref());
352        Ok(buf.len() - offset)
353    }
354
355    /// Encrypt: allocates a new Vec.
356    #[inline]
357    pub fn encrypt(&self, aad: &[u8], plaintext: &[u8]) -> Result<Vec<u8>, CryptoError> {
358        let mut buf = Vec::with_capacity(plaintext.len() + AEAD_OVERHEAD);
359        buf.extend_from_slice(plaintext);
360        self.encrypt_in_place(aad, &mut buf)?;
361        Ok(buf)
362    }
363
364    /// Decrypt in place: verifies tag and returns plaintext slice.
365    #[inline]
366    pub fn decrypt_in_place<'a>(
367        &self,
368        aad: &[u8],
369        buf: &'a mut [u8],
370    ) -> Result<&'a mut [u8], CryptoError> {
371        let counter = self.inner.recv_counter.fetch_add(1, Ordering::Relaxed);
372        if counter >= AEAD_MAX_INVOCATIONS {
373            return Err(CryptoError::NonceExhausted);
374        }
375        let nonce = self.make_nonce(counter);
376        self.inner
377            .recv_key
378            .open_in_place(nonce, Aad::from(aad), buf)
379            .map_err(|_| CryptoError::DecryptionFailed)
380    }
381
382    /// Number of encryptions performed on this session (per-direction send counter).
383    /// Useful for emitting AEAD-invocation metrics and for the mid-session
384    /// rekey-trigger logic in `Session::rekey`.
385    #[inline]
386    pub fn send_invocations(&self) -> u64 {
387        self.inner.send_counter.load(Ordering::Relaxed)
388    }
389
390    /// Number of decryptions performed on this session (per-direction recv counter).
391    #[inline]
392    pub fn recv_invocations(&self) -> u64 {
393        self.inner.recv_counter.load(Ordering::Relaxed)
394    }
395
396    /// Decrypt: allocates a new Vec.
397    #[inline]
398    pub fn decrypt(&self, aad: &[u8], ciphertext: &[u8]) -> Result<Vec<u8>, CryptoError> {
399        let mut buf = ciphertext.to_vec();
400        let plaintext = self.decrypt_in_place(aad, &mut buf)?;
401        let len = plaintext.len();
402        buf.truncate(len);
403        Ok(buf)
404    }
405
406    // ── V2 / explicit-nonce path ───────────────────────────────────────
407    //
408    // The V1 paths above derive the AEAD nonce from an internal monotonic
409    // counter — fast and minimal-on-wire, but fragile under attack: a
410    // failed decrypt still advances the counter, so a follow-up legitimate
411    // packet decrypts under a different nonce than the sender used.
412    //
413    // V2 fixes this by deriving the nonce from the authenticated header
414    // fields the caller supplies. Failed decrypts no longer desync the
415    // receiver. The counter API is kept in place so the caller can still
416    // track / cap invocation counts for telemetry.
417
418    /// Encrypt with an explicit caller-supplied nonce. The caller MUST
419    /// ensure uniqueness of `(key, nonce)`. The production caller
420    /// (`Session::build_packet_nonce`) derives the 12-byte nonce as
421    /// `nonce_prefix(4) ‖ packet_number(8, big-endian)`, so uniqueness
422    /// follows from the wire-format invariant that the per-direction `u64`
423    /// packet number is strictly monotonic and never reused within an epoch.
424    #[inline]
425    pub fn encrypt_with_nonce(
426        &self,
427        nonce_bytes: [u8; 12],
428        aad: &[u8],
429        plaintext: &[u8],
430    ) -> Result<Vec<u8>, CryptoError> {
431        let counter = self.inner.send_counter.fetch_add(1, Ordering::Relaxed);
432        if counter >= AEAD_MAX_INVOCATIONS {
433            return Err(CryptoError::NonceExhausted);
434        }
435        let nonce = Nonce::assume_unique_for_key(nonce_bytes);
436        let mut buf = Vec::with_capacity(plaintext.len() + AEAD_OVERHEAD);
437        buf.extend_from_slice(plaintext);
438        self.inner
439            .send_key
440            .seal_in_place_append_tag(nonce, Aad::from(aad), &mut buf)
441            .map_err(|_| CryptoError::EncryptionFailed)?;
442        Ok(buf)
443    }
444
445    /// Decrypt with an explicit caller-supplied nonce. Unlike [`Self::decrypt`],
446    /// a tag-check failure does NOT advance the internal counter — only
447    /// the bounded telemetry counter increments.
448    #[inline]
449    pub fn decrypt_with_nonce(
450        &self,
451        nonce_bytes: [u8; 12],
452        aad: &[u8],
453        ciphertext: &[u8],
454    ) -> Result<Vec<u8>, CryptoError> {
455        // T5.5: check the ceiling against the CURRENT count without consuming it — a forged
456        // packet (which fails the open below) must NOT advance the recv invocation counter
457        // toward `NonceExhausted`. Only a successful, authenticated open counts.
458        if self.inner.recv_counter.load(Ordering::Relaxed) >= AEAD_MAX_INVOCATIONS {
459            return Err(CryptoError::NonceExhausted);
460        }
461        let nonce = Nonce::assume_unique_for_key(nonce_bytes);
462        let mut buf = ciphertext.to_vec();
463        let plaintext_slice = self
464            .inner
465            .recv_key
466            .open_in_place(nonce, Aad::from(aad), &mut buf)
467            .map_err(|_| CryptoError::DecryptionFailed)?;
468        let len = plaintext_slice.len();
469        // Authenticated open succeeded — now it counts toward the per-direction ceiling (T5.5).
470        self.inner.recv_counter.fetch_add(1, Ordering::Relaxed);
471        buf.truncate(len);
472        Ok(buf)
473    }
474
475    /// Expose the 4-byte nonce prefix for the V2 nonce construction
476    /// (`prefix(4) || packet_number(8, big-endian)`; see
477    /// `Session::build_packet_nonce`).
478    #[inline]
479    pub fn nonce_prefix(&self) -> [u8; 4] {
480        self.inner.nonce_prefix
481    }
482
483    #[inline(always)]
484    fn make_nonce(&self, counter: u64) -> Nonce {
485        let mut n = [0u8; 12];
486        n[..4].copy_from_slice(&self.inner.nonce_prefix);
487        n[4..12].copy_from_slice(&counter.to_be_bytes());
488        Nonce::assume_unique_for_key(n)
489    }
490}
491
492/// Crypto errors
493#[derive(Debug, Clone, Copy)]
494pub enum CryptoError {
495    EncryptionFailed,
496    DecryptionFailed,
497    /// Per-direction AEAD counter would exceed [`AEAD_MAX_INVOCATIONS`].
498    /// Callers must rotate keys (`Session::rekey`) or close the session.
499    NonceExhausted,
500}
501
502impl std::fmt::Display for CryptoError {
503    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
504        match self {
505            Self::EncryptionFailed => write!(f, "Encryption failed"),
506            Self::DecryptionFailed => write!(f, "Decryption / authentication failed"),
507            Self::NonceExhausted => write!(
508                f,
509                "AEAD nonce exhausted: per-direction counter exceeded {} invocations \
510                 (rotate keys before reusing this session)",
511                AEAD_MAX_INVOCATIONS
512            ),
513        }
514    }
515}
516
517impl std::error::Error for CryptoError {}
518
519#[cfg(test)]
520mod tests {
521    use super::*;
522
523    #[test]
524    fn hw_detection() {
525        let caps = HwCaps::detect();
526        let suite = caps.recommended_cipher();
527        eprintln!("HW AES: {}, Recommended: {:?}", caps.has_hw_aes, suite);
528        // On Apple Silicon / modern x86, should pick AES
529        // On old ARM / MIPS, should pick ChaCha20
530    }
531
532    #[test]
533    fn round_trip_aes() {
534        let secret = [0xABu8; 32];
535        let a = CryptoSession::with_suite(&secret, CipherSuite::Aes256Gcm).unwrap();
536        let b = CryptoSession::with_suite_peer(&secret, CipherSuite::Aes256Gcm).unwrap();
537
538        let msg = b"Hello, PQ AES world!";
539        let ct = a.encrypt(&[], msg).unwrap();
540        let pt = b.decrypt(&[], &ct).unwrap();
541        assert_eq!(&pt, msg);
542    }
543
544    /// Round-trip AES-256-GCM through the FIPS backend (`aws-lc-rs`).
545    /// Identical to [`round_trip_aes`] but explicit about which backend
546    /// is exercised — runs only when the `fips` feature is active.
547    #[cfg(feature = "fips")]
548    #[test]
549    fn round_trip_aes_aws_lc_rs() {
550        let secret = [0xCEu8; 32];
551        let a = CryptoSession::with_suite(&secret, CipherSuite::Aes256Gcm).unwrap();
552        let b = CryptoSession::with_suite_peer(&secret, CipherSuite::Aes256Gcm).unwrap();
553
554        let msg = b"Hello, FIPS-mode AES world!";
555        let ct = a.encrypt(&[], msg).unwrap();
556        let pt = b.decrypt(&[], &ct).unwrap();
557        assert_eq!(&pt, msg);
558    }
559
560    // ChaCha20-Poly1305 is rejected under `--features fips`; only run
561    // the positive round-trip on non-fips builds.
562    #[cfg(not(feature = "fips"))]
563    #[test]
564    fn round_trip_chacha() {
565        let secret = [0xCDu8; 32];
566        let a = CryptoSession::with_suite(&secret, CipherSuite::ChaCha20Poly1305).unwrap();
567        let b = CryptoSession::with_suite_peer(&secret, CipherSuite::ChaCha20Poly1305).unwrap();
568
569        let msg = b"Hello, PQ ChaCha world!";
570        let ct = a.encrypt(&[], msg).unwrap();
571        let pt = b.decrypt(&[], &ct).unwrap();
572        assert_eq!(&pt, msg);
573    }
574
575    /// Under fips, requesting ChaCha20-Poly1305 fails fast with
576    /// `CoreError::CipherSuiteUnavailable`.
577    #[cfg(feature = "fips")]
578    #[test]
579    fn chacha_rejected_under_fips() {
580        let secret = [0xCDu8; 32];
581
582        match CryptoSession::with_suite(&secret, CipherSuite::ChaCha20Poly1305) {
583            Err(CoreError::CipherSuiteUnavailable(_)) => {}
584            Err(e) => panic!("expected CipherSuiteUnavailable, got {e:?}"),
585            Ok(_) => panic!("expected error, got ok"),
586        }
587
588        match CryptoSession::with_suite_peer(&secret, CipherSuite::ChaCha20Poly1305) {
589            Err(CoreError::CipherSuiteUnavailable(_)) => {}
590            Err(e) => panic!("expected CipherSuiteUnavailable, got {e:?}"),
591            Ok(_) => panic!("expected error, got ok"),
592        }
593    }
594
595    #[test]
596    fn round_trip_auto() {
597        let secret = [0xEFu8; 32];
598        let a = CryptoSession::from_shared_secret(&secret).unwrap();
599        let b = CryptoSession::from_shared_secret_peer(&secret).unwrap();
600
601        assert_eq!(a.cipher_suite(), b.cipher_suite());
602        let msg = b"Auto-detected cipher!";
603        let ct = a.encrypt(&[], msg).unwrap();
604        let pt = b.decrypt(&[], &ct).unwrap();
605        assert_eq!(&pt, msg);
606    }
607
608    #[test]
609    fn in_place_with_offset() {
610        let secret = [0xAB; 32];
611        let session = CryptoSession::with_suite(&secret, CipherSuite::Aes256Gcm).unwrap();
612        let peer = CryptoSession::with_suite_peer(&secret, CipherSuite::Aes256Gcm).unwrap();
613
614        let data = b"Payload after header";
615        let header_len = 4usize;
616        let mut buf = Vec::with_capacity(header_len + data.len() + AEAD_OVERHEAD);
617        buf.extend_from_slice(&[0u8; 4]); // placeholder for header
618        buf.extend_from_slice(data);
619
620        let ct_len = session
621            .encrypt_in_place_offset(&[0u8; 4], &mut buf, header_len)
622            .unwrap();
623
624        // Write header
625        buf[..4].copy_from_slice(&(ct_len as u32).to_be_bytes());
626
627        // Decrypt on peer side
628        let len = u32::from_be_bytes([buf[0], buf[1], buf[2], buf[3]]) as usize;
629        let (_header, payload) = buf.split_at_mut(4);
630        let pt = peer
631            .decrypt_in_place(&[0u8; 4], &mut payload[..len])
632            .unwrap();
633        assert_eq!(pt, data);
634    }
635
636    #[cfg(not(feature = "fips"))]
637    #[test]
638    fn negotiation() {
639        let server_aes = HwCaps { has_hw_aes: true };
640        let server_no_aes = HwCaps { has_hw_aes: false };
641
642        // Client prefers both, server has AES → AES
643        let result = negotiate_cipher(
644            &[CipherSuite::Aes256Gcm, CipherSuite::ChaCha20Poly1305],
645            &server_aes,
646        )
647        .unwrap();
648        assert_eq!(result, CipherSuite::Aes256Gcm);
649
650        // Client prefers both, server no AES → ChaCha20
651        let result = negotiate_cipher(
652            &[CipherSuite::Aes256Gcm, CipherSuite::ChaCha20Poly1305],
653            &server_no_aes,
654        )
655        .unwrap();
656        assert_eq!(result, CipherSuite::ChaCha20Poly1305);
657
658        // Client only ChaCha, server has AES → ChaCha (client's preference)
659        let result = negotiate_cipher(&[CipherSuite::ChaCha20Poly1305], &server_aes).unwrap();
660        assert_eq!(result, CipherSuite::ChaCha20Poly1305);
661    }
662
663    /// Under fips, a ChaCha-only client offer is rejected.
664    #[cfg(feature = "fips")]
665    #[test]
666    fn negotiation_rejects_chacha_only_under_fips() {
667        let server_aes = HwCaps { has_hw_aes: true };
668
669        // Mixed: AES present → succeeds with AES regardless of order.
670        let suite = negotiate_cipher(
671            &[CipherSuite::ChaCha20Poly1305, CipherSuite::Aes256Gcm],
672            &server_aes,
673        )
674        .unwrap();
675        assert_eq!(suite, CipherSuite::Aes256Gcm);
676
677        // ChaCha-only offer: rejected.
678        let err = negotiate_cipher(&[CipherSuite::ChaCha20Poly1305], &server_aes).unwrap_err();
679        assert!(
680            matches!(err, CoreError::CipherSuiteUnavailable(_)),
681            "expected CipherSuiteUnavailable, got {err:?}"
682        );
683    }
684
685    // Skip the dual-suite throughput sweep under `fips` — only one
686    // suite (`Aes256Gcm`) is permitted in that configuration.
687    #[cfg(not(feature = "fips"))]
688    #[test]
689    fn throughput_comparison() {
690        use std::time::Instant;
691
692        let secret = [0xAB; 32];
693        let data = vec![0u8; 16 * 1024]; // 16KB
694        let iters = 50_000;
695
696        for suite in [CipherSuite::Aes256Gcm, CipherSuite::ChaCha20Poly1305] {
697            let session = CryptoSession::with_suite(&secret, suite).unwrap();
698            let start = Instant::now();
699            for _ in 0..iters {
700                let e = session.encrypt(&[], &data).unwrap();
701                std::hint::black_box(e);
702            }
703            let elapsed = start.elapsed();
704            let tput = (data.len() * iters) as f64 / 1_048_576.0 / elapsed.as_secs_f64();
705            eprintln!("{:?}: {:.0} MiB/s", suite, tput);
706        }
707    }
708}