Skip to main content

brainwires_network/ipc/
crypto.rs

1//! IPC Encryption using ChaCha20-Poly1305
2//!
3//! Provides authenticated encryption for IPC messages between CLI processes.
4//! Uses a shared key derived from the session token for symmetric encryption.
5
6use anyhow::{Context, Result};
7use chacha20poly1305::{
8    ChaCha20Poly1305, Nonce,
9    aead::{Aead, KeyInit},
10};
11use rand::Rng;
12use sha2::{Digest, Sha256};
13use zeroize::Zeroizing;
14
15/// Key size for ChaCha20-Poly1305 (256 bits)
16const KEY_SIZE: usize = 32;
17/// Nonce size for ChaCha20-Poly1305 (96 bits)
18const NONCE_SIZE: usize = 12;
19
20/// Encrypted message format:
21/// [nonce (12 bytes)][ciphertext (variable)][auth tag (16 bytes, included in ciphertext)]
22pub struct IpcCipher {
23    cipher: ChaCha20Poly1305,
24}
25
26impl IpcCipher {
27    /// Create a new cipher from a session token
28    ///
29    /// The session token is hashed with SHA-256 to derive the encryption key.
30    /// This ensures a consistent 256-bit key regardless of token length.
31    pub fn from_session_token(token: &str) -> Self {
32        let key = derive_key_from_token(token);
33        let cipher = ChaCha20Poly1305::new_from_slice(key.as_slice())
34            .expect("Key is always 32 bytes from SHA-256");
35        Self { cipher }
36    }
37
38    /// Encrypt a message
39    ///
40    /// Returns the encrypted message with nonce prepended.
41    /// Format: [nonce (12 bytes)][ciphertext + auth tag]
42    pub fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>> {
43        // Generate random nonce
44        let mut nonce_bytes = [0u8; NONCE_SIZE];
45        rand::rng().fill_bytes(&mut nonce_bytes);
46        let nonce = Nonce::from_slice(&nonce_bytes);
47
48        // Encrypt with authentication
49        let ciphertext = self
50            .cipher
51            .encrypt(nonce, plaintext)
52            .map_err(|e| anyhow::anyhow!("Encryption failed: {}", e))?;
53
54        // Prepend nonce to ciphertext
55        let mut result = Vec::with_capacity(NONCE_SIZE + ciphertext.len());
56        result.extend_from_slice(&nonce_bytes);
57        result.extend_from_slice(&ciphertext);
58
59        Ok(result)
60    }
61
62    /// Decrypt a message
63    ///
64    /// Expects format: [nonce (12 bytes)][ciphertext + auth tag]
65    pub fn decrypt(&self, encrypted: &[u8]) -> Result<Vec<u8>> {
66        if encrypted.len() < NONCE_SIZE {
67            anyhow::bail!("Encrypted message too short (missing nonce)");
68        }
69
70        // Extract nonce and ciphertext
71        let (nonce_bytes, ciphertext) = encrypted.split_at(NONCE_SIZE);
72        let nonce = Nonce::from_slice(nonce_bytes);
73
74        // Decrypt and verify authentication
75        let plaintext = self
76            .cipher
77            .decrypt(nonce, ciphertext)
78            .map_err(|_| anyhow::anyhow!("Decryption failed (authentication failed)"))?;
79
80        Ok(plaintext)
81    }
82
83    /// Encrypt a string message to base64
84    ///
85    /// Convenient method for encrypting string messages.
86    pub fn encrypt_string(&self, plaintext: &str) -> Result<String> {
87        let encrypted = self.encrypt(plaintext.as_bytes())?;
88        Ok(base64::Engine::encode(
89            &base64::engine::general_purpose::STANDARD,
90            &encrypted,
91        ))
92    }
93
94    /// Decrypt a base64 message to string
95    ///
96    /// Convenient method for decrypting string messages.
97    pub fn decrypt_string(&self, encrypted_b64: &str) -> Result<String> {
98        let encrypted =
99            base64::Engine::decode(&base64::engine::general_purpose::STANDARD, encrypted_b64)
100                .context("Invalid base64 encoding")?;
101
102        let plaintext = self.decrypt(&encrypted)?;
103        String::from_utf8(plaintext).context("Decrypted data is not valid UTF-8")
104    }
105}
106
107/// Derive a 256-bit key from a session token using SHA-256
108fn derive_key_from_token(token: &str) -> Zeroizing<[u8; KEY_SIZE]> {
109    let mut hasher = Sha256::new();
110    // Add a domain separator to prevent key reuse across different contexts
111    hasher.update(b"brainwires-ipc-v1:");
112    hasher.update(token.as_bytes());
113
114    let result = hasher.finalize();
115    let mut key = Zeroizing::new([0u8; KEY_SIZE]);
116    key.copy_from_slice(&result);
117    key
118}
119
120/// Generate a random encryption key (for testing or direct use)
121pub fn generate_random_key() -> Zeroizing<[u8; KEY_SIZE]> {
122    let mut key = Zeroizing::new([0u8; KEY_SIZE]);
123    rand::rng().fill_bytes(&mut *key);
124    key
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    #[test]
132    fn test_encrypt_decrypt_roundtrip() {
133        let token = "test-session-token-12345";
134        let cipher = IpcCipher::from_session_token(token);
135
136        let plaintext = b"Hello, this is a secret message!";
137        let encrypted = cipher.encrypt(plaintext).unwrap();
138
139        // Encrypted should be different from plaintext
140        assert_ne!(encrypted.as_slice(), plaintext);
141
142        // Decrypt should recover original
143        let decrypted = cipher.decrypt(&encrypted).unwrap();
144        assert_eq!(decrypted, plaintext);
145    }
146
147    #[test]
148    fn test_encrypt_decrypt_string() {
149        let token = "string-test-token";
150        let cipher = IpcCipher::from_session_token(token);
151
152        let message = "This is a JSON message: {\"key\": \"value\"}";
153        let encrypted = cipher.encrypt_string(message).unwrap();
154
155        // Encrypted is base64
156        assert!(
157            encrypted
158                .chars()
159                .all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '/' || c == '=')
160        );
161
162        let decrypted = cipher.decrypt_string(&encrypted).unwrap();
163        assert_eq!(decrypted, message);
164    }
165
166    #[test]
167    fn test_different_tokens_different_ciphertext() {
168        let cipher1 = IpcCipher::from_session_token("token1");
169        let cipher2 = IpcCipher::from_session_token("token2");
170
171        let plaintext = b"Same message";
172        let encrypted1 = cipher1.encrypt(plaintext).unwrap();
173        let encrypted2 = cipher2.encrypt(plaintext).unwrap();
174
175        // Different tokens should produce different ciphertexts
176        // (also nonces are random, so even same token would differ)
177        assert_ne!(encrypted1, encrypted2);
178
179        // Can't decrypt with wrong key
180        assert!(cipher2.decrypt(&encrypted1).is_err());
181        assert!(cipher1.decrypt(&encrypted2).is_err());
182    }
183
184    #[test]
185    fn test_tamper_detection() {
186        let token = "tamper-test";
187        let cipher = IpcCipher::from_session_token(token);
188
189        let plaintext = b"Original message";
190        let mut encrypted = cipher.encrypt(plaintext).unwrap();
191
192        // Tamper with the ciphertext
193        if let Some(byte) = encrypted.last_mut() {
194            *byte ^= 0xFF;
195        }
196
197        // Decryption should fail due to authentication failure
198        assert!(cipher.decrypt(&encrypted).is_err());
199    }
200
201    #[test]
202    fn test_empty_message() {
203        let token = "empty-test";
204        let cipher = IpcCipher::from_session_token(token);
205
206        let plaintext = b"";
207        let encrypted = cipher.encrypt(plaintext).unwrap();
208        let decrypted = cipher.decrypt(&encrypted).unwrap();
209
210        assert_eq!(decrypted, plaintext);
211    }
212
213    #[test]
214    fn test_large_message() {
215        let token = "large-test";
216        let cipher = IpcCipher::from_session_token(token);
217
218        // 1 MB message
219        let plaintext: Vec<u8> = (0..1_000_000).map(|i| (i % 256) as u8).collect();
220        let encrypted = cipher.encrypt(&plaintext).unwrap();
221        let decrypted = cipher.decrypt(&encrypted).unwrap();
222
223        assert_eq!(decrypted, plaintext);
224    }
225
226    #[test]
227    fn test_key_derivation_deterministic() {
228        let token = "deterministic-test";
229
230        let cipher1 = IpcCipher::from_session_token(token);
231        let cipher2 = IpcCipher::from_session_token(token);
232
233        // Same token should allow decryption
234        let plaintext = b"Test message";
235        let encrypted = cipher1.encrypt(plaintext).unwrap();
236        let decrypted = cipher2.decrypt(&encrypted).unwrap();
237
238        assert_eq!(decrypted, plaintext);
239    }
240}