Skip to main content

corium_crypt/
lib.rs

1//! Cryptographic primitives and key resolution for Corium.
2//!
3//! This crate deliberately has no storage or async-runtime dependency. It owns
4//! stored encryption formats and secret-key hygiene; callers own where keys and
5//! ciphertext live.
6
7use std::collections::{BTreeMap, BTreeSet};
8use std::fmt;
9
10use aes_gcm_siv::aead::{Aead, KeyInit, Payload};
11use aes_gcm_siv::{Aes256GcmSiv, Nonce};
12use async_trait::async_trait;
13use thiserror::Error;
14use zeroize::Zeroizing;
15
16/// Magic prefix for an encrypted content-addressed blob.
17pub const BLOB_MAGIC: &[u8; 8] = b"CORIUMB1";
18
19const ALGORITHM_AES_256_GCM_SIV: u8 = 1;
20const BLOB_HEADER_LEN: usize = BLOB_MAGIC.len() + 1 + size_of::<u32>() + size_of::<u64>();
21const AEAD_TAG_LEN: usize = 16;
22const NONCE_LEN: usize = 12;
23
24/// Opaque, zeroized 256-bit key material.
25#[derive(Clone, Eq, PartialEq)]
26pub struct SecretKey(Zeroizing<[u8; 32]>);
27
28impl SecretKey {
29    /// Copies a 256-bit key into zeroized storage.
30    #[must_use]
31    pub fn new(bytes: [u8; 32]) -> Self {
32        Self(Zeroizing::new(bytes))
33    }
34
35    /// Copies a byte slice into zeroized storage.
36    ///
37    /// # Errors
38    ///
39    /// Returns [`CryptError::InvalidKeyLength`] unless `bytes` is 32 bytes.
40    pub fn from_slice(bytes: &[u8]) -> Result<Self, CryptError> {
41        let bytes = <[u8; 32]>::try_from(bytes).map_err(|_| CryptError::InvalidKeyLength)?;
42        Ok(Self::new(bytes))
43    }
44
45    fn as_bytes(&self) -> &[u8; 32] {
46        &self.0
47    }
48}
49
50impl fmt::Debug for SecretKey {
51    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
52        f.write_str("SecretKey([REDACTED])")
53    }
54}
55
56/// A key identity stored in a manifest or protection-class entity.
57#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
58pub struct KeyId(String);
59
60impl KeyId {
61    /// Creates a key identity.
62    ///
63    /// # Errors
64    ///
65    /// Returns [`KeyError::InvalidId`] for an empty identity.
66    pub fn new(value: impl Into<String>) -> Result<Self, KeyError> {
67        let value = value.into();
68        if value.is_empty() {
69            return Err(KeyError::InvalidId);
70        }
71        Ok(Self(value))
72    }
73
74    /// Returns the key identity as its URI-like string.
75    #[must_use]
76    pub fn as_str(&self) -> &str {
77        &self.0
78    }
79}
80
81impl fmt::Display for KeyId {
82    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83        f.write_str(&self.0)
84    }
85}
86
87/// Parsed metadata from an encrypted blob header.
88#[derive(Clone, Copy, Debug, Eq, PartialEq)]
89pub struct BlobHeader {
90    /// Key epoch used to encrypt the object.
91    pub epoch: u32,
92    /// Length of the plaintext payload.
93    pub plaintext_len: u64,
94}
95
96/// Failures while encrypting or decrypting stored data.
97#[derive(Debug, Error)]
98pub enum CryptError {
99    /// A secret key was not exactly 256 bits.
100    #[error("secret key must be exactly 32 bytes")]
101    InvalidKeyLength,
102    /// The encrypted object does not contain a complete, supported header.
103    #[error("invalid encrypted blob header")]
104    InvalidBlobHeader,
105    /// The encrypted object names an unsupported algorithm.
106    #[error("unsupported encrypted blob algorithm {0}")]
107    UnsupportedAlgorithm(u8),
108    /// The object length disagrees with its authenticated header.
109    #[error("encrypted blob length does not match its header")]
110    InvalidBlobLength,
111    /// Encryption failed after inputs were validated.
112    #[error("encrypted blob encryption failed")]
113    EncryptionFailed,
114    /// Authentication failed, including when the wrong key was supplied.
115    #[error("encrypted blob authentication failed")]
116    AuthenticationFailed,
117    /// The plaintext is too large for the stored length field.
118    #[error("plaintext is too large to encrypt")]
119    PlaintextTooLarge,
120}
121
122/// Failures while resolving or wrapping keys.
123#[derive(Debug, Error)]
124pub enum KeyError {
125    /// A key identity was empty.
126    #[error("key identity must not be empty")]
127    InvalidId,
128    /// No material exists for this identity and epoch.
129    #[error("key {id} has no material for epoch {epoch}")]
130    MissingKey {
131        /// Requested key identity.
132        id: KeyId,
133        /// Requested key epoch.
134        epoch: u32,
135    },
136    /// No current write epoch is configured for this identity.
137    #[error("key {0} has no current epoch")]
138    MissingCurrentEpoch(KeyId),
139    /// Wrapped key material decrypted to an invalid length.
140    #[error("wrapped key did not contain a 256-bit key")]
141    InvalidWrappedKey,
142    /// Wrapped key metadata named a different key epoch.
143    #[error("wrapped key uses epoch {actual}, expected {expected}")]
144    WrappedEpochMismatch {
145        /// Requested key epoch.
146        expected: u32,
147        /// Epoch recorded in the wrapped key.
148        actual: u32,
149    },
150    /// A cryptographic operation failed.
151    #[error(transparent)]
152    Crypt(#[from] CryptError),
153}
154
155/// Resolves key material without coupling Corium to a KMS implementation.
156#[async_trait]
157pub trait Keyring: Send + Sync {
158    /// Resolves material for a specific epoch.
159    async fn key(&self, id: &KeyId, epoch: u32) -> Result<SecretKey, KeyError>;
160
161    /// Returns the epoch new writes should use.
162    async fn current_epoch(&self, id: &KeyId) -> Result<u32, KeyError>;
163
164    /// Wraps a data-encryption key under the requested key and epoch.
165    async fn wrap(&self, id: &KeyId, epoch: u32, dek: &SecretKey) -> Result<Vec<u8>, KeyError>;
166
167    /// Unwraps a stored data-encryption key.
168    async fn unwrap(&self, id: &KeyId, epoch: u32, wrapped: &[u8]) -> Result<SecretKey, KeyError>;
169
170    /// Lists the key identities this process can resolve.
171    fn key_ids(&self) -> &[KeyId];
172}
173
174/// In-memory keyring for tests and keys loaded from files or environment
175/// variables by a higher-level configuration layer.
176#[derive(Clone, Default)]
177pub struct StaticKeyring {
178    keys: BTreeMap<(KeyId, u32), SecretKey>,
179    current_epochs: BTreeMap<KeyId, u32>,
180    key_ids: Vec<KeyId>,
181}
182
183impl StaticKeyring {
184    /// Inserts material and optionally makes its epoch current for writes.
185    pub fn insert(&mut self, id: KeyId, epoch: u32, key: SecretKey, current: bool) {
186        if current {
187            self.current_epochs.insert(id.clone(), epoch);
188        }
189        self.keys.insert((id, epoch), key);
190        self.key_ids = self
191            .keys
192            .keys()
193            .map(|(id, _)| id.clone())
194            .collect::<BTreeSet<_>>()
195            .into_iter()
196            .collect();
197    }
198}
199
200#[async_trait]
201impl Keyring for StaticKeyring {
202    async fn key(&self, id: &KeyId, epoch: u32) -> Result<SecretKey, KeyError> {
203        self.keys
204            .get(&(id.clone(), epoch))
205            .cloned()
206            .ok_or_else(|| KeyError::MissingKey {
207                id: id.clone(),
208                epoch,
209            })
210    }
211
212    async fn current_epoch(&self, id: &KeyId) -> Result<u32, KeyError> {
213        self.current_epochs
214            .get(id)
215            .copied()
216            .ok_or_else(|| KeyError::MissingCurrentEpoch(id.clone()))
217    }
218
219    async fn wrap(&self, id: &KeyId, epoch: u32, dek: &SecretKey) -> Result<Vec<u8>, KeyError> {
220        let kek = self.key(id, epoch).await?;
221        let wrapping_key = derive_key(&kek, b"corium/key-wrap");
222        encrypt_blob(&wrapping_key, epoch, dek.as_bytes()).map_err(Into::into)
223    }
224
225    async fn unwrap(&self, id: &KeyId, epoch: u32, wrapped: &[u8]) -> Result<SecretKey, KeyError> {
226        let kek = self.key(id, epoch).await?;
227        let wrapping_key = derive_key(&kek, b"corium/key-wrap");
228        let header = parse_blob_header(wrapped)?;
229        if header.epoch != epoch {
230            return Err(KeyError::WrappedEpochMismatch {
231                expected: epoch,
232                actual: header.epoch,
233            });
234        }
235        let plaintext = Zeroizing::new(decrypt_blob(&wrapping_key, wrapped)?);
236        SecretKey::from_slice(plaintext.as_slice()).map_err(|_| KeyError::InvalidWrappedKey)
237    }
238
239    fn key_ids(&self) -> &[KeyId] {
240        &self.key_ids
241    }
242}
243
244/// Derives a separate 256-bit key for a domain-specific context.
245#[must_use]
246pub fn derive_key(parent: &SecretKey, context: &[u8]) -> SecretKey {
247    let mut hasher = blake3::Hasher::new_keyed(parent.as_bytes());
248    hasher.update(b"corium/derived-key");
249    hasher.update(context);
250    SecretKey::new(*hasher.finalize().as_bytes())
251}
252
253/// Parses and validates an encrypted blob's cleartext header.
254///
255/// # Errors
256///
257/// Returns a [`CryptError`] when the header, algorithm, or stored length is
258/// invalid.
259pub fn parse_blob_header(object: &[u8]) -> Result<BlobHeader, CryptError> {
260    if object.len() < BLOB_HEADER_LEN || &object[..BLOB_MAGIC.len()] != BLOB_MAGIC {
261        return Err(CryptError::InvalidBlobHeader);
262    }
263    let algorithm = object[BLOB_MAGIC.len()];
264    if algorithm != ALGORITHM_AES_256_GCM_SIV {
265        return Err(CryptError::UnsupportedAlgorithm(algorithm));
266    }
267
268    let epoch_offset = BLOB_MAGIC.len() + 1;
269    let length_offset = epoch_offset + size_of::<u32>();
270    let epoch = u32::from_be_bytes(
271        object[epoch_offset..length_offset]
272            .try_into()
273            .map_err(|_| CryptError::InvalidBlobHeader)?,
274    );
275    let plaintext_len = u64::from_be_bytes(
276        object[length_offset..BLOB_HEADER_LEN]
277            .try_into()
278            .map_err(|_| CryptError::InvalidBlobHeader)?,
279    );
280    let plaintext_len =
281        usize::try_from(plaintext_len).map_err(|_| CryptError::InvalidBlobLength)?;
282    let expected_len = BLOB_HEADER_LEN
283        .checked_add(NONCE_LEN)
284        .and_then(|length| length.checked_add(plaintext_len))
285        .and_then(|length| length.checked_add(AEAD_TAG_LEN))
286        .ok_or(CryptError::InvalidBlobLength)?;
287    if object.len() != expected_len {
288        return Err(CryptError::InvalidBlobLength);
289    }
290    Ok(BlobHeader {
291        epoch,
292        plaintext_len: plaintext_len as u64,
293    })
294}
295
296/// Encrypts a blob deterministically for a given key epoch and plaintext.
297///
298/// The header remains cleartext, is authenticated as AAD, and the returned
299/// object's content digest is suitable as its storage identity.
300///
301/// # Errors
302///
303/// Returns a [`CryptError`] if the plaintext is too large or encryption fails.
304pub fn encrypt_blob(key: &SecretKey, epoch: u32, plaintext: &[u8]) -> Result<Vec<u8>, CryptError> {
305    let plaintext_len =
306        u64::try_from(plaintext.len()).map_err(|_| CryptError::PlaintextTooLarge)?;
307    let mut header = Vec::with_capacity(BLOB_HEADER_LEN);
308    header.extend_from_slice(BLOB_MAGIC);
309    header.push(ALGORITHM_AES_256_GCM_SIV);
310    header.extend_from_slice(&epoch.to_be_bytes());
311    header.extend_from_slice(&plaintext_len.to_be_bytes());
312
313    let plaintext_digest = blake3::hash(plaintext);
314    let mut nonce_hasher = blake3::Hasher::new_keyed(key.as_bytes());
315    nonce_hasher.update(b"corium/blob-nonce");
316    nonce_hasher.update(&header);
317    nonce_hasher.update(plaintext_digest.as_bytes());
318    let nonce_digest = nonce_hasher.finalize();
319    let nonce_bytes = &nonce_digest.as_bytes()[..NONCE_LEN];
320    let cipher =
321        Aes256GcmSiv::new_from_slice(key.as_bytes()).map_err(|_| CryptError::InvalidKeyLength)?;
322    let ciphertext = cipher
323        .encrypt(
324            Nonce::from_slice(nonce_bytes),
325            Payload {
326                msg: plaintext,
327                aad: &header,
328            },
329        )
330        .map_err(|_| CryptError::EncryptionFailed)?;
331    header.extend_from_slice(nonce_bytes);
332    header.extend_from_slice(&ciphertext);
333    Ok(header)
334}
335
336/// Authenticates and decrypts an encrypted blob.
337///
338/// # Errors
339///
340/// Returns a [`CryptError`] for malformed data, the wrong key, or tampering.
341pub fn decrypt_blob(key: &SecretKey, object: &[u8]) -> Result<Vec<u8>, CryptError> {
342    let _header = parse_blob_header(object)?;
343    let header = &object[..BLOB_HEADER_LEN];
344    let nonce_end = BLOB_HEADER_LEN + NONCE_LEN;
345    let nonce = Nonce::from_slice(&object[BLOB_HEADER_LEN..nonce_end]);
346    let ciphertext = &object[nonce_end..];
347    let cipher =
348        Aes256GcmSiv::new_from_slice(key.as_bytes()).map_err(|_| CryptError::InvalidKeyLength)?;
349    cipher
350        .decrypt(
351            nonce,
352            Payload {
353                msg: ciphertext,
354                aad: header,
355            },
356        )
357        .map_err(|_| CryptError::AuthenticationFailed)
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363    use proptest::prelude::*;
364
365    fn key(byte: u8) -> SecretKey {
366        SecretKey::new([byte; 32])
367    }
368
369    proptest! {
370        #[test]
371        fn blobs_are_deterministic_and_round_trip(
372            plaintext in prop::collection::vec(any::<u8>(), 0..4096)
373        ) {
374            let encrypted = encrypt_blob(&key(7), 3, &plaintext).expect("encrypt");
375            let repeated = encrypt_blob(&key(7), 3, &plaintext).expect("repeat");
376            prop_assert_eq!(&encrypted, &repeated);
377            prop_assert_ne!(
378                encrypt_blob(&key(7), 4, &plaintext).expect("different epoch"),
379                encrypted.clone()
380            );
381            prop_assert_eq!(decrypt_blob(&key(7), &encrypted).expect("decrypt"), plaintext);
382        }
383    }
384
385    #[test]
386    fn header_and_ciphertext_are_authenticated() {
387        let encrypted = encrypt_blob(&key(1), 9, b"sentinel").expect("encrypt");
388        assert_eq!(
389            parse_blob_header(&encrypted).expect("header"),
390            BlobHeader {
391                epoch: 9,
392                plaintext_len: 8,
393            }
394        );
395        assert!(!encrypted.windows(8).any(|window| window == b"sentinel"));
396        assert!(decrypt_blob(&key(2), &encrypted).is_err());
397
398        let mut tampered = encrypted;
399        *tampered.last_mut().expect("ciphertext") ^= 1;
400        assert!(decrypt_blob(&key(1), &tampered).is_err());
401
402        let mut tampered_nonce = encrypt_blob(&key(1), 9, b"sentinel").expect("encrypt");
403        tampered_nonce[BLOB_HEADER_LEN] ^= 1;
404        assert!(decrypt_blob(&key(1), &tampered_nonce).is_err());
405    }
406
407    #[test]
408    fn debug_never_reveals_key_material() {
409        let rendered = format!("{:?}", key(0xA5));
410        assert_eq!(rendered, "SecretKey([REDACTED])");
411        assert!(!rendered.contains("165"));
412    }
413
414    #[tokio::test]
415    async fn static_keyring_resolves_and_wraps_keys() {
416        let id = KeyId::new("file:test-kek").expect("key id");
417        let mut keyring = StaticKeyring::default();
418        keyring.insert(id.clone(), 4, key(4), true);
419
420        assert_eq!(keyring.current_epoch(&id).await.expect("epoch"), 4);
421        assert_eq!(keyring.key_ids(), std::slice::from_ref(&id));
422        let wrapped = keyring.wrap(&id, 4, &key(8)).await.expect("wrap");
423        assert_eq!(
424            keyring.unwrap(&id, 4, &wrapped).await.expect("unwrap"),
425            key(8)
426        );
427    }
428}