1use 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
24pub const AEAD_OVERHEAD: usize = 16;
26
27pub const AEAD_MAX_INVOCATIONS: u64 = 1u64 << 48;
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55#[repr(u8)]
56pub enum CipherSuite {
57 Aes256Gcm = 1,
60 ChaCha20Poly1305 = 2,
69}
70
71impl CipherSuite {
72 pub fn to_byte(self) -> u8 {
74 self as u8
75 }
76
77 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 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#[derive(Debug, Clone, Copy)]
97pub struct HwCaps {
98 pub has_hw_aes: bool,
99}
100
101impl HwCaps {
102 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 }
128
129 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
152pub 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 client_preferred.contains(&server_pref) {
179 return Ok(server_pref);
180 }
181 Ok(client_preferred
183 .first()
184 .copied()
185 .unwrap_or(CipherSuite::ChaCha20Poly1305))
186 }
187}
188
189#[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 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 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 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 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 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 #[inline]
310 pub fn cipher_suite(&self) -> CipherSuite {
311 self.inner.suite
312 }
313
314 #[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 #[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 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 buf.extend_from_slice(tag.as_ref());
352 Ok(buf.len() - offset)
353 }
354
355 #[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 #[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 #[inline]
386 pub fn send_invocations(&self) -> u64 {
387 self.inner.send_counter.load(Ordering::Relaxed)
388 }
389
390 #[inline]
392 pub fn recv_invocations(&self) -> u64 {
393 self.inner.recv_counter.load(Ordering::Relaxed)
394 }
395
396 #[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 #[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 #[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 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 self.inner.recv_counter.fetch_add(1, Ordering::Relaxed);
471 buf.truncate(len);
472 Ok(buf)
473 }
474
475 #[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#[derive(Debug, Clone, Copy)]
494pub enum CryptoError {
495 EncryptionFailed,
496 DecryptionFailed,
497 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 }
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 #[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 #[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 #[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]); 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 buf[..4].copy_from_slice(&(ct_len as u32).to_be_bytes());
626
627 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 let result = negotiate_cipher(
644 &[CipherSuite::Aes256Gcm, CipherSuite::ChaCha20Poly1305],
645 &server_aes,
646 )
647 .unwrap();
648 assert_eq!(result, CipherSuite::Aes256Gcm);
649
650 let result = negotiate_cipher(
652 &[CipherSuite::Aes256Gcm, CipherSuite::ChaCha20Poly1305],
653 &server_no_aes,
654 )
655 .unwrap();
656 assert_eq!(result, CipherSuite::ChaCha20Poly1305);
657
658 let result = negotiate_cipher(&[CipherSuite::ChaCha20Poly1305], &server_aes).unwrap();
660 assert_eq!(result, CipherSuite::ChaCha20Poly1305);
661 }
662
663 #[cfg(feature = "fips")]
665 #[test]
666 fn negotiation_rejects_chacha_only_under_fips() {
667 let server_aes = HwCaps { has_hw_aes: true };
668
669 let suite = negotiate_cipher(
671 &[CipherSuite::ChaCha20Poly1305, CipherSuite::Aes256Gcm],
672 &server_aes,
673 )
674 .unwrap();
675 assert_eq!(suite, CipherSuite::Aes256Gcm);
676
677 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 #[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]; 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}