Skip to main content

phantom_protocol/crypto/
aes_session.rs

1//! High-Performance AES-GCM Session Encryption
2//!
3//! Uses `ring` crate for AES-256-GCM with hardware acceleration.
4//! On Apple Silicon M1: ARM FEAT_AES intrinsics (~4-8 GB/s)
5//! On x86_64: AES-NI instructions (~4-8 GB/s)
6//!
7//! `ring` uses hand-optimized assembly for both ARM64 and x86_64,
8//! ensuring maximum throughput compared to pure-Rust crates.
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` (same `LessSafeKey` / `Nonce` / `seal_in_place_append_tag`
13//! / `open_in_place`), so the rest of this module is untouched and `ring`
14//! is no longer linked on the fips path — see `crypto/adaptive_crypto.rs`
15//! for the same backend dispatch.
16
17#[cfg(feature = "fips")]
18use aws_lc_rs::aead::{Aad, LessSafeKey, Nonce, UnboundKey, AES_256_GCM};
19#[cfg(not(feature = "fips"))]
20use ring::aead::{Aad, LessSafeKey, Nonce, UnboundKey, AES_256_GCM};
21use std::sync::atomic::{AtomicU64, Ordering};
22
23/// Overhead bytes added by AES-256-GCM (16-byte auth tag)
24pub const AES_GCM_OVERHEAD: usize = 16;
25
26/// High-performance session encryption using ring's AES-256-GCM
27/// (hardware accelerated on ARM64/x86_64)
28pub struct AesSession {
29    /// Send cipher key
30    send_key: LessSafeKey,
31    /// Receive cipher key
32    recv_key: LessSafeKey,
33    /// Send nonce counter
34    send_counter: AtomicU64,
35    /// Receive nonce counter
36    recv_counter: AtomicU64,
37    /// Nonce prefix (4 bytes, set per session)
38    nonce_prefix: [u8; 4],
39}
40
41impl AesSession {
42    /// Create from a 32-byte shared secret (derived from PQC handshake).
43    /// This is the "initiator" side.
44    /// Create from a 32-byte shared secret (derived from PQC handshake).
45    /// This is the "initiator" side.
46    pub fn from_shared_secret(shared_secret: &[u8; 32]) -> Result<Self, crate::CoreError> {
47        Self::build(shared_secret, false)
48    }
49
50    /// Create the "peer" (responder) side — send/recv keys are swapped so that
51    /// initiator's encrypt can be decrypted by peer's decrypt, and vice versa.
52    /// Create the "peer" (responder) side — send/recv keys are swapped so that
53    /// initiator's encrypt can be decrypted by peer's decrypt, and vice versa.
54    pub fn from_shared_secret_peer(shared_secret: &[u8; 32]) -> Result<Self, crate::CoreError> {
55        Self::build(shared_secret, true)
56    }
57
58    fn build(shared_secret: &[u8; 32], swap: bool) -> Result<Self, crate::CoreError> {
59        // `crypto::kdf::derive_key_32` cfg-dispatches between
60        // `blake3::derive_key` (default) and HKDF-SHA256 (`--features
61        // fips`). API shape and 32-byte output are identical.
62        // CRYPTO-3: wipe the per-direction AEAD key bytes on every exit path
63        // once copied into ring's opaque `UnboundKey`.
64        let key_a = zeroize::Zeroizing::new(crate::crypto::kdf::derive_key_32(
65            "phantom-aes-send-v1",
66            shared_secret,
67        ));
68        let key_b = zeroize::Zeroizing::new(crate::crypto::kdf::derive_key_32(
69            "phantom-aes-recv-v1",
70            shared_secret,
71        ));
72
73        let (send_bytes, recv_bytes) = if swap { (key_b, key_a) } else { (key_a, key_b) };
74
75        let send_unbound = UnboundKey::new(&AES_256_GCM, &*send_bytes)
76            .map_err(|_| crate::CoreError::CryptoError("Invalid key".into()))?;
77        let recv_unbound = UnboundKey::new(&AES_256_GCM, &*recv_bytes)
78            .map_err(|_| crate::CoreError::CryptoError("Invalid key".into()))?;
79
80        let prefix_bytes = crate::crypto::kdf::derive_key_32("phantom-nonce-pfx-v1", shared_secret);
81        let mut nonce_prefix = [0u8; 4];
82        nonce_prefix.copy_from_slice(&prefix_bytes[..4]);
83
84        Ok(Self {
85            send_key: LessSafeKey::new(send_unbound),
86            recv_key: LessSafeKey::new(recv_unbound),
87            send_counter: AtomicU64::new(0),
88            recv_counter: AtomicU64::new(0),
89            nonce_prefix,
90        })
91    }
92
93    /// Encrypt in place: appends 16-byte tag. Returns total ciphertext length.
94    #[inline]
95    pub fn encrypt_in_place(&self, aad: &[u8], buf: &mut Vec<u8>) -> Result<(), EncryptError> {
96        let counter = self.send_counter.fetch_add(1, Ordering::Relaxed);
97        let nonce = self.make_nonce(counter);
98        self.send_key
99            .seal_in_place_append_tag(nonce, Aad::from(aad), buf)
100            .map_err(|_| EncryptError::EncryptionFailed)?;
101        Ok(())
102    }
103
104    /// Encrypt: allocates a new Vec with ciphertext.
105    #[inline]
106    pub fn encrypt(&self, aad: &[u8], plaintext: &[u8]) -> Result<Vec<u8>, EncryptError> {
107        let mut buf = Vec::with_capacity(plaintext.len() + AES_GCM_OVERHEAD);
108        buf.extend_from_slice(plaintext);
109        self.encrypt_in_place(aad, &mut buf)?;
110        Ok(buf)
111    }
112
113    /// Decrypt in place: verifies tag and truncates. Returns plaintext slice.
114    #[inline]
115    pub fn decrypt_in_place<'a>(
116        &self,
117        aad: &[u8],
118        buf: &'a mut [u8],
119    ) -> Result<&'a mut [u8], EncryptError> {
120        let counter = self.recv_counter.fetch_add(1, Ordering::Relaxed);
121        let nonce = self.make_nonce(counter);
122        self.recv_key
123            .open_in_place(nonce, Aad::from(aad), buf)
124            .map_err(|_| EncryptError::DecryptionFailed)
125    }
126
127    /// Decrypt: allocates a new Vec with plaintext.
128    #[inline]
129    pub fn decrypt(&self, aad: &[u8], ciphertext: &[u8]) -> Result<Vec<u8>, EncryptError> {
130        let mut buf = ciphertext.to_vec();
131        let plaintext = self.decrypt_in_place(aad, &mut buf)?;
132        let len = plaintext.len();
133        buf.truncate(len);
134        Ok(buf)
135    }
136
137    #[inline(always)]
138    fn make_nonce(&self, counter: u64) -> Nonce {
139        let mut n = [0u8; 12];
140        n[..4].copy_from_slice(&self.nonce_prefix);
141        n[4..12].copy_from_slice(&counter.to_be_bytes());
142        Nonce::assume_unique_for_key(n)
143    }
144}
145
146/// Encryption errors
147#[derive(Debug, Clone, Copy)]
148pub enum EncryptError {
149    EncryptionFailed,
150    DecryptionFailed,
151}
152
153impl std::fmt::Display for EncryptError {
154    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
155        match self {
156            Self::EncryptionFailed => write!(f, "Encryption failed"),
157            Self::DecryptionFailed => write!(f, "Decryption / authentication failed"),
158        }
159    }
160}
161
162impl std::error::Error for EncryptError {}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    #[test]
169    fn round_trip() {
170        let secret = [0xABu8; 32];
171        // Two "peers" derived from the same secret, but with swapped keys
172        // Two "peers" derived from the same secret, but with swapped keys
173        let session_a = AesSession::from_shared_secret(&secret).unwrap();
174        let session_b = AesSession::from_shared_secret_peer(&secret).unwrap();
175
176        let msg = b"Hello, PQC world!";
177        let ct = session_a.encrypt(&[], msg).expect("Encryption failed");
178
179        // session_b decrypt uses recv_key which matches session_a's send_key
180        let pt = session_b.decrypt(&[], &ct).expect("Decryption failed");
181        assert_eq!(&pt, msg);
182    }
183
184    #[test]
185    fn throughput_smoke() {
186        use std::time::Instant;
187
188        let session = AesSession::from_shared_secret(&[0xAB; 32]).unwrap();
189        let data = vec![0u8; 64 * 1024];
190        let iters = 50_000;
191
192        let start = Instant::now();
193        for _ in 0..iters {
194            let enc = session.encrypt(&[], &data).expect("Encryption failed");
195            std::hint::black_box(enc);
196        }
197        let elapsed = start.elapsed();
198
199        let total_mb = (data.len() * iters) as f64 / 1024.0 / 1024.0;
200        let throughput = total_mb / elapsed.as_secs_f64();
201        let backend = if cfg!(feature = "fips") {
202            "aws-lc-rs"
203        } else {
204            "ring"
205        };
206        eprintln!("{backend} AES-256-GCM: {throughput:.0} MiB/s");
207        // With HW AES should be well above 1 GiB/s
208    }
209}