Skip to main content

objectstore_service/
encryption.rs

1//! Authenticated encryption for serializable values.
2
3use std::collections::BTreeMap;
4use std::fmt;
5
6use ring::aead::{AES_256_GCM, Aad, LessSafeKey, Nonce, UnboundKey};
7use ring::rand::{SecureRandom, SystemRandom};
8use serde::{Serialize, de::DeserializeOwned};
9
10use crate::error::{Error, ErrorKind, Result, ResultExt as _};
11
12/// AES-GCM nonce length in bytes.
13const NONCE_LENGTH: usize = 12;
14/// AES-GCM authentication tag length in bytes.
15const TAG_LENGTH: usize = 16;
16/// Encrypted envelope format version.
17const FORMAT_VERSION: u8 = 0;
18
19/// Errors produced while decrypting an encrypted value.
20#[derive(Debug, thiserror::Error)]
21pub(crate) enum CipherError {
22    #[error("encrypted value is malformed")]
23    MalformedEnvelope,
24    #[error("unsupported encrypted value format version {0}")]
25    UnsupportedVersion(u8),
26    #[error("encrypted value has an invalid key ID")]
27    InvalidKeyId,
28    #[error("encrypted value could not be authenticated")]
29    Authentication,
30    #[error("encrypted value could not be deserialized")]
31    Deserialization(#[source] serde_json::Error),
32}
33
34/// Encrypts and decrypts serializable values with AES-256-GCM.
35///
36/// The active key encrypts new values, while the key ID embedded in an existing envelope selects
37/// its decryption key.
38pub struct Cipher {
39    active_key_id: String,
40    active_key: LessSafeKey,
41    decryption_keys: BTreeMap<String, LessSafeKey>,
42    random: SystemRandom,
43}
44
45impl Cipher {
46    /// Constructs a cipher with a fresh process-local AES-256-GCM key.
47    ///
48    /// Values encrypted with this key cannot be decrypted after a service restart. Configure a
49    /// persistent keyring with [`Self::new`] when encrypted values must survive restarts.
50    ///
51    /// Returns an error if secure random key generation fails.
52    pub fn ephemeral() -> anyhow::Result<Self> {
53        let key_id = "ephemeral";
54        let random = SystemRandom::new();
55        let mut key = [0; 32];
56        random
57            .fill(&mut key)
58            .map_err(|_| anyhow::anyhow!("failed to generate encryption key"))?;
59        Self::new(key_id, BTreeMap::from([(key_id.to_owned(), key.to_vec())]))
60    }
61
62    /// Validates and constructs a cipher from raw AES-256-GCM keys.
63    ///
64    /// Returns an error for invalid key IDs or sizes, or when the active key is absent.
65    pub fn new(
66        active_key_id: impl Into<String>,
67        keys: BTreeMap<String, Vec<u8>>,
68    ) -> anyhow::Result<Self> {
69        let active_key_id = active_key_id.into();
70        validate_key_id(&active_key_id)?;
71
72        let mut validated = BTreeMap::new();
73        for (key_id, key) in keys {
74            validate_key_id(&key_id)?;
75            anyhow::ensure!(
76                key.len() == AES_256_GCM.key_len(),
77                "encryption key {key_id:?} must contain exactly 32 bytes, got {}",
78                key.len()
79            );
80            let key = UnboundKey::new(&AES_256_GCM, &key)
81                .map(LessSafeKey::new)
82                .map_err(|_| anyhow::anyhow!("invalid encryption key material"))?;
83            validated.insert(key_id, key);
84        }
85
86        let active_key = validated.remove(&active_key_id).ok_or_else(|| {
87            anyhow::anyhow!("active encryption key {active_key_id:?} is not configured")
88        })?;
89
90        Ok(Self {
91            active_key_id,
92            active_key,
93            decryption_keys: validated,
94            random: SystemRandom::new(),
95        })
96    }
97
98    /// Encrypts a serializable value.
99    ///
100    /// Envelope format:
101    /// ```text
102    /// +------------++------------------------------+------------------------+---------------------------+--------------------+
103    /// |            || Header                       | Nonce                  | Ciphertext                | Authentication tag |
104    /// +------------++------------------------------+------------------------+---------------------------+--------------------+
105    /// | Contains   || version + ID length + key ID | random                 | serde_json(value)         | AES-GCM verifier   |
106    /// | Protection || public (authenticated)       | public (authenticated) | encrypted + authenticated | public; checked    |
107    /// | Encoding   || 1 B + 1 B + UTF-8            | 12 B                   | variable                  | 16 B               |
108    /// +------------++------------------------------+------------------------+---------------------------+--------------------+
109    /// ```
110    pub(crate) fn encrypt<T>(&self, value: &T) -> Result<Vec<u8>>
111    where
112        T: Serialize + ?Sized,
113    {
114        let key_id = self.active_key_id.as_bytes();
115        let key_id_length = u8::try_from(key_id.len()).context(
116            ErrorKind::Internal,
117            "encryption key ID exceeds maximum length",
118        )?;
119
120        // Plaintext header.
121        let mut header = Vec::with_capacity(2 + key_id.len());
122        header.push(FORMAT_VERSION);
123        header.push(key_id_length);
124        header.extend_from_slice(key_id);
125
126        let mut ciphertext = serde_json::to_vec(value)
127            .context(ErrorKind::Internal, "failed to serialize encrypted value")?;
128
129        let mut nonce = [0; NONCE_LENGTH];
130        self.random
131            .fill(&mut nonce)
132            .map_err(|_| Error::new(ErrorKind::Internal, "failed to generate encryption nonce"))?;
133
134        // Encrypt the serialized value in place and append the authentication tag.
135        // The tag also authenticates the plaintext header.
136        self.active_key
137            .seal_in_place_append_tag(
138                Nonce::assume_unique_for_key(nonce),
139                Aad::from(&header),
140                &mut ciphertext,
141            )
142            .map_err(|_| Error::new(ErrorKind::Internal, "failed to encrypt value"))?;
143
144        let mut envelope = Vec::with_capacity(header.len() + nonce.len() + ciphertext.len());
145        envelope.extend_from_slice(&header);
146        envelope.extend_from_slice(&nonce);
147        envelope.extend_from_slice(&ciphertext);
148        Ok(envelope)
149    }
150
151    /// Decrypts and deserializes an encrypted value.
152    pub(crate) fn decrypt<T>(&self, envelope: &[u8]) -> std::result::Result<T, CipherError>
153    where
154        T: DeserializeOwned,
155    {
156        let (&version, rest) = envelope
157            .split_first()
158            .ok_or(CipherError::MalformedEnvelope)?;
159        if version != FORMAT_VERSION {
160            return Err(CipherError::UnsupportedVersion(version));
161        }
162
163        // Parse the plaintext header and nonce.
164        // They remain untrusted until AES-GCM verifies the authentication tag below.
165        let (&key_id_length, rest) = rest.split_first().ok_or(CipherError::MalformedEnvelope)?;
166        let key_id_length = usize::from(key_id_length);
167        if key_id_length == 0 {
168            return Err(CipherError::MalformedEnvelope);
169        }
170        let (key_id, rest) = rest
171            .split_at_checked(key_id_length)
172            .ok_or(CipherError::MalformedEnvelope)?;
173        let (nonce, ciphertext) = rest
174            .split_at_checked(NONCE_LENGTH)
175            .ok_or(CipherError::MalformedEnvelope)?;
176
177        // Use the plaintext key ID to select a key, without trusting the ID until authentication.
178        let key_id = std::str::from_utf8(key_id).map_err(|_| CipherError::InvalidKeyId)?;
179        let key = if key_id == self.active_key_id {
180            &self.active_key
181        } else {
182            self.decryption_keys
183                .get(key_id)
184                .ok_or(CipherError::InvalidKeyId)?
185        };
186        let header_length = 2 + key_id_length;
187        let header = &envelope[..header_length];
188        let nonce: [u8; NONCE_LENGTH] = nonce.try_into().expect("nonce length was checked");
189        if ciphertext.len() < TAG_LENGTH {
190            return Err(CipherError::MalformedEnvelope);
191        }
192
193        // Verify the tag over the ciphertext and original header.
194        let mut ciphertext = ciphertext.to_vec();
195        let plaintext = key
196            .open_in_place(
197                Nonce::assume_unique_for_key(nonce),
198                Aad::from(header),
199                &mut ciphertext,
200            )
201            .map_err(|_| CipherError::Authentication)?;
202
203        serde_json::from_slice(plaintext).map_err(CipherError::Deserialization)
204    }
205}
206
207impl fmt::Debug for Cipher {
208    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
209        let mut key_ids = self.decryption_keys.keys().collect::<Vec<_>>();
210        key_ids.push(&self.active_key_id);
211        key_ids.sort_unstable();
212
213        f.debug_struct("Cipher")
214            .field("active_key_id", &self.active_key_id)
215            .field("key_ids", &key_ids)
216            .finish_non_exhaustive()
217    }
218}
219
220fn validate_key_id(key_id: &str) -> anyhow::Result<()> {
221    let key_id_length = key_id.len();
222    u8::try_from(key_id_length).map_err(|_| {
223        anyhow::anyhow!(
224            "encryption key ID must be at most {} bytes, got {key_id_length}",
225            u8::MAX
226        )
227    })?;
228    anyhow::ensure!(
229        !key_id.is_empty()
230            && key_id
231                .bytes()
232                .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')),
233        "invalid encryption key ID {key_id:?}"
234    );
235    Ok(())
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241
242    fn cipher(active: &str, keys: &[(&str, u8)]) -> Cipher {
243        Cipher::new(
244            active,
245            keys.iter()
246                .map(|(key_id, byte)| (key_id.to_string(), vec![*byte; 32]))
247                .collect(),
248        )
249        .unwrap()
250    }
251
252    #[test]
253    fn encryption_is_randomized_and_supports_multiple_types() {
254        let cipher = cipher("v1", &[("v1", 7)]);
255        let value = vec!["first".to_owned(), "second".to_owned()];
256
257        let first = cipher.encrypt(&value).unwrap();
258        let second = cipher.encrypt(&value).unwrap();
259
260        assert_ne!(first, second);
261        assert_eq!(first[0], FORMAT_VERSION);
262        assert_eq!(cipher.decrypt::<Vec<String>>(&first).unwrap(), value);
263
264        let number = cipher.encrypt(&42_u64).unwrap();
265        assert_eq!(cipher.decrypt::<u64>(&number).unwrap(), 42);
266    }
267
268    #[test]
269    fn encryption_rejects_tampering_plaintext_and_wrong_types() {
270        let cipher = cipher("v1", &[("v1", 7)]);
271        let token = cipher.encrypt("value").unwrap();
272
273        let mut tampered = token.clone();
274        *tampered.last_mut().unwrap() ^= 1;
275        assert!(matches!(
276            cipher.decrypt::<String>(&tampered),
277            Err(CipherError::Authentication)
278        ));
279
280        let mut unsupported_version = token.clone();
281        unsupported_version[0] = FORMAT_VERSION + 1;
282        assert!(matches!(
283            cipher.decrypt::<String>(&unsupported_version),
284            Err(CipherError::UnsupportedVersion(_))
285        ));
286        assert!(cipher.decrypt::<String>(b"plaintext").is_err());
287        assert!(matches!(
288            cipher.decrypt::<u64>(&token),
289            Err(CipherError::Deserialization(_))
290        ));
291    }
292
293    #[test]
294    fn rotation_decrypts_old_keys_and_removal_invalidates_them() {
295        let old = cipher("v1", &[("v1", 1)]);
296        let old_value = old.encrypt("old value").unwrap();
297
298        let rotated = cipher("v2", &[("v1", 1), ("v2", 2)]);
299        assert_eq!(rotated.decrypt::<String>(&old_value).unwrap(), "old value");
300        let new_value = rotated.encrypt("new value").unwrap();
301        assert_eq!(new_value[2..4], *b"v2");
302
303        let removed = cipher("v2", &[("v2", 2)]);
304        assert!(matches!(
305            removed.decrypt::<String>(&old_value),
306            Err(CipherError::InvalidKeyId)
307        ));
308    }
309
310    #[test]
311    fn configuration_validates_ids_lengths_and_active_key() {
312        let error = Cipher::new("missing", BTreeMap::new()).unwrap_err();
313        assert_eq!(
314            error.to_string(),
315            "active encryption key \"missing\" is not configured"
316        );
317
318        let error =
319            Cipher::new("bad key", BTreeMap::from([("bad key".into(), vec![0; 32])])).unwrap_err();
320        assert_eq!(error.to_string(), "invalid encryption key ID \"bad key\"");
321
322        let error = Cipher::new("v1", BTreeMap::from([("v1".into(), vec![0; 31])])).unwrap_err();
323        assert_eq!(
324            error.to_string(),
325            "encryption key \"v1\" must contain exactly 32 bytes, got 31"
326        );
327    }
328}