Skip to main content

secrets_core/
crypto.rs

1use aes_gcm::Aes256Gcm;
2use aes_gcm::aead::array::Array;
3use aes_gcm::aead::{Aead as _, Generate, KeyInit, Nonce};
4use sha2::{Digest, Sha256};
5use thiserror::Error;
6
7#[derive(Debug, Error)]
8pub enum CryptoError {
9    #[error("encryption failed")]
10    Seal,
11    #[error("decryption failed (tampered or wrong key)")]
12    Open,
13}
14
15pub trait Aead: Send + Sync {
16    fn seal(&self, plaintext: &[u8]) -> Result<Vec<u8>, CryptoError>;
17    fn open(&self, ciphertext: &[u8]) -> Result<Vec<u8>, CryptoError>;
18
19    /// Whether this blob is already sealed under the key `seal` would use.
20    /// Rewrapping asks this so it can skip values that need no work; a
21    /// single-key implementation has nothing to rotate to, hence the default.
22    fn is_current(&self, _blob: &[u8]) -> bool {
23        true
24    }
25
26    /// A short, stable label for the key `seal` is using, so an operator can
27    /// confirm which key a replica actually holds.
28    fn active_key_id(&self) -> String {
29        "unversioned".to_string()
30    }
31}
32
33/// Marks a blob that carries its key id. Values written before key rotation
34/// existed begin directly with the nonce and carry no id, so they are
35/// recognised by *failing* to decrypt as versioned — the GCM tag is what makes
36/// that safe rather than a guess.
37const VERSIONED_MARKER: u8 = 0x01;
38const KEY_ID_LEN: usize = 4;
39const NONCE_LEN: usize = 12;
40
41/// Identifies a key by a prefix of its own SHA-256, rather than by a
42/// configured label. Nothing to keep in sync between replicas, and an
43/// operator cannot mislabel a key.
44pub fn key_id(key: &[u8; 32]) -> u32 {
45    let digest = Sha256::digest(key);
46    u32::from_be_bytes(digest[..KEY_ID_LEN].try_into().expect("sha256 is 32 bytes"))
47}
48
49struct Key {
50    id: u32,
51    cipher: Aes256Gcm,
52}
53
54/// One active key for sealing plus any number of retired keys kept only for
55/// opening. This is what makes the master key rotatable: run with both, rewrap
56/// the store, then drop the old key.
57pub struct KeyRing {
58    active: Key,
59    retired: Vec<Key>,
60}
61
62impl KeyRing {
63    pub fn new(active: &[u8; 32], retired: &[[u8; 32]]) -> Self {
64        Self {
65            active: Key {
66                id: key_id(active),
67                cipher: Aes256Gcm::new(active.into()),
68            },
69            retired: retired
70                .iter()
71                .map(|key| Key {
72                    id: key_id(key),
73                    cipher: Aes256Gcm::new(key.into()),
74                })
75                .collect(),
76        }
77    }
78
79    pub fn active_key_id(&self) -> u32 {
80        self.active.id
81    }
82
83    /// The number of keys that exist only to read old data. Zero means the
84    /// store is fully rewrapped, or was never rotated.
85    pub fn retired_key_count(&self) -> usize {
86        self.retired.len()
87    }
88
89    /// The key id recorded in a blob, or `None` for a pre-rotation value.
90    fn embedded_key_id(blob: &[u8]) -> Option<u32> {
91        if blob.len() < 1 + KEY_ID_LEN + NONCE_LEN || blob[0] != VERSIONED_MARKER {
92            return None;
93        }
94        Some(u32::from_be_bytes(
95            blob[1..1 + KEY_ID_LEN].try_into().expect("checked length"),
96        ))
97    }
98
99    fn keys(&self) -> impl Iterator<Item = &Key> {
100        std::iter::once(&self.active).chain(self.retired.iter())
101    }
102}
103
104impl Aead for KeyRing {
105    fn seal(&self, plaintext: &[u8]) -> Result<Vec<u8>, CryptoError> {
106        let nonce = Nonce::<Aes256Gcm>::generate();
107        let ciphertext = self
108            .active
109            .cipher
110            .encrypt(&nonce, plaintext)
111            .map_err(|_| CryptoError::Seal)?;
112
113        let mut out = Vec::with_capacity(1 + KEY_ID_LEN + nonce.len() + ciphertext.len());
114        out.push(VERSIONED_MARKER);
115        out.extend_from_slice(&self.active.id.to_be_bytes());
116        out.extend_from_slice(&nonce);
117        out.extend_from_slice(&ciphertext);
118        Ok(out)
119    }
120
121    fn open(&self, blob: &[u8]) -> Result<Vec<u8>, CryptoError> {
122        // Versioned: go straight to the named key. A blob whose id is not in
123        // the ring means the operator dropped a key that is still in use.
124        if let Some(id) = Self::embedded_key_id(blob)
125            && let Some(key) = self.keys().find(|key| key.id == id)
126        {
127            let (nonce, ciphertext) = blob[1 + KEY_ID_LEN..].split_at(NONCE_LEN);
128            let nonce = Array::try_from(nonce).map_err(|_| CryptoError::Open)?;
129            if let Ok(plaintext) = key.cipher.decrypt(&nonce, ciphertext) {
130                return Ok(plaintext);
131            }
132        }
133
134        // Pre-rotation layout: the whole blob is `nonce || ciphertext`. Trying
135        // every key is safe because GCM authenticates — a wrong key cannot
136        // produce a plausible plaintext.
137        if blob.len() >= NONCE_LEN {
138            let (nonce, ciphertext) = blob.split_at(NONCE_LEN);
139            if let Ok(nonce) = Array::try_from(nonce) {
140                for key in self.keys() {
141                    if let Ok(plaintext) = key.cipher.decrypt(&nonce, ciphertext) {
142                        return Ok(plaintext);
143                    }
144                }
145            }
146        }
147        Err(CryptoError::Open)
148    }
149
150    fn is_current(&self, blob: &[u8]) -> bool {
151        Self::embedded_key_id(blob) == Some(self.active.id)
152    }
153
154    fn active_key_id(&self) -> String {
155        format!("{:08x}", self.active.id)
156    }
157}
158
159/// AES-256-GCM in the pre-rotation layout: `nonce || ciphertext || tag`, with
160/// no key id. Retained because it is what every value written before 1.0 looks
161/// like, and `KeyRing` still reads that form — new deployments should use
162/// `KeyRing`, which is what the server wires up.
163pub struct Aes256GcmAead {
164    cipher: Aes256Gcm,
165}
166
167impl Aes256GcmAead {
168    pub fn new(key: &[u8; 32]) -> Self {
169        Self {
170            cipher: Aes256Gcm::new(key.into()),
171        }
172    }
173}
174
175impl Aead for Aes256GcmAead {
176    fn seal(&self, plaintext: &[u8]) -> Result<Vec<u8>, CryptoError> {
177        let nonce = Nonce::<Aes256Gcm>::generate();
178        let ciphertext = self
179            .cipher
180            .encrypt(&nonce, plaintext)
181            .map_err(|_| CryptoError::Seal)?;
182        let mut out = Vec::with_capacity(nonce.len() + ciphertext.len());
183        out.extend_from_slice(&nonce);
184        out.extend_from_slice(&ciphertext);
185        Ok(out)
186    }
187
188    fn open(&self, blob: &[u8]) -> Result<Vec<u8>, CryptoError> {
189        if blob.len() < 12 {
190            return Err(CryptoError::Open);
191        }
192        let (nonce, ciphertext) = blob.split_at(12);
193        let nonce = Array::try_from(nonce).map_err(|_| CryptoError::Open)?;
194        self.cipher
195            .decrypt(&nonce, ciphertext)
196            .map_err(|_| CryptoError::Open)
197    }
198}
199
200pub trait MasterKeyProvider: Send + Sync {
201    fn current_key(&self) -> [u8; 32];
202
203    /// Keys kept only so previously-written values still open. Empty unless a
204    /// rotation is in progress.
205    fn retired_keys(&self) -> Vec<[u8; 32]> {
206        Vec::new()
207    }
208}
209
210/// v1 master key source: a hex-encoded 32-byte key from an env var, or (if
211/// the env var holds a path instead) read from a file. Swappable later for
212/// a KMS-backed provider without touching `Barrier` or its callers.
213pub struct StaticMasterKeyProvider {
214    key: [u8; 32],
215    retired: Vec<[u8; 32]>,
216}
217
218impl StaticMasterKeyProvider {
219    pub fn from_hex(hex_key: &str) -> Result<Self, CryptoError> {
220        Ok(Self {
221            key: decode_key(hex_key)?,
222            retired: Vec::new(),
223        })
224    }
225
226    pub fn from_env(var: &str) -> Result<Self, CryptoError> {
227        Self::from_hex(&read_env_or_file(var)?)
228    }
229
230    /// Adds decrypt-only keys from a comma-separated env var. Absent or empty
231    /// is normal — it means no rotation is in flight.
232    pub fn with_retired_from_env(mut self, var: &str) -> Result<Self, CryptoError> {
233        let Ok(raw) = read_env_or_file(var) else {
234            return Ok(self);
235        };
236        for candidate in raw.split(',').map(str::trim).filter(|c| !c.is_empty()) {
237            self.retired.push(decode_key(candidate)?);
238        }
239        Ok(self)
240    }
241}
242
243fn decode_key(hex_key: &str) -> Result<[u8; 32], CryptoError> {
244    let bytes = hex::decode(hex_key.trim()).map_err(|_| CryptoError::Seal)?;
245    bytes.try_into().map_err(|_| CryptoError::Seal)
246}
247
248fn read_env_or_file(var: &str) -> Result<String, CryptoError> {
249    let value = std::env::var(var).map_err(|_| CryptoError::Seal)?;
250    // The env var may hold the key itself or a path to a file containing it.
251    Ok(std::fs::read_to_string(&value).unwrap_or(value))
252}
253
254impl MasterKeyProvider for StaticMasterKeyProvider {
255    fn current_key(&self) -> [u8; 32] {
256        self.key
257    }
258
259    fn retired_keys(&self) -> Vec<[u8; 32]> {
260        self.retired.clone()
261    }
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267
268    fn aead() -> Aes256GcmAead {
269        Aes256GcmAead::new(&[7u8; 32])
270    }
271
272    #[test]
273    fn round_trip() {
274        let aead = aead();
275        let plaintext = b"super secret value";
276        let sealed = aead.seal(plaintext).unwrap();
277        assert_eq!(aead.open(&sealed).unwrap(), plaintext);
278    }
279
280    #[test]
281    fn tamper_detection() {
282        let aead = aead();
283        let mut sealed = aead.seal(b"super secret value").unwrap();
284        let last = sealed.len() - 1;
285        sealed[last] ^= 0xFF;
286        assert!(aead.open(&sealed).is_err());
287    }
288
289    #[test]
290    fn wrong_key_fails() {
291        let sealed = aead().seal(b"super secret value").unwrap();
292        let other = Aes256GcmAead::new(&[9u8; 32]);
293        assert!(other.open(&sealed).is_err());
294    }
295
296    const OLD: [u8; 32] = [7u8; 32];
297    const NEW: [u8; 32] = [11u8; 32];
298
299    #[test]
300    fn key_ids_are_derived_and_distinct() {
301        assert_eq!(key_id(&OLD), key_id(&OLD));
302        assert_ne!(key_id(&OLD), key_id(&NEW));
303    }
304
305    #[test]
306    fn keyring_round_trip_and_marks_its_own_output_current() {
307        let ring = KeyRing::new(&NEW, &[]);
308        let sealed = ring.seal(b"hunter2").unwrap();
309        assert_eq!(ring.open(&sealed).unwrap(), b"hunter2");
310        assert!(ring.is_current(&sealed));
311        assert_eq!(ring.active_key_id(), key_id(&NEW));
312    }
313
314    /// The compatibility case that matters: data written before rotation
315    /// existed carries no key id, and must still open.
316    #[test]
317    fn keyring_reads_pre_rotation_values() {
318        let legacy = Aes256GcmAead::new(&OLD).seal(b"written in 0.1").unwrap();
319        let ring = KeyRing::new(&OLD, &[]);
320        assert_eq!(ring.open(&legacy).unwrap(), b"written in 0.1");
321        // …and is reported as stale, so rewrapping upgrades it.
322        assert!(!ring.is_current(&legacy));
323    }
324
325    /// Mid-rotation: the new key seals, the retired key still opens.
326    #[test]
327    fn keyring_opens_values_sealed_by_a_retired_key() {
328        let before = KeyRing::new(&OLD, &[]);
329        let sealed = before.seal(b"sealed under the old key").unwrap();
330
331        let rotating = KeyRing::new(&NEW, &[OLD]);
332        assert_eq!(rotating.open(&sealed).unwrap(), b"sealed under the old key");
333        assert!(
334            !rotating.is_current(&sealed),
335            "a value on the retired key must be reported as needing rewrap"
336        );
337        assert_eq!(rotating.retired_key_count(), 1);
338    }
339
340    /// Dropping a key that is still in use must fail loudly, not silently
341    /// return garbage.
342    #[test]
343    fn keyring_refuses_a_value_whose_key_is_gone() {
344        let sealed = KeyRing::new(&OLD, &[]).seal(b"orphaned").unwrap();
345        assert!(KeyRing::new(&NEW, &[]).open(&sealed).is_err());
346    }
347
348    #[test]
349    fn keyring_detects_tampering() {
350        let ring = KeyRing::new(&NEW, &[]);
351        let mut sealed = ring.seal(b"hunter2").unwrap();
352        let last = sealed.len() - 1;
353        sealed[last] ^= 0xFF;
354        assert!(ring.open(&sealed).is_err());
355    }
356
357    /// A truncated or empty blob must not panic on the slicing in `open`.
358    #[test]
359    fn keyring_rejects_short_blobs_without_panicking() {
360        let ring = KeyRing::new(&NEW, &[]);
361        for len in 0..20 {
362            assert!(ring.open(&vec![VERSIONED_MARKER; len]).is_err());
363        }
364    }
365}