1use 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
16pub 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#[derive(Clone, Eq, PartialEq)]
26pub struct SecretKey(Zeroizing<[u8; 32]>);
27
28impl SecretKey {
29 #[must_use]
31 pub fn new(bytes: [u8; 32]) -> Self {
32 Self(Zeroizing::new(bytes))
33 }
34
35 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#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
58pub struct KeyId(String);
59
60impl KeyId {
61 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 #[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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
89pub struct BlobHeader {
90 pub epoch: u32,
92 pub plaintext_len: u64,
94}
95
96#[derive(Debug, Error)]
98pub enum CryptError {
99 #[error("secret key must be exactly 32 bytes")]
101 InvalidKeyLength,
102 #[error("invalid encrypted blob header")]
104 InvalidBlobHeader,
105 #[error("unsupported encrypted blob algorithm {0}")]
107 UnsupportedAlgorithm(u8),
108 #[error("encrypted blob length does not match its header")]
110 InvalidBlobLength,
111 #[error("encrypted blob encryption failed")]
113 EncryptionFailed,
114 #[error("encrypted blob authentication failed")]
116 AuthenticationFailed,
117 #[error("plaintext is too large to encrypt")]
119 PlaintextTooLarge,
120}
121
122#[derive(Debug, Error)]
124pub enum KeyError {
125 #[error("key identity must not be empty")]
127 InvalidId,
128 #[error("key {id} has no material for epoch {epoch}")]
130 MissingKey {
131 id: KeyId,
133 epoch: u32,
135 },
136 #[error("key {0} has no current epoch")]
138 MissingCurrentEpoch(KeyId),
139 #[error("wrapped key did not contain a 256-bit key")]
141 InvalidWrappedKey,
142 #[error("wrapped key uses epoch {actual}, expected {expected}")]
144 WrappedEpochMismatch {
145 expected: u32,
147 actual: u32,
149 },
150 #[error(transparent)]
152 Crypt(#[from] CryptError),
153}
154
155#[async_trait]
157pub trait Keyring: Send + Sync {
158 async fn key(&self, id: &KeyId, epoch: u32) -> Result<SecretKey, KeyError>;
160
161 async fn current_epoch(&self, id: &KeyId) -> Result<u32, KeyError>;
163
164 async fn wrap(&self, id: &KeyId, epoch: u32, dek: &SecretKey) -> Result<Vec<u8>, KeyError>;
166
167 async fn unwrap(&self, id: &KeyId, epoch: u32, wrapped: &[u8]) -> Result<SecretKey, KeyError>;
169
170 fn key_ids(&self) -> &[KeyId];
172}
173
174#[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 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#[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
253pub 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
296pub 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
336pub 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}