1use std::collections::{BTreeMap, BTreeSet};
8use std::fmt;
9
10use aes_gcm::Aes256Gcm;
11use aes_gcm_siv::aead::{Aead, KeyInit, Payload};
12use aes_gcm_siv::{Aes256GcmSiv, Nonce};
13use async_trait::async_trait;
14use thiserror::Error;
15use zeroize::Zeroizing;
16
17pub const BLOB_MAGIC: &[u8; 8] = b"CORIUMB1";
19
20pub const LOG_MAGIC: &[u8; 8] = b"CORIUML1";
22
23const ALGORITHM_AES_256_GCM_SIV: u8 = 1;
24const ALGORITHM_AES_256_GCM: u8 = 2;
25const BLOB_HEADER_LEN: usize = BLOB_MAGIC.len() + 1 + size_of::<u32>() + size_of::<u64>();
26const LOG_HEADER_LEN: usize = LOG_MAGIC.len() + 1 + size_of::<u32>() + size_of::<u64>() + NONCE_LEN;
28const AEAD_TAG_LEN: usize = 16;
29const NONCE_LEN: usize = 12;
30
31#[derive(Clone, Eq, PartialEq)]
33pub struct SecretKey(Zeroizing<[u8; 32]>);
34
35impl SecretKey {
36 #[must_use]
38 pub fn new(bytes: [u8; 32]) -> Self {
39 Self(Zeroizing::new(bytes))
40 }
41
42 pub fn generate() -> Result<Self, CryptError> {
51 let mut bytes = Zeroizing::new([0_u8; 32]);
52 getrandom::fill(bytes.as_mut_slice()).map_err(|_| CryptError::RandomnessUnavailable)?;
53 Ok(Self(bytes))
54 }
55
56 pub fn from_slice(bytes: &[u8]) -> Result<Self, CryptError> {
62 let bytes = <[u8; 32]>::try_from(bytes).map_err(|_| CryptError::InvalidKeyLength)?;
63 Ok(Self::new(bytes))
64 }
65
66 fn as_bytes(&self) -> &[u8; 32] {
67 &self.0
68 }
69}
70
71impl fmt::Debug for SecretKey {
72 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73 f.write_str("SecretKey([REDACTED])")
74 }
75}
76
77#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
79pub struct KeyId(String);
80
81impl KeyId {
82 pub fn new(value: impl Into<String>) -> Result<Self, KeyError> {
88 let value = value.into();
89 if value.is_empty() {
90 return Err(KeyError::InvalidId);
91 }
92 Ok(Self(value))
93 }
94
95 #[must_use]
97 pub fn as_str(&self) -> &str {
98 &self.0
99 }
100}
101
102impl fmt::Display for KeyId {
103 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
104 f.write_str(&self.0)
105 }
106}
107
108#[derive(Clone, Copy, Debug, Eq, PartialEq)]
110pub struct BlobHeader {
111 pub epoch: u32,
113 pub plaintext_len: u64,
115}
116
117#[derive(Clone, Copy, Debug, Eq, PartialEq)]
119pub struct LogHeader {
120 pub epoch: u32,
122 pub t: u64,
124}
125
126#[derive(Debug, Error)]
128pub enum CryptError {
129 #[error("secret key must be exactly 32 bytes")]
131 InvalidKeyLength,
132 #[error("cryptographic randomness is unavailable")]
134 RandomnessUnavailable,
135 #[error("invalid encrypted blob header")]
137 InvalidBlobHeader,
138 #[error("invalid encrypted log record header")]
140 InvalidLogHeader,
141 #[error("unsupported encrypted blob algorithm {0}")]
143 UnsupportedAlgorithm(u8),
144 #[error("encrypted blob length does not match its header")]
146 InvalidBlobLength,
147 #[error("encryption failed")]
149 EncryptionFailed,
150 #[error("authentication failed")]
152 AuthenticationFailed,
153 #[error("plaintext is too large to encrypt")]
155 PlaintextTooLarge,
156}
157
158#[derive(Debug, Error)]
160pub enum KeyError {
161 #[error("key identity must not be empty")]
163 InvalidId,
164 #[error("key {id} has no material for epoch {epoch}")]
166 MissingKey {
167 id: KeyId,
169 epoch: u32,
171 },
172 #[error("key {0} has no current epoch")]
174 MissingCurrentEpoch(KeyId),
175 #[error("wrapped key did not contain a 256-bit key")]
177 InvalidWrappedKey,
178 #[error("wrapped key uses epoch {actual}, expected {expected}")]
180 WrappedEpochMismatch {
181 expected: u32,
183 actual: u32,
185 },
186 #[error(transparent)]
188 Crypt(#[from] CryptError),
189}
190
191#[async_trait]
193pub trait Keyring: Send + Sync {
194 async fn key(&self, id: &KeyId, epoch: u32) -> Result<SecretKey, KeyError>;
196
197 async fn current_epoch(&self, id: &KeyId) -> Result<u32, KeyError>;
199
200 async fn wrap(&self, id: &KeyId, epoch: u32, dek: &SecretKey) -> Result<Vec<u8>, KeyError>;
202
203 async fn unwrap(&self, id: &KeyId, epoch: u32, wrapped: &[u8]) -> Result<SecretKey, KeyError>;
205
206 fn key_ids(&self) -> &[KeyId];
208}
209
210#[derive(Clone, Default)]
213pub struct StaticKeyring {
214 keys: BTreeMap<(KeyId, u32), SecretKey>,
215 current_epochs: BTreeMap<KeyId, u32>,
216 key_ids: Vec<KeyId>,
217}
218
219impl StaticKeyring {
220 pub fn insert(&mut self, id: KeyId, epoch: u32, key: SecretKey, current: bool) {
222 if current {
223 self.current_epochs.insert(id.clone(), epoch);
224 }
225 self.keys.insert((id, epoch), key);
226 self.key_ids = self
227 .keys
228 .keys()
229 .map(|(id, _)| id.clone())
230 .collect::<BTreeSet<_>>()
231 .into_iter()
232 .collect();
233 }
234}
235
236#[async_trait]
237impl Keyring for StaticKeyring {
238 async fn key(&self, id: &KeyId, epoch: u32) -> Result<SecretKey, KeyError> {
239 self.keys
240 .get(&(id.clone(), epoch))
241 .cloned()
242 .ok_or_else(|| KeyError::MissingKey {
243 id: id.clone(),
244 epoch,
245 })
246 }
247
248 async fn current_epoch(&self, id: &KeyId) -> Result<u32, KeyError> {
249 self.current_epochs
250 .get(id)
251 .copied()
252 .ok_or_else(|| KeyError::MissingCurrentEpoch(id.clone()))
253 }
254
255 async fn wrap(&self, id: &KeyId, epoch: u32, dek: &SecretKey) -> Result<Vec<u8>, KeyError> {
256 let kek = self.key(id, epoch).await?;
257 let wrapping_key = derive_key(&kek, b"corium/key-wrap");
258 encrypt_blob(&wrapping_key, epoch, dek.as_bytes()).map_err(Into::into)
259 }
260
261 async fn unwrap(&self, id: &KeyId, epoch: u32, wrapped: &[u8]) -> Result<SecretKey, KeyError> {
262 let kek = self.key(id, epoch).await?;
263 let wrapping_key = derive_key(&kek, b"corium/key-wrap");
264 let header = parse_blob_header(wrapped)?;
265 if header.epoch != epoch {
266 return Err(KeyError::WrappedEpochMismatch {
267 expected: epoch,
268 actual: header.epoch,
269 });
270 }
271 let plaintext = Zeroizing::new(decrypt_blob(&wrapping_key, wrapped)?);
272 SecretKey::from_slice(plaintext.as_slice()).map_err(|_| KeyError::InvalidWrappedKey)
273 }
274
275 fn key_ids(&self) -> &[KeyId] {
276 &self.key_ids
277 }
278}
279
280#[must_use]
282pub fn derive_key(parent: &SecretKey, context: &[u8]) -> SecretKey {
283 let mut hasher = blake3::Hasher::new_keyed(parent.as_bytes());
284 hasher.update(b"corium/derived-key");
285 hasher.update(context);
286 SecretKey::new(*hasher.finalize().as_bytes())
287}
288
289pub fn parse_blob_header(object: &[u8]) -> Result<BlobHeader, CryptError> {
296 if object.len() < BLOB_HEADER_LEN || &object[..BLOB_MAGIC.len()] != BLOB_MAGIC {
297 return Err(CryptError::InvalidBlobHeader);
298 }
299 let algorithm = object[BLOB_MAGIC.len()];
300 if algorithm != ALGORITHM_AES_256_GCM_SIV {
301 return Err(CryptError::UnsupportedAlgorithm(algorithm));
302 }
303
304 let epoch_offset = BLOB_MAGIC.len() + 1;
305 let length_offset = epoch_offset + size_of::<u32>();
306 let epoch = u32::from_be_bytes(
307 object[epoch_offset..length_offset]
308 .try_into()
309 .map_err(|_| CryptError::InvalidBlobHeader)?,
310 );
311 let plaintext_len = u64::from_be_bytes(
312 object[length_offset..BLOB_HEADER_LEN]
313 .try_into()
314 .map_err(|_| CryptError::InvalidBlobHeader)?,
315 );
316 let plaintext_len =
317 usize::try_from(plaintext_len).map_err(|_| CryptError::InvalidBlobLength)?;
318 let expected_len = BLOB_HEADER_LEN
319 .checked_add(NONCE_LEN)
320 .and_then(|length| length.checked_add(plaintext_len))
321 .and_then(|length| length.checked_add(AEAD_TAG_LEN))
322 .ok_or(CryptError::InvalidBlobLength)?;
323 if object.len() != expected_len {
324 return Err(CryptError::InvalidBlobLength);
325 }
326 Ok(BlobHeader {
327 epoch,
328 plaintext_len: plaintext_len as u64,
329 })
330}
331
332pub fn encrypt_blob(key: &SecretKey, epoch: u32, plaintext: &[u8]) -> Result<Vec<u8>, CryptError> {
341 let plaintext_len =
342 u64::try_from(plaintext.len()).map_err(|_| CryptError::PlaintextTooLarge)?;
343 let mut header = Vec::with_capacity(BLOB_HEADER_LEN);
344 header.extend_from_slice(BLOB_MAGIC);
345 header.push(ALGORITHM_AES_256_GCM_SIV);
346 header.extend_from_slice(&epoch.to_be_bytes());
347 header.extend_from_slice(&plaintext_len.to_be_bytes());
348
349 let plaintext_digest = blake3::hash(plaintext);
350 let mut nonce_hasher = blake3::Hasher::new_keyed(key.as_bytes());
351 nonce_hasher.update(b"corium/blob-nonce");
352 nonce_hasher.update(&header);
353 nonce_hasher.update(plaintext_digest.as_bytes());
354 let nonce_digest = nonce_hasher.finalize();
355 let nonce_bytes = &nonce_digest.as_bytes()[..NONCE_LEN];
356 let cipher =
357 Aes256GcmSiv::new_from_slice(key.as_bytes()).map_err(|_| CryptError::InvalidKeyLength)?;
358 let ciphertext = cipher
359 .encrypt(
360 Nonce::from_slice(nonce_bytes),
361 Payload {
362 msg: plaintext,
363 aad: &header,
364 },
365 )
366 .map_err(|_| CryptError::EncryptionFailed)?;
367 header.extend_from_slice(nonce_bytes);
368 header.extend_from_slice(&ciphertext);
369 Ok(header)
370}
371
372pub fn decrypt_blob(key: &SecretKey, object: &[u8]) -> Result<Vec<u8>, CryptError> {
378 let _header = parse_blob_header(object)?;
379 let header = &object[..BLOB_HEADER_LEN];
380 let nonce_end = BLOB_HEADER_LEN + NONCE_LEN;
381 let nonce = Nonce::from_slice(&object[BLOB_HEADER_LEN..nonce_end]);
382 let ciphertext = &object[nonce_end..];
383 let cipher =
384 Aes256GcmSiv::new_from_slice(key.as_bytes()).map_err(|_| CryptError::InvalidKeyLength)?;
385 cipher
386 .decrypt(
387 nonce,
388 Payload {
389 msg: ciphertext,
390 aad: header,
391 },
392 )
393 .map_err(|_| CryptError::AuthenticationFailed)
394}
395
396#[must_use]
403pub fn is_encrypted_log_record(payload: &[u8]) -> bool {
404 payload.len() >= LOG_MAGIC.len() && &payload[..LOG_MAGIC.len()] == LOG_MAGIC
405}
406
407pub fn parse_log_header(payload: &[u8]) -> Result<LogHeader, CryptError> {
418 if !is_encrypted_log_record(payload) || payload.len() < LOG_HEADER_LEN + AEAD_TAG_LEN {
419 return Err(CryptError::InvalidLogHeader);
420 }
421 let algorithm = payload[LOG_MAGIC.len()];
422 if algorithm != ALGORITHM_AES_256_GCM {
423 return Err(CryptError::UnsupportedAlgorithm(algorithm));
424 }
425 let epoch_offset = LOG_MAGIC.len() + 1;
426 let t_offset = epoch_offset + size_of::<u32>();
427 let nonce_offset = t_offset + size_of::<u64>();
428 let epoch = u32::from_be_bytes(
429 payload[epoch_offset..t_offset]
430 .try_into()
431 .map_err(|_| CryptError::InvalidLogHeader)?,
432 );
433 let t = u64::from_be_bytes(
434 payload[t_offset..nonce_offset]
435 .try_into()
436 .map_err(|_| CryptError::InvalidLogHeader)?,
437 );
438 Ok(LogHeader { epoch, t })
439}
440
441fn log_header_and_aad(
443 epoch: u32,
444 lineage: &[u8],
445 log_version: u64,
446 t: u64,
447 nonce: &[u8; NONCE_LEN],
448) -> (Vec<u8>, Vec<u8>) {
449 let mut header = Vec::with_capacity(LOG_HEADER_LEN);
450 header.extend_from_slice(LOG_MAGIC);
451 header.push(ALGORITHM_AES_256_GCM);
452 header.extend_from_slice(&epoch.to_be_bytes());
453 header.extend_from_slice(&t.to_be_bytes());
454 header.extend_from_slice(nonce);
455
456 let mut aad = Vec::with_capacity(b"corium/log-v1".len() + 16 + lineage.len() + header.len());
460 aad.extend_from_slice(b"corium/log-v1");
461 aad.extend_from_slice(&(lineage.len() as u64).to_be_bytes());
462 aad.extend_from_slice(lineage);
463 aad.extend_from_slice(&log_version.to_be_bytes());
464 aad.extend_from_slice(&header);
465 (header, aad)
466}
467
468pub fn encrypt_log_record(
488 key: &SecretKey,
489 epoch: u32,
490 lineage: &[u8],
491 log_version: u64,
492 t: u64,
493 plaintext: &[u8],
494) -> Result<Vec<u8>, CryptError> {
495 let mut nonce_bytes = [0_u8; NONCE_LEN];
496 getrandom::fill(&mut nonce_bytes).map_err(|_| CryptError::RandomnessUnavailable)?;
497 let (mut header, aad) = log_header_and_aad(epoch, lineage, log_version, t, &nonce_bytes);
498 let cipher =
499 Aes256Gcm::new_from_slice(key.as_bytes()).map_err(|_| CryptError::InvalidKeyLength)?;
500 let ciphertext = cipher
501 .encrypt(
502 Nonce::from_slice(&nonce_bytes),
503 Payload {
504 msg: plaintext,
505 aad: &aad,
506 },
507 )
508 .map_err(|_| CryptError::EncryptionFailed)?;
509 header.extend_from_slice(&ciphertext);
510 Ok(header)
511}
512
513pub fn decrypt_log_record(
524 key: &SecretKey,
525 lineage: &[u8],
526 log_version: u64,
527 payload: &[u8],
528) -> Result<Vec<u8>, CryptError> {
529 let LogHeader { epoch, t } = parse_log_header(payload)?;
530 let header = &payload[..LOG_HEADER_LEN];
531 let nonce_offset = LOG_HEADER_LEN - NONCE_LEN;
532 let nonce_bytes = <[u8; NONCE_LEN]>::try_from(&header[nonce_offset..])
533 .map_err(|_| CryptError::InvalidLogHeader)?;
534 let (_, aad) = log_header_and_aad(epoch, lineage, log_version, t, &nonce_bytes);
535 let cipher =
536 Aes256Gcm::new_from_slice(key.as_bytes()).map_err(|_| CryptError::InvalidKeyLength)?;
537 cipher
538 .decrypt(
539 Nonce::from_slice(&nonce_bytes),
540 Payload {
541 msg: &payload[LOG_HEADER_LEN..],
542 aad: &aad,
543 },
544 )
545 .map_err(|_| CryptError::AuthenticationFailed)
546}
547
548#[cfg(test)]
549mod tests {
550 use super::*;
551 use proptest::prelude::*;
552
553 fn key(byte: u8) -> SecretKey {
554 SecretKey::new([byte; 32])
555 }
556
557 proptest! {
558 #[test]
559 fn blobs_are_deterministic_and_round_trip(
560 plaintext in prop::collection::vec(any::<u8>(), 0..4096)
561 ) {
562 let encrypted = encrypt_blob(&key(7), 3, &plaintext).expect("encrypt");
563 let repeated = encrypt_blob(&key(7), 3, &plaintext).expect("repeat");
564 prop_assert_eq!(&encrypted, &repeated);
565 prop_assert_ne!(
566 encrypt_blob(&key(7), 4, &plaintext).expect("different epoch"),
567 encrypted.clone()
568 );
569 prop_assert_eq!(decrypt_blob(&key(7), &encrypted).expect("decrypt"), plaintext);
570 }
571 }
572
573 #[test]
574 fn header_and_ciphertext_are_authenticated() {
575 let encrypted = encrypt_blob(&key(1), 9, b"sentinel").expect("encrypt");
576 assert_eq!(
577 parse_blob_header(&encrypted).expect("header"),
578 BlobHeader {
579 epoch: 9,
580 plaintext_len: 8,
581 }
582 );
583 assert!(!encrypted.windows(8).any(|window| window == b"sentinel"));
584 assert!(decrypt_blob(&key(2), &encrypted).is_err());
585
586 let mut tampered = encrypted;
587 *tampered.last_mut().expect("ciphertext") ^= 1;
588 assert!(decrypt_blob(&key(1), &tampered).is_err());
589
590 let mut tampered_nonce = encrypt_blob(&key(1), 9, b"sentinel").expect("encrypt");
591 tampered_nonce[BLOB_HEADER_LEN] ^= 1;
592 assert!(decrypt_blob(&key(1), &tampered_nonce).is_err());
593 }
594
595 proptest! {
596 #[test]
597 fn log_records_round_trip(payload in prop::collection::vec(any::<u8>(), 0..4096)) {
598 let encrypted = encrypt_log_record(&key(5), 2, b"people", 7, 42, &payload)
599 .expect("encrypt");
600 prop_assert_eq!(
601 parse_log_header(&encrypted).expect("header"),
602 LogHeader { epoch: 2, t: 42 }
603 );
604 prop_assert_eq!(
605 decrypt_log_record(&key(5), b"people", 7, &encrypted).expect("decrypt"),
606 payload
607 );
608 }
609 }
610
611 #[test]
612 fn log_records_are_bound_to_their_position() {
613 let plaintext = b"sentinel payload".as_slice();
614 let encrypted = encrypt_log_record(&key(5), 2, b"people", 7, 42, plaintext).expect("seal");
615 assert!(is_encrypted_log_record(&encrypted));
616 assert!(
617 !encrypted
618 .windows(plaintext.len())
619 .any(|window| window == plaintext)
620 );
621
622 assert!(decrypt_log_record(&key(5), b"other", 7, &encrypted).is_err());
625 assert!(decrypt_log_record(&key(5), b"people", 8, &encrypted).is_err());
626 assert!(decrypt_log_record(&key(6), b"people", 7, &encrypted).is_err());
627
628 let mut moved = encrypted.clone();
629 let t_offset = LOG_MAGIC.len() + 1 + size_of::<u32>();
630 moved[t_offset..t_offset + size_of::<u64>()].copy_from_slice(&43_u64.to_be_bytes());
631 assert_eq!(parse_log_header(&moved).expect("header").t, 43);
632 assert!(decrypt_log_record(&key(5), b"people", 7, &moved).is_err());
633
634 let mut retagged = encrypted;
635 retagged[LOG_MAGIC.len() + 1] ^= 1;
636 assert!(decrypt_log_record(&key(5), b"people", 7, &retagged).is_err());
637 }
638
639 #[test]
640 fn log_records_do_not_reuse_a_nonce() {
641 let first = encrypt_log_record(&key(5), 2, b"people", 7, 42, b"first").expect("first");
644 let second = encrypt_log_record(&key(5), 2, b"people", 7, 42, b"first").expect("second");
645 assert_ne!(first, second);
646 assert_eq!(
647 decrypt_log_record(&key(5), b"people", 7, &second).expect("decrypt"),
648 b"first"
649 );
650 }
651
652 #[test]
653 fn plaintext_records_are_not_mistaken_for_encrypted_ones() {
654 let mut plaintext = 42_u64.to_be_bytes().to_vec();
655 plaintext.extend_from_slice(&[0; 32]);
656 assert!(!is_encrypted_log_record(&plaintext));
657 assert!(matches!(
658 parse_log_header(&plaintext),
659 Err(CryptError::InvalidLogHeader)
660 ));
661 assert!(!is_encrypted_log_record(&[]));
662 assert!(!is_encrypted_log_record(LOG_MAGIC.as_slice().split_at(4).0));
663 }
664
665 #[test]
666 fn generated_keys_are_distinct() {
667 let first = SecretKey::generate().expect("generate");
668 assert_ne!(first, SecretKey::generate().expect("generate"));
669 assert_ne!(first, SecretKey::new([0; 32]));
670 }
671
672 #[test]
673 fn debug_never_reveals_key_material() {
674 let rendered = format!("{:?}", key(0xA5));
675 assert_eq!(rendered, "SecretKey([REDACTED])");
676 assert!(!rendered.contains("165"));
677 }
678
679 #[tokio::test]
680 async fn static_keyring_resolves_and_wraps_keys() {
681 let id = KeyId::new("file:test-kek").expect("key id");
682 let mut keyring = StaticKeyring::default();
683 keyring.insert(id.clone(), 4, key(4), true);
684
685 assert_eq!(keyring.current_epoch(&id).await.expect("epoch"), 4);
686 assert_eq!(keyring.key_ids(), std::slice::from_ref(&id));
687 let wrapped = keyring.wrap(&id, 4, &key(8)).await.expect("wrap");
688 assert_eq!(
689 keyring.unwrap(&id, 4, &wrapped).await.expect("unwrap"),
690 key(8)
691 );
692 }
693}