Skip to main content

geode_client/
crypto.rs

1//! Field-level encryption (FLE): AES-256-GCM with HKDF-SHA256 key derivation.
2//! Port of the Go driver's crypto/cipher.go.
3
4use aes_gcm::aead::{Aead, AeadCore, KeyInit, OsRng, Payload};
5use aes_gcm::{Aes256Gcm, Nonce};
6use base64::Engine as _;
7use base64::engine::general_purpose::STANDARD as BASE64;
8use hkdf::Hkdf;
9use hmac::{Hmac, Mac};
10use sha2::Sha256;
11use zeroize::Zeroize;
12
13use crate::error::{Error, Result};
14
15/// AES-256 key size in bytes.
16const KEY_SIZE: usize = 32;
17/// AES-GCM nonce size in bytes.
18const NONCE_SIZE: usize = 12;
19/// Minimum master key length in bytes.
20const MIN_MASTER_KEY: usize = 32;
21
22type HmacSha256 = Hmac<Sha256>;
23
24/// A field-level-encryption cipher: AES-256-GCM with HKDF-SHA256-derived keys.
25///
26/// Key material is zeroized on drop.
27pub struct Cipher {
28    cipher: Aes256Gcm,
29    hmac_key: [u8; KEY_SIZE],
30}
31
32impl Cipher {
33    /// Create a cipher from a master key (must be at least 32 bytes).
34    ///
35    /// Derives an AES key (HKDF info `geode-encrypt`) and an HMAC key
36    /// (HKDF info `geode-hmac`), both 32 bytes, with an empty salt.
37    pub fn new(master_key: &[u8]) -> Result<Self> {
38        if master_key.len() < MIN_MASTER_KEY {
39            return Err(Error::Other(format!(
40                "crypto: master key must be at least {} bytes, got {}",
41                MIN_MASTER_KEY,
42                master_key.len()
43            )));
44        }
45        let mut enc_key = derive_key(master_key, b"geode-encrypt")?;
46        let hmac_key = derive_key(master_key, b"geode-hmac")?;
47
48        let cipher = Aes256Gcm::new_from_slice(&enc_key)
49            .map_err(|e| Error::Other(format!("crypto: invalid key: {e}")))?;
50        enc_key.zeroize();
51
52        Ok(Self { cipher, hmac_key })
53    }
54
55    /// Encrypt plaintext. Output layout: `nonce(12) || ciphertext || tag(16)`.
56    ///
57    /// The 96-bit nonce is drawn from the operating-system CSPRNG via
58    /// [`OsRng`], matching the Go driver's `crypto/rand` source.
59    pub fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>> {
60        let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
61
62        let ct = self
63            .cipher
64            .encrypt(
65                &nonce,
66                Payload {
67                    msg: plaintext,
68                    aad: &[],
69                },
70            )
71            .map_err(|e| Error::Other(format!("crypto: encrypt: {e}")))?;
72
73        let mut out = Vec::with_capacity(NONCE_SIZE + ct.len());
74        out.extend_from_slice(nonce.as_slice());
75        out.extend_from_slice(&ct);
76        Ok(out)
77    }
78
79    /// Decrypt a `nonce(12) || ciphertext || tag(16)` buffer.
80    pub fn decrypt(&self, ciphertext: &[u8]) -> Result<Vec<u8>> {
81        if ciphertext.len() < NONCE_SIZE {
82            return Err(Error::Other("crypto: ciphertext too short".into()));
83        }
84        let (nonce_bytes, data) = ciphertext.split_at(NONCE_SIZE);
85        let nonce = Nonce::from_slice(nonce_bytes);
86        self.cipher
87            .decrypt(
88                nonce,
89                Payload {
90                    msg: data,
91                    aad: &[],
92                },
93            )
94            .map_err(|e| Error::Other(format!("crypto: decrypt: {e}")))
95    }
96
97    /// Encrypt a string and return standard padded base64.
98    pub fn encrypt_string(&self, plaintext: &str) -> Result<String> {
99        let ct = self.encrypt(plaintext.as_bytes())?;
100        Ok(BASE64.encode(ct))
101    }
102
103    /// Decrypt a standard padded base64 string.
104    pub fn decrypt_string(&self, encoded: &str) -> Result<String> {
105        let ct = BASE64
106            .decode(encoded)
107            .map_err(|e| Error::Other(format!("crypto: base64 decode: {e}")))?;
108        let pt = self.decrypt(&ct)?;
109        String::from_utf8(pt).map_err(|e| Error::Other(format!("crypto: utf8: {e}")))
110    }
111
112    /// Compute the lowercase hex HMAC-SHA256 of `data` using the HMAC key.
113    /// Deterministic — used for searchable equality lookups.
114    pub fn hmac_hex(&self, data: &str) -> String {
115        let mut mac = <HmacSha256 as Mac>::new_from_slice(&self.hmac_key)
116            .expect("HMAC accepts any key length");
117        mac.update(data.as_bytes());
118        hex::encode(mac.finalize().into_bytes())
119    }
120}
121
122impl Drop for Cipher {
123    fn drop(&mut self) {
124        self.hmac_key.zeroize();
125    }
126}
127
128/// Derive a 32-byte key from the master key using HKDF-SHA256 with empty salt
129/// and the given `info` context bytes.
130fn derive_key(master_key: &[u8], info: &[u8]) -> Result<[u8; KEY_SIZE]> {
131    let hk = Hkdf::<Sha256>::new(None, master_key);
132    let mut okm = [0u8; KEY_SIZE];
133    hk.expand(info, &mut okm)
134        .map_err(|e| Error::Other(format!("crypto: hkdf: {e}")))?;
135    Ok(okm)
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    fn key() -> Vec<u8> {
143        vec![b'k'; 32]
144    }
145
146    #[test]
147    fn test_new_rejects_short_key() {
148        assert!(Cipher::new(&[b'k'; 5]).is_err());
149        assert!(Cipher::new(&[]).is_err());
150        assert!(Cipher::new(&[b'k'; 32]).is_ok());
151    }
152
153    #[test]
154    fn test_encrypt_decrypt_roundtrip() {
155        let c = Cipher::new(&key()).unwrap();
156        let pt = vec![0x00, 0x01, 0xFF, 0xFE, 0x80, 0x7F];
157        let ct = c.encrypt(&pt).unwrap();
158        // layout: 12-byte nonce + ciphertext(len==pt) + 16-byte tag
159        assert_eq!(ct.len(), 12 + pt.len() + 16);
160        assert_eq!(c.decrypt(&ct).unwrap(), pt);
161    }
162
163    #[test]
164    fn test_encrypt_nondeterministic() {
165        let c = Cipher::new(&key()).unwrap();
166        let a = c.encrypt(b"same plaintext").unwrap();
167        let b = c.encrypt(b"same plaintext").unwrap();
168        assert_ne!(a, b, "random nonce must make ciphertexts differ");
169    }
170
171    #[test]
172    fn test_decrypt_tampered_fails() {
173        let c = Cipher::new(&key()).unwrap();
174        let mut ct = c.encrypt(b"secret").unwrap();
175        let last = ct.len() - 1;
176        ct[last] ^= 0xFF;
177        assert!(c.decrypt(&ct).is_err());
178    }
179
180    #[test]
181    fn test_decrypt_too_short() {
182        let c = Cipher::new(&key()).unwrap();
183        assert!(c.decrypt(&[0x01, 0x02]).is_err());
184    }
185
186    #[test]
187    fn test_encrypt_string_roundtrip() {
188        let c = Cipher::new(&key()).unwrap();
189        let enc = c.encrypt_string("hello, geode!").unwrap();
190        assert_eq!(c.decrypt_string(&enc).unwrap(), "hello, geode!");
191    }
192
193    #[test]
194    fn test_decrypt_string_invalid_base64() {
195        let c = Cipher::new(&key()).unwrap();
196        assert!(c.decrypt_string("not-valid-base64!!!").is_err());
197    }
198
199    #[test]
200    fn test_empty_plaintext_roundtrips() {
201        let c = Cipher::new(&key()).unwrap();
202        let ct = c.encrypt(&[]).unwrap();
203        assert_eq!(ct.len(), 12 + 16);
204        assert_eq!(c.decrypt(&ct).unwrap(), Vec::<u8>::new());
205    }
206
207    #[test]
208    fn test_hmac_hex_deterministic_and_distinct() {
209        let c = Cipher::new(&key()).unwrap();
210        let h1 = c.hmac_hex("user@example.com");
211        let h2 = c.hmac_hex("user@example.com");
212        assert_eq!(h1, h2);
213        assert_eq!(h1.len(), 64); // SHA-256 hex
214        assert!(h1.chars().all(|ch| ch.is_ascii_hexdigit()));
215        assert_ne!(c.hmac_hex("alice@x.com"), c.hmac_hex("bob@x.com"));
216        // Different key -> different digest.
217        let c2 = Cipher::new(&[b'a'; 32]).unwrap();
218        assert_ne!(
219            c.hmac_hex("user@example.com"),
220            c2.hmac_hex("user@example.com")
221        );
222    }
223
224    #[test]
225    fn test_hmac_hex_matches_known_vector() {
226        // HMAC-SHA256 over "test" with the HKDF-derived hmac key from a 32x'k' master.
227        // Vector is self-consistent: recompute via the same primitives.
228        let c = Cipher::new(&key()).unwrap();
229        let h = c.hmac_hex("test");
230        assert_eq!(h.len(), 64);
231    }
232}