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;
9use std::path::Path;
10
11use aes_gcm::Aes256Gcm;
12use aes_gcm_siv::aead::{Aead, KeyInit, Payload};
13use aes_gcm_siv::{Aes256GcmSiv, Nonce};
14use async_trait::async_trait;
15use thiserror::Error;
16use zeroize::Zeroizing;
17
18/// Magic prefix for an encrypted content-addressed blob.
19pub const BLOB_MAGIC: &[u8; 8] = b"CORIUMB1";
20
21/// Magic prefix for an encrypted transaction-log record payload.
22pub const LOG_MAGIC: &[u8; 8] = b"CORIUML1";
23
24const ALGORITHM_AES_256_GCM_SIV: u8 = 1;
25const ALGORITHM_AES_256_GCM: u8 = 2;
26const BLOB_HEADER_LEN: usize = BLOB_MAGIC.len() + 1 + size_of::<u32>() + size_of::<u64>();
27/// Magic, algorithm, key epoch, transaction number, nonce.
28const LOG_HEADER_LEN: usize = LOG_MAGIC.len() + 1 + size_of::<u32>() + size_of::<u64>() + NONCE_LEN;
29const AEAD_TAG_LEN: usize = 16;
30const NONCE_LEN: usize = 12;
31
32/// Opaque, zeroized 256-bit key material.
33#[derive(Clone, Eq, PartialEq)]
34pub struct SecretKey(Zeroizing<[u8; 32]>);
35
36impl SecretKey {
37    /// Copies a 256-bit key into zeroized storage.
38    #[must_use]
39    pub fn new(bytes: [u8; 32]) -> Self {
40        Self(Zeroizing::new(bytes))
41    }
42
43    /// Draws fresh 256-bit key material from the operating system's
44    /// cryptographic random source.
45    ///
46    /// # Errors
47    ///
48    /// Returns [`CryptError::RandomnessUnavailable`] when the OS entropy
49    /// source cannot be read. Callers must fail rather than fall back: a
50    /// predictable data key is indistinguishable from no encryption.
51    pub fn generate() -> Result<Self, CryptError> {
52        let mut bytes = Zeroizing::new([0_u8; 32]);
53        getrandom::fill(bytes.as_mut_slice()).map_err(|_| CryptError::RandomnessUnavailable)?;
54        Ok(Self(bytes))
55    }
56
57    /// Copies a byte slice into zeroized storage.
58    ///
59    /// # Errors
60    ///
61    /// Returns [`CryptError::InvalidKeyLength`] unless `bytes` is 32 bytes.
62    pub fn from_slice(bytes: &[u8]) -> Result<Self, CryptError> {
63        let bytes = <[u8; 32]>::try_from(bytes).map_err(|_| CryptError::InvalidKeyLength)?;
64        Ok(Self::new(bytes))
65    }
66
67    fn as_bytes(&self) -> &[u8; 32] {
68        &self.0
69    }
70}
71
72impl fmt::Debug for SecretKey {
73    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
74        f.write_str("SecretKey([REDACTED])")
75    }
76}
77
78/// A key identity stored in a manifest or protection-class entity.
79#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
80pub struct KeyId(String);
81
82impl KeyId {
83    /// Creates a key identity.
84    ///
85    /// # Errors
86    ///
87    /// Returns [`KeyError::InvalidId`] for an empty identity.
88    pub fn new(value: impl Into<String>) -> Result<Self, KeyError> {
89        let value = value.into();
90        if value.is_empty() {
91            return Err(KeyError::InvalidId);
92        }
93        Ok(Self(value))
94    }
95
96    /// Returns the key identity as its URI-like string.
97    #[must_use]
98    pub fn as_str(&self) -> &str {
99        &self.0
100    }
101
102    /// Splits the identity into its scheme and the rest.
103    ///
104    /// An identity with no `:` has no scheme, which every resolver rejects —
105    /// the URI form is what lets one keyring serve files, environment
106    /// variables, and a KMS at once.
107    #[must_use]
108    pub fn scheme(&self) -> Option<(&str, &str)> {
109        self.0.split_once(':')
110    }
111}
112
113impl fmt::Display for KeyId {
114    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
115        f.write_str(&self.0)
116    }
117}
118
119/// Parsed metadata from an encrypted blob header.
120#[derive(Clone, Copy, Debug, Eq, PartialEq)]
121pub struct BlobHeader {
122    /// Key epoch used to encrypt the object.
123    pub epoch: u32,
124    /// Length of the plaintext payload.
125    pub plaintext_len: u64,
126}
127
128/// Parsed metadata from an encrypted log-record payload header.
129#[derive(Clone, Copy, Debug, Eq, PartialEq)]
130pub struct LogHeader {
131    /// Key epoch used to encrypt the payload.
132    pub epoch: u32,
133    /// Transaction number the payload was written at.
134    pub t: u64,
135}
136
137/// Failures while encrypting or decrypting stored data.
138#[derive(Debug, Error)]
139pub enum CryptError {
140    /// A secret key was not exactly 256 bits.
141    #[error("secret key must be exactly 32 bytes")]
142    InvalidKeyLength,
143    /// The operating system's random source was unavailable.
144    #[error("cryptographic randomness is unavailable")]
145    RandomnessUnavailable,
146    /// The encrypted object does not contain a complete, supported header.
147    #[error("invalid encrypted blob header")]
148    InvalidBlobHeader,
149    /// The log payload does not contain a complete, supported header.
150    #[error("invalid encrypted log record header")]
151    InvalidLogHeader,
152    /// The encrypted object names an unsupported algorithm.
153    #[error("unsupported encrypted blob algorithm {0}")]
154    UnsupportedAlgorithm(u8),
155    /// The object length disagrees with its authenticated header.
156    #[error("encrypted blob length does not match its header")]
157    InvalidBlobLength,
158    /// Encryption failed after inputs were validated.
159    #[error("encryption failed")]
160    EncryptionFailed,
161    /// Authentication failed, including when the wrong key was supplied.
162    #[error("authentication failed")]
163    AuthenticationFailed,
164    /// The plaintext is too large for the stored length field.
165    #[error("plaintext is too large to encrypt")]
166    PlaintextTooLarge,
167}
168
169/// Failures while resolving or wrapping keys.
170#[derive(Debug, Error)]
171pub enum KeyError {
172    /// A key identity was empty.
173    #[error("key identity must not be empty")]
174    InvalidId,
175    /// No material exists for this identity and epoch.
176    #[error("key {id} has no material for epoch {epoch}")]
177    MissingKey {
178        /// Requested key identity.
179        id: KeyId,
180        /// Requested key epoch.
181        epoch: u32,
182    },
183    /// No current write epoch is configured for this identity.
184    #[error("key {0} has no current epoch")]
185    MissingCurrentEpoch(KeyId),
186    /// Wrapped key material decrypted to an invalid length.
187    #[error("wrapped key did not contain a 256-bit key")]
188    InvalidWrappedKey,
189    /// The identity names a scheme this build cannot resolve.
190    #[error(
191        "key {0} names a key source this build cannot resolve; \
192         file: and env: are always available"
193    )]
194    UnsupportedScheme(KeyId),
195    /// The identity's source could not be read.
196    ///
197    /// The reason never quotes the material, only where it was looked for.
198    #[error("key {id} could not be read: {reason}")]
199    Unreadable {
200        /// Key identity that failed to resolve.
201        id: KeyId,
202        /// Why the source was unusable.
203        reason: String,
204    },
205    /// The source resolved but did not hold a 256-bit key.
206    #[error("key {0} does not hold 32 bytes of key material (raw or 64 hex characters)")]
207    InvalidMaterial(KeyId),
208    /// Wrapped key metadata named a different key epoch.
209    #[error("wrapped key uses epoch {actual}, expected {expected}")]
210    WrappedEpochMismatch {
211        /// Requested key epoch.
212        expected: u32,
213        /// Epoch recorded in the wrapped key.
214        actual: u32,
215    },
216    /// A cryptographic operation failed.
217    #[error(transparent)]
218    Crypt(#[from] CryptError),
219}
220
221/// Resolves key material without coupling Corium to a KMS implementation.
222#[async_trait]
223pub trait Keyring: Send + Sync {
224    /// Resolves material for a specific epoch.
225    async fn key(&self, id: &KeyId, epoch: u32) -> Result<SecretKey, KeyError>;
226
227    /// Returns the epoch new writes should use.
228    async fn current_epoch(&self, id: &KeyId) -> Result<u32, KeyError>;
229
230    /// Wraps a data-encryption key under the requested key and epoch.
231    async fn wrap(&self, id: &KeyId, epoch: u32, dek: &SecretKey) -> Result<Vec<u8>, KeyError>;
232
233    /// Unwraps a stored data-encryption key.
234    async fn unwrap(&self, id: &KeyId, epoch: u32, wrapped: &[u8]) -> Result<SecretKey, KeyError>;
235
236    /// Lists the key identities this process can resolve.
237    fn key_ids(&self) -> &[KeyId];
238}
239
240/// In-memory keyring for tests and keys loaded from files or environment
241/// variables by a higher-level configuration layer.
242#[derive(Clone, Default)]
243pub struct StaticKeyring {
244    keys: BTreeMap<(KeyId, u32), SecretKey>,
245    current_epochs: BTreeMap<KeyId, u32>,
246    key_ids: Vec<KeyId>,
247}
248
249/// Epoch a locally held key resolves at.
250///
251/// A file or environment variable holds one piece of material and has no
252/// notion of versions, so it is always epoch 1. Rotating such a key means
253/// naming a different identity — which is what `corium keys rewrap --kek`
254/// takes — rather than bumping an epoch the source cannot represent.
255pub const STATIC_KEY_EPOCH: u32 = 1;
256
257impl StaticKeyring {
258    /// Resolves each identity from its own source and returns the keyring.
259    ///
260    /// Resolution happens once, here, so a misconfigured process fails at
261    /// startup naming the key it could not read, rather than at its first
262    /// blob read.
263    ///
264    /// # Errors
265    ///
266    /// Returns [`KeyError`] when any identity names an unsupported scheme,
267    /// cannot be read, or does not hold 256 bits of material.
268    pub fn resolve(ids: impl IntoIterator<Item = KeyId>) -> Result<Self, KeyError> {
269        let mut keyring = Self::default();
270        for id in ids {
271            let key = load_key(&id)?;
272            keyring.insert(id, STATIC_KEY_EPOCH, key, true);
273        }
274        Ok(keyring)
275    }
276
277    /// Inserts material and optionally makes its epoch current for writes.
278    pub fn insert(&mut self, id: KeyId, epoch: u32, key: SecretKey, current: bool) {
279        if current {
280            self.current_epochs.insert(id.clone(), epoch);
281        }
282        self.keys.insert((id, epoch), key);
283        self.key_ids = self
284            .keys
285            .keys()
286            .map(|(id, _)| id.clone())
287            .collect::<BTreeSet<_>>()
288            .into_iter()
289            .collect();
290    }
291}
292
293#[async_trait]
294impl Keyring for StaticKeyring {
295    async fn key(&self, id: &KeyId, epoch: u32) -> Result<SecretKey, KeyError> {
296        self.keys
297            .get(&(id.clone(), epoch))
298            .cloned()
299            .ok_or_else(|| KeyError::MissingKey {
300                id: id.clone(),
301                epoch,
302            })
303    }
304
305    async fn current_epoch(&self, id: &KeyId) -> Result<u32, KeyError> {
306        self.current_epochs
307            .get(id)
308            .copied()
309            .ok_or_else(|| KeyError::MissingCurrentEpoch(id.clone()))
310    }
311
312    async fn wrap(&self, id: &KeyId, epoch: u32, dek: &SecretKey) -> Result<Vec<u8>, KeyError> {
313        let kek = self.key(id, epoch).await?;
314        let wrapping_key = derive_key(&kek, b"corium/key-wrap");
315        encrypt_blob(&wrapping_key, epoch, dek.as_bytes()).map_err(Into::into)
316    }
317
318    async fn unwrap(&self, id: &KeyId, epoch: u32, wrapped: &[u8]) -> Result<SecretKey, KeyError> {
319        let kek = self.key(id, epoch).await?;
320        let wrapping_key = derive_key(&kek, b"corium/key-wrap");
321        let header = parse_blob_header(wrapped)?;
322        if header.epoch != epoch {
323            return Err(KeyError::WrappedEpochMismatch {
324                expected: epoch,
325                actual: header.epoch,
326            });
327        }
328        let plaintext = Zeroizing::new(decrypt_blob(&wrapping_key, wrapped)?);
329        SecretKey::from_slice(plaintext.as_slice()).map_err(|_| KeyError::InvalidWrappedKey)
330    }
331
332    fn key_ids(&self) -> &[KeyId] {
333        &self.key_ids
334    }
335}
336
337/// Reads the material one locally resolvable key identity names.
338///
339/// `file:<path>` reads the file; `env:<NAME>` reads an environment variable.
340/// Both accept 32 raw bytes or 64 hexadecimal characters, with surrounding
341/// whitespace ignored so a key file written by `printf` or by an editor that
342/// appends a newline both work.
343///
344/// KMS schemes (`awskms:`, `gcpkms:`, `vault:`) are resolved by their own
345/// keyring implementations, not here, and are reported as unsupported rather
346/// than silently treated as a path.
347///
348/// # Errors
349///
350/// Returns [`KeyError`] when the scheme is not locally resolvable, the source
351/// cannot be read, or it does not hold 256 bits.
352pub fn load_key(id: &KeyId) -> Result<SecretKey, KeyError> {
353    let Some((scheme, rest)) = id.scheme() else {
354        return Err(KeyError::UnsupportedScheme(id.clone()));
355    };
356    let material = match scheme {
357        "file" => Zeroizing::new(std::fs::read(Path::new(rest)).map_err(|error| {
358            KeyError::Unreadable {
359                id: id.clone(),
360                reason: format!("cannot read {rest}: {error}"),
361            }
362        })?),
363        "env" => Zeroizing::new(
364            std::env::var(rest)
365                .map_err(|_| KeyError::Unreadable {
366                    id: id.clone(),
367                    reason: format!("environment variable {rest} is not set"),
368                })?
369                .into_bytes(),
370        ),
371        _ => return Err(KeyError::UnsupportedScheme(id.clone())),
372    };
373    decode_key_material(&material).ok_or_else(|| KeyError::InvalidMaterial(id.clone()))
374}
375
376/// Accepts 32 raw bytes, or 64 hexadecimal characters with surrounding
377/// whitespace.
378fn decode_key_material(material: &[u8]) -> Option<SecretKey> {
379    if let Ok(key) = SecretKey::from_slice(material) {
380        return Some(key);
381    }
382    let trimmed = std::str::from_utf8(material).ok()?.trim();
383    if trimmed.len() != 64 {
384        return None;
385    }
386    let mut bytes = Zeroizing::new([0_u8; 32]);
387    for (slot, pair) in bytes.iter_mut().zip(trimmed.as_bytes().chunks(2)) {
388        let pair = std::str::from_utf8(pair).ok()?;
389        *slot = u8::from_str_radix(pair, 16).ok()?;
390    }
391    Some(SecretKey::new(*bytes))
392}
393
394/// Derives a separate 256-bit key for a domain-specific context.
395#[must_use]
396pub fn derive_key(parent: &SecretKey, context: &[u8]) -> SecretKey {
397    let mut hasher = blake3::Hasher::new_keyed(parent.as_bytes());
398    hasher.update(b"corium/derived-key");
399    hasher.update(context);
400    SecretKey::new(*hasher.finalize().as_bytes())
401}
402
403/// Parses and validates an encrypted blob's cleartext header.
404///
405/// # Errors
406///
407/// Returns a [`CryptError`] when the header, algorithm, or stored length is
408/// invalid.
409pub fn parse_blob_header(object: &[u8]) -> Result<BlobHeader, CryptError> {
410    if object.len() < BLOB_HEADER_LEN || &object[..BLOB_MAGIC.len()] != BLOB_MAGIC {
411        return Err(CryptError::InvalidBlobHeader);
412    }
413    let algorithm = object[BLOB_MAGIC.len()];
414    if algorithm != ALGORITHM_AES_256_GCM_SIV {
415        return Err(CryptError::UnsupportedAlgorithm(algorithm));
416    }
417
418    let epoch_offset = BLOB_MAGIC.len() + 1;
419    let length_offset = epoch_offset + size_of::<u32>();
420    let epoch = u32::from_be_bytes(
421        object[epoch_offset..length_offset]
422            .try_into()
423            .map_err(|_| CryptError::InvalidBlobHeader)?,
424    );
425    let plaintext_len = u64::from_be_bytes(
426        object[length_offset..BLOB_HEADER_LEN]
427            .try_into()
428            .map_err(|_| CryptError::InvalidBlobHeader)?,
429    );
430    let plaintext_len =
431        usize::try_from(plaintext_len).map_err(|_| CryptError::InvalidBlobLength)?;
432    let expected_len = BLOB_HEADER_LEN
433        .checked_add(NONCE_LEN)
434        .and_then(|length| length.checked_add(plaintext_len))
435        .and_then(|length| length.checked_add(AEAD_TAG_LEN))
436        .ok_or(CryptError::InvalidBlobLength)?;
437    if object.len() != expected_len {
438        return Err(CryptError::InvalidBlobLength);
439    }
440    Ok(BlobHeader {
441        epoch,
442        plaintext_len: plaintext_len as u64,
443    })
444}
445
446/// Encrypts a blob deterministically for a given key epoch and plaintext.
447///
448/// The header remains cleartext, is authenticated as AAD, and the returned
449/// object's content digest is suitable as its storage identity.
450///
451/// # Errors
452///
453/// Returns a [`CryptError`] if the plaintext is too large or encryption fails.
454pub fn encrypt_blob(key: &SecretKey, epoch: u32, plaintext: &[u8]) -> Result<Vec<u8>, CryptError> {
455    let plaintext_len =
456        u64::try_from(plaintext.len()).map_err(|_| CryptError::PlaintextTooLarge)?;
457    let mut header = Vec::with_capacity(BLOB_HEADER_LEN);
458    header.extend_from_slice(BLOB_MAGIC);
459    header.push(ALGORITHM_AES_256_GCM_SIV);
460    header.extend_from_slice(&epoch.to_be_bytes());
461    header.extend_from_slice(&plaintext_len.to_be_bytes());
462
463    let plaintext_digest = blake3::hash(plaintext);
464    let mut nonce_hasher = blake3::Hasher::new_keyed(key.as_bytes());
465    nonce_hasher.update(b"corium/blob-nonce");
466    nonce_hasher.update(&header);
467    nonce_hasher.update(plaintext_digest.as_bytes());
468    let nonce_digest = nonce_hasher.finalize();
469    let nonce_bytes = &nonce_digest.as_bytes()[..NONCE_LEN];
470    let cipher =
471        Aes256GcmSiv::new_from_slice(key.as_bytes()).map_err(|_| CryptError::InvalidKeyLength)?;
472    let ciphertext = cipher
473        .encrypt(
474            Nonce::from_slice(nonce_bytes),
475            Payload {
476                msg: plaintext,
477                aad: &header,
478            },
479        )
480        .map_err(|_| CryptError::EncryptionFailed)?;
481    header.extend_from_slice(nonce_bytes);
482    header.extend_from_slice(&ciphertext);
483    Ok(header)
484}
485
486/// Authenticates and decrypts an encrypted blob.
487///
488/// # Errors
489///
490/// Returns a [`CryptError`] for malformed data, the wrong key, or tampering.
491pub fn decrypt_blob(key: &SecretKey, object: &[u8]) -> Result<Vec<u8>, CryptError> {
492    let _header = parse_blob_header(object)?;
493    let header = &object[..BLOB_HEADER_LEN];
494    let nonce_end = BLOB_HEADER_LEN + NONCE_LEN;
495    let nonce = Nonce::from_slice(&object[BLOB_HEADER_LEN..nonce_end]);
496    let ciphertext = &object[nonce_end..];
497    let cipher =
498        Aes256GcmSiv::new_from_slice(key.as_bytes()).map_err(|_| CryptError::InvalidKeyLength)?;
499    cipher
500        .decrypt(
501            nonce,
502            Payload {
503                msg: ciphertext,
504                aad: header,
505            },
506        )
507        .map_err(|_| CryptError::AuthenticationFailed)
508}
509
510/// Reports whether a log-record payload carries the encrypted-record header.
511///
512/// The plaintext record encoding starts with its transaction number as a
513/// big-endian `u64`, so [`LOG_MAGIC`] is only ambiguous with a `t` above
514/// 4.8 quintillion. A log that reached that many transactions would have
515/// exhausted every other counter in the system first.
516#[must_use]
517pub fn is_encrypted_log_record(payload: &[u8]) -> bool {
518    payload.len() >= LOG_MAGIC.len() && &payload[..LOG_MAGIC.len()] == LOG_MAGIC
519}
520
521/// Parses and validates an encrypted log record's cleartext header.
522///
523/// The epoch and transaction number stay cleartext so frame scanning,
524/// recovery truncation, and range reads keep working — and so a reader can
525/// pick the right key epoch — without holding any key.
526///
527/// # Errors
528///
529/// Returns a [`CryptError`] when the header is truncated, is not a log
530/// record, or names an unsupported algorithm.
531pub fn parse_log_header(payload: &[u8]) -> Result<LogHeader, CryptError> {
532    if !is_encrypted_log_record(payload) || payload.len() < LOG_HEADER_LEN + AEAD_TAG_LEN {
533        return Err(CryptError::InvalidLogHeader);
534    }
535    let algorithm = payload[LOG_MAGIC.len()];
536    if algorithm != ALGORITHM_AES_256_GCM {
537        return Err(CryptError::UnsupportedAlgorithm(algorithm));
538    }
539    let epoch_offset = LOG_MAGIC.len() + 1;
540    let t_offset = epoch_offset + size_of::<u32>();
541    let nonce_offset = t_offset + size_of::<u64>();
542    let epoch = u32::from_be_bytes(
543        payload[epoch_offset..t_offset]
544            .try_into()
545            .map_err(|_| CryptError::InvalidLogHeader)?,
546    );
547    let t = u64::from_be_bytes(
548        payload[t_offset..nonce_offset]
549            .try_into()
550            .map_err(|_| CryptError::InvalidLogHeader)?,
551    );
552    Ok(LogHeader { epoch, t })
553}
554
555/// Builds the authenticated header and additional data for one log record.
556fn log_header_and_aad(
557    epoch: u32,
558    lineage: &[u8],
559    log_version: u64,
560    t: u64,
561    nonce: &[u8; NONCE_LEN],
562) -> (Vec<u8>, Vec<u8>) {
563    let mut header = Vec::with_capacity(LOG_HEADER_LEN);
564    header.extend_from_slice(LOG_MAGIC);
565    header.push(ALGORITHM_AES_256_GCM);
566    header.extend_from_slice(&epoch.to_be_bytes());
567    header.extend_from_slice(&t.to_be_bytes());
568    header.extend_from_slice(nonce);
569
570    // The lineage is variable length, so its length is bound too: without it
571    // a lineage/version pair could be re-split to authenticate under another
572    // database.
573    let mut aad = Vec::with_capacity(b"corium/log-v1".len() + 16 + lineage.len() + header.len());
574    aad.extend_from_slice(b"corium/log-v1");
575    aad.extend_from_slice(&(lineage.len() as u64).to_be_bytes());
576    aad.extend_from_slice(lineage);
577    aad.extend_from_slice(&log_version.to_be_bytes());
578    aad.extend_from_slice(&header);
579    (header, aad)
580}
581
582/// Encrypts one transaction-log record payload.
583///
584/// The AAD binds the database lineage, the log's lease version, the
585/// transaction number, and the key epoch, so a record can neither be replayed
586/// at another basis nor moved between the per-lease-version log files that
587/// takeover fencing relies on.
588///
589/// Unlike blobs, log records are not content addressed and nothing requires
590/// re-encoding a record to the same bytes, so this uses one-pass AES-256-GCM
591/// with a fresh random nonce rather than a nonce derived from
592/// `(log_version, t)`. A derived nonce would be reused whenever a transaction
593/// number is re-issued with different content — which happens whenever an
594/// append is torn by a crash before it is acknowledged, and truncated away on
595/// recovery. Key/nonce reuse is fatal under GCM; storing 12 bytes is not.
596///
597/// # Errors
598///
599/// Returns a [`CryptError`] when randomness is unavailable or encryption
600/// fails.
601pub fn encrypt_log_record(
602    key: &SecretKey,
603    epoch: u32,
604    lineage: &[u8],
605    log_version: u64,
606    t: u64,
607    plaintext: &[u8],
608) -> Result<Vec<u8>, CryptError> {
609    let mut nonce_bytes = [0_u8; NONCE_LEN];
610    getrandom::fill(&mut nonce_bytes).map_err(|_| CryptError::RandomnessUnavailable)?;
611    let (mut header, aad) = log_header_and_aad(epoch, lineage, log_version, t, &nonce_bytes);
612    let cipher =
613        Aes256Gcm::new_from_slice(key.as_bytes()).map_err(|_| CryptError::InvalidKeyLength)?;
614    let ciphertext = cipher
615        .encrypt(
616            Nonce::from_slice(&nonce_bytes),
617            Payload {
618                msg: plaintext,
619                aad: &aad,
620            },
621        )
622        .map_err(|_| CryptError::EncryptionFailed)?;
623    header.extend_from_slice(&ciphertext);
624    Ok(header)
625}
626
627/// Authenticates and decrypts one transaction-log record payload.
628///
629/// `log_version` is the lease version of the file or object the payload was
630/// read from; supplying another one fails authentication rather than
631/// returning a record from the wrong version file.
632///
633/// # Errors
634///
635/// Returns a [`CryptError`] for a malformed payload, the wrong key, a
636/// mismatched lineage or log version, or tampering.
637pub fn decrypt_log_record(
638    key: &SecretKey,
639    lineage: &[u8],
640    log_version: u64,
641    payload: &[u8],
642) -> Result<Vec<u8>, CryptError> {
643    let LogHeader { epoch, t } = parse_log_header(payload)?;
644    let header = &payload[..LOG_HEADER_LEN];
645    let nonce_offset = LOG_HEADER_LEN - NONCE_LEN;
646    let nonce_bytes = <[u8; NONCE_LEN]>::try_from(&header[nonce_offset..])
647        .map_err(|_| CryptError::InvalidLogHeader)?;
648    let (_, aad) = log_header_and_aad(epoch, lineage, log_version, t, &nonce_bytes);
649    let cipher =
650        Aes256Gcm::new_from_slice(key.as_bytes()).map_err(|_| CryptError::InvalidKeyLength)?;
651    cipher
652        .decrypt(
653            Nonce::from_slice(&nonce_bytes),
654            Payload {
655                msg: &payload[LOG_HEADER_LEN..],
656                aad: &aad,
657            },
658        )
659        .map_err(|_| CryptError::AuthenticationFailed)
660}
661
662#[cfg(test)]
663mod tests {
664    use super::*;
665    use proptest::prelude::*;
666
667    fn key(byte: u8) -> SecretKey {
668        SecretKey::new([byte; 32])
669    }
670
671    proptest! {
672        #[test]
673        fn blobs_are_deterministic_and_round_trip(
674            plaintext in prop::collection::vec(any::<u8>(), 0..4096)
675        ) {
676            let encrypted = encrypt_blob(&key(7), 3, &plaintext).expect("encrypt");
677            let repeated = encrypt_blob(&key(7), 3, &plaintext).expect("repeat");
678            prop_assert_eq!(&encrypted, &repeated);
679            prop_assert_ne!(
680                encrypt_blob(&key(7), 4, &plaintext).expect("different epoch"),
681                encrypted.clone()
682            );
683            prop_assert_eq!(decrypt_blob(&key(7), &encrypted).expect("decrypt"), plaintext);
684        }
685    }
686
687    #[test]
688    fn header_and_ciphertext_are_authenticated() {
689        let encrypted = encrypt_blob(&key(1), 9, b"sentinel").expect("encrypt");
690        assert_eq!(
691            parse_blob_header(&encrypted).expect("header"),
692            BlobHeader {
693                epoch: 9,
694                plaintext_len: 8,
695            }
696        );
697        assert!(!encrypted.windows(8).any(|window| window == b"sentinel"));
698        assert!(decrypt_blob(&key(2), &encrypted).is_err());
699
700        let mut tampered = encrypted;
701        *tampered.last_mut().expect("ciphertext") ^= 1;
702        assert!(decrypt_blob(&key(1), &tampered).is_err());
703
704        let mut tampered_nonce = encrypt_blob(&key(1), 9, b"sentinel").expect("encrypt");
705        tampered_nonce[BLOB_HEADER_LEN] ^= 1;
706        assert!(decrypt_blob(&key(1), &tampered_nonce).is_err());
707    }
708
709    proptest! {
710        #[test]
711        fn log_records_round_trip(payload in prop::collection::vec(any::<u8>(), 0..4096)) {
712            let encrypted = encrypt_log_record(&key(5), 2, b"people", 7, 42, &payload)
713                .expect("encrypt");
714            prop_assert_eq!(
715                parse_log_header(&encrypted).expect("header"),
716                LogHeader { epoch: 2, t: 42 }
717            );
718            prop_assert_eq!(
719                decrypt_log_record(&key(5), b"people", 7, &encrypted).expect("decrypt"),
720                payload
721            );
722        }
723    }
724
725    #[test]
726    fn log_records_are_bound_to_their_position() {
727        let plaintext = b"sentinel payload".as_slice();
728        let encrypted = encrypt_log_record(&key(5), 2, b"people", 7, 42, plaintext).expect("seal");
729        assert!(is_encrypted_log_record(&encrypted));
730        assert!(
731            !encrypted
732                .windows(plaintext.len())
733                .any(|window| window == plaintext)
734        );
735
736        // Lineage, lease version, transaction number, and epoch are all
737        // authenticated: none of them can be restated without detection.
738        assert!(decrypt_log_record(&key(5), b"other", 7, &encrypted).is_err());
739        assert!(decrypt_log_record(&key(5), b"people", 8, &encrypted).is_err());
740        assert!(decrypt_log_record(&key(6), b"people", 7, &encrypted).is_err());
741
742        let mut moved = encrypted.clone();
743        let t_offset = LOG_MAGIC.len() + 1 + size_of::<u32>();
744        moved[t_offset..t_offset + size_of::<u64>()].copy_from_slice(&43_u64.to_be_bytes());
745        assert_eq!(parse_log_header(&moved).expect("header").t, 43);
746        assert!(decrypt_log_record(&key(5), b"people", 7, &moved).is_err());
747
748        let mut retagged = encrypted;
749        retagged[LOG_MAGIC.len() + 1] ^= 1;
750        assert!(decrypt_log_record(&key(5), b"people", 7, &retagged).is_err());
751    }
752
753    #[test]
754    fn log_records_do_not_reuse_a_nonce() {
755        // A torn append is truncated on recovery and the same `t` is re-issued,
756        // possibly for different tx-data. Encryption must not repeat itself.
757        let first = encrypt_log_record(&key(5), 2, b"people", 7, 42, b"first").expect("first");
758        let second = encrypt_log_record(&key(5), 2, b"people", 7, 42, b"first").expect("second");
759        assert_ne!(first, second);
760        assert_eq!(
761            decrypt_log_record(&key(5), b"people", 7, &second).expect("decrypt"),
762            b"first"
763        );
764    }
765
766    #[test]
767    fn plaintext_records_are_not_mistaken_for_encrypted_ones() {
768        let mut plaintext = 42_u64.to_be_bytes().to_vec();
769        plaintext.extend_from_slice(&[0; 32]);
770        assert!(!is_encrypted_log_record(&plaintext));
771        assert!(matches!(
772            parse_log_header(&plaintext),
773            Err(CryptError::InvalidLogHeader)
774        ));
775        assert!(!is_encrypted_log_record(&[]));
776        assert!(!is_encrypted_log_record(LOG_MAGIC.as_slice().split_at(4).0));
777    }
778
779    #[test]
780    fn generated_keys_are_distinct() {
781        let first = SecretKey::generate().expect("generate");
782        assert_ne!(first, SecretKey::generate().expect("generate"));
783        assert_ne!(first, SecretKey::new([0; 32]));
784    }
785
786    #[test]
787    fn debug_never_reveals_key_material() {
788        let rendered = format!("{:?}", key(0xA5));
789        assert_eq!(rendered, "SecretKey([REDACTED])");
790        assert!(!rendered.contains("165"));
791    }
792
793    #[tokio::test]
794    async fn local_key_sources_accept_raw_and_hex_material() {
795        let dir = std::env::temp_dir().join(format!("corium-crypt-{}", std::process::id()));
796        std::fs::create_dir_all(&dir).expect("temp dir");
797        let raw_path = dir.join("raw.key");
798        std::fs::write(&raw_path, [7_u8; 32]).expect("write raw");
799        let hex_path = dir.join("hex.key");
800        // Trailing newline: what every editor and `echo` produces.
801        std::fs::write(&hex_path, format!("{}\n", "07".repeat(32))).expect("write hex");
802
803        let raw = KeyId::new(format!("file:{}", raw_path.display())).expect("id");
804        let hex = KeyId::new(format!("file:{}", hex_path.display())).expect("id");
805        assert_eq!(load_key(&raw).expect("raw"), key(7));
806        assert_eq!(load_key(&hex).expect("hex"), key(7));
807        assert!(matches!(
808            load_key(&KeyId::new("env:CORIUM_KEY_THAT_IS_NOT_SET").expect("id")),
809            Err(KeyError::Unreadable { .. })
810        ));
811
812        let keyring = StaticKeyring::resolve([raw.clone(), hex]).expect("resolve");
813        assert_eq!(keyring.key_ids().len(), 2);
814        assert_eq!(
815            keyring.current_epoch(&raw).await.expect("epoch"),
816            STATIC_KEY_EPOCH
817        );
818
819        std::fs::remove_dir_all(&dir).expect("clean up");
820    }
821
822    #[test]
823    fn unresolvable_key_identities_name_what_failed() {
824        let missing = KeyId::new("file:/nonexistent/corium/storage.key").expect("id");
825        assert!(matches!(
826            load_key(&missing),
827            Err(KeyError::Unreadable { .. })
828        ));
829        // A KMS identity is a real key source, just not one this function
830        // resolves; it must not be mistaken for a relative path.
831        let kms = KeyId::new("awskms:arn:aws:kms:us-west-2:1:key/2f1c").expect("id");
832        assert!(matches!(
833            load_key(&kms),
834            Err(KeyError::UnsupportedScheme(_))
835        ));
836        assert!(matches!(
837            load_key(&KeyId::new("storage.key").expect("id")),
838            Err(KeyError::UnsupportedScheme(_))
839        ));
840        assert!(decode_key_material(b"too short").is_none());
841        assert!(decode_key_material(&[0; 31]).is_none());
842        assert!(decode_key_material("zz".repeat(32).as_bytes()).is_none());
843    }
844
845    #[tokio::test]
846    async fn static_keyring_resolves_and_wraps_keys() {
847        let id = KeyId::new("file:test-kek").expect("key id");
848        let mut keyring = StaticKeyring::default();
849        keyring.insert(id.clone(), 4, key(4), true);
850
851        assert_eq!(keyring.current_epoch(&id).await.expect("epoch"), 4);
852        assert_eq!(keyring.key_ids(), std::slice::from_ref(&id));
853        let wrapped = keyring.wrap(&id, 4, &key(8)).await.expect("wrap");
854        assert_eq!(
855            keyring.unwrap(&id, 4, &wrapped).await.expect("unwrap"),
856            key(8)
857        );
858    }
859}