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::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
17/// Magic prefix for an encrypted content-addressed blob.
18pub const BLOB_MAGIC: &[u8; 8] = b"CORIUMB1";
19
20/// Magic prefix for an encrypted transaction-log record payload.
21pub 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>();
26/// Magic, algorithm, key epoch, transaction number, nonce.
27const 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/// Opaque, zeroized 256-bit key material.
32#[derive(Clone, Eq, PartialEq)]
33pub struct SecretKey(Zeroizing<[u8; 32]>);
34
35impl SecretKey {
36    /// Copies a 256-bit key into zeroized storage.
37    #[must_use]
38    pub fn new(bytes: [u8; 32]) -> Self {
39        Self(Zeroizing::new(bytes))
40    }
41
42    /// Draws fresh 256-bit key material from the operating system's
43    /// cryptographic random source.
44    ///
45    /// # Errors
46    ///
47    /// Returns [`CryptError::RandomnessUnavailable`] when the OS entropy
48    /// source cannot be read. Callers must fail rather than fall back: a
49    /// predictable data key is indistinguishable from no encryption.
50    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    /// Copies a byte slice into zeroized storage.
57    ///
58    /// # Errors
59    ///
60    /// Returns [`CryptError::InvalidKeyLength`] unless `bytes` is 32 bytes.
61    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/// A key identity stored in a manifest or protection-class entity.
78#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
79pub struct KeyId(String);
80
81impl KeyId {
82    /// Creates a key identity.
83    ///
84    /// # Errors
85    ///
86    /// Returns [`KeyError::InvalidId`] for an empty identity.
87    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    /// Returns the key identity as its URI-like string.
96    #[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/// Parsed metadata from an encrypted blob header.
109#[derive(Clone, Copy, Debug, Eq, PartialEq)]
110pub struct BlobHeader {
111    /// Key epoch used to encrypt the object.
112    pub epoch: u32,
113    /// Length of the plaintext payload.
114    pub plaintext_len: u64,
115}
116
117/// Parsed metadata from an encrypted log-record payload header.
118#[derive(Clone, Copy, Debug, Eq, PartialEq)]
119pub struct LogHeader {
120    /// Key epoch used to encrypt the payload.
121    pub epoch: u32,
122    /// Transaction number the payload was written at.
123    pub t: u64,
124}
125
126/// Failures while encrypting or decrypting stored data.
127#[derive(Debug, Error)]
128pub enum CryptError {
129    /// A secret key was not exactly 256 bits.
130    #[error("secret key must be exactly 32 bytes")]
131    InvalidKeyLength,
132    /// The operating system's random source was unavailable.
133    #[error("cryptographic randomness is unavailable")]
134    RandomnessUnavailable,
135    /// The encrypted object does not contain a complete, supported header.
136    #[error("invalid encrypted blob header")]
137    InvalidBlobHeader,
138    /// The log payload does not contain a complete, supported header.
139    #[error("invalid encrypted log record header")]
140    InvalidLogHeader,
141    /// The encrypted object names an unsupported algorithm.
142    #[error("unsupported encrypted blob algorithm {0}")]
143    UnsupportedAlgorithm(u8),
144    /// The object length disagrees with its authenticated header.
145    #[error("encrypted blob length does not match its header")]
146    InvalidBlobLength,
147    /// Encryption failed after inputs were validated.
148    #[error("encryption failed")]
149    EncryptionFailed,
150    /// Authentication failed, including when the wrong key was supplied.
151    #[error("authentication failed")]
152    AuthenticationFailed,
153    /// The plaintext is too large for the stored length field.
154    #[error("plaintext is too large to encrypt")]
155    PlaintextTooLarge,
156}
157
158/// Failures while resolving or wrapping keys.
159#[derive(Debug, Error)]
160pub enum KeyError {
161    /// A key identity was empty.
162    #[error("key identity must not be empty")]
163    InvalidId,
164    /// No material exists for this identity and epoch.
165    #[error("key {id} has no material for epoch {epoch}")]
166    MissingKey {
167        /// Requested key identity.
168        id: KeyId,
169        /// Requested key epoch.
170        epoch: u32,
171    },
172    /// No current write epoch is configured for this identity.
173    #[error("key {0} has no current epoch")]
174    MissingCurrentEpoch(KeyId),
175    /// Wrapped key material decrypted to an invalid length.
176    #[error("wrapped key did not contain a 256-bit key")]
177    InvalidWrappedKey,
178    /// Wrapped key metadata named a different key epoch.
179    #[error("wrapped key uses epoch {actual}, expected {expected}")]
180    WrappedEpochMismatch {
181        /// Requested key epoch.
182        expected: u32,
183        /// Epoch recorded in the wrapped key.
184        actual: u32,
185    },
186    /// A cryptographic operation failed.
187    #[error(transparent)]
188    Crypt(#[from] CryptError),
189}
190
191/// Resolves key material without coupling Corium to a KMS implementation.
192#[async_trait]
193pub trait Keyring: Send + Sync {
194    /// Resolves material for a specific epoch.
195    async fn key(&self, id: &KeyId, epoch: u32) -> Result<SecretKey, KeyError>;
196
197    /// Returns the epoch new writes should use.
198    async fn current_epoch(&self, id: &KeyId) -> Result<u32, KeyError>;
199
200    /// Wraps a data-encryption key under the requested key and epoch.
201    async fn wrap(&self, id: &KeyId, epoch: u32, dek: &SecretKey) -> Result<Vec<u8>, KeyError>;
202
203    /// Unwraps a stored data-encryption key.
204    async fn unwrap(&self, id: &KeyId, epoch: u32, wrapped: &[u8]) -> Result<SecretKey, KeyError>;
205
206    /// Lists the key identities this process can resolve.
207    fn key_ids(&self) -> &[KeyId];
208}
209
210/// In-memory keyring for tests and keys loaded from files or environment
211/// variables by a higher-level configuration layer.
212#[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    /// Inserts material and optionally makes its epoch current for writes.
221    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/// Derives a separate 256-bit key for a domain-specific context.
281#[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
289/// Parses and validates an encrypted blob's cleartext header.
290///
291/// # Errors
292///
293/// Returns a [`CryptError`] when the header, algorithm, or stored length is
294/// invalid.
295pub 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
332/// Encrypts a blob deterministically for a given key epoch and plaintext.
333///
334/// The header remains cleartext, is authenticated as AAD, and the returned
335/// object's content digest is suitable as its storage identity.
336///
337/// # Errors
338///
339/// Returns a [`CryptError`] if the plaintext is too large or encryption fails.
340pub 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
372/// Authenticates and decrypts an encrypted blob.
373///
374/// # Errors
375///
376/// Returns a [`CryptError`] for malformed data, the wrong key, or tampering.
377pub 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/// Reports whether a log-record payload carries the encrypted-record header.
397///
398/// The plaintext record encoding starts with its transaction number as a
399/// big-endian `u64`, so [`LOG_MAGIC`] is only ambiguous with a `t` above
400/// 4.8 quintillion. A log that reached that many transactions would have
401/// exhausted every other counter in the system first.
402#[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
407/// Parses and validates an encrypted log record's cleartext header.
408///
409/// The epoch and transaction number stay cleartext so frame scanning,
410/// recovery truncation, and range reads keep working — and so a reader can
411/// pick the right key epoch — without holding any key.
412///
413/// # Errors
414///
415/// Returns a [`CryptError`] when the header is truncated, is not a log
416/// record, or names an unsupported algorithm.
417pub 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
441/// Builds the authenticated header and additional data for one log record.
442fn 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    // The lineage is variable length, so its length is bound too: without it
457    // a lineage/version pair could be re-split to authenticate under another
458    // database.
459    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
468/// Encrypts one transaction-log record payload.
469///
470/// The AAD binds the database lineage, the log's lease version, the
471/// transaction number, and the key epoch, so a record can neither be replayed
472/// at another basis nor moved between the per-lease-version log files that
473/// takeover fencing relies on.
474///
475/// Unlike blobs, log records are not content addressed and nothing requires
476/// re-encoding a record to the same bytes, so this uses one-pass AES-256-GCM
477/// with a fresh random nonce rather than a nonce derived from
478/// `(log_version, t)`. A derived nonce would be reused whenever a transaction
479/// number is re-issued with different content — which happens whenever an
480/// append is torn by a crash before it is acknowledged, and truncated away on
481/// recovery. Key/nonce reuse is fatal under GCM; storing 12 bytes is not.
482///
483/// # Errors
484///
485/// Returns a [`CryptError`] when randomness is unavailable or encryption
486/// fails.
487pub 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
513/// Authenticates and decrypts one transaction-log record payload.
514///
515/// `log_version` is the lease version of the file or object the payload was
516/// read from; supplying another one fails authentication rather than
517/// returning a record from the wrong version file.
518///
519/// # Errors
520///
521/// Returns a [`CryptError`] for a malformed payload, the wrong key, a
522/// mismatched lineage or log version, or tampering.
523pub 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        // Lineage, lease version, transaction number, and epoch are all
623        // authenticated: none of them can be restated without detection.
624        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        // A torn append is truncated on recovery and the same `t` is re-issued,
642        // possibly for different tx-data. Encryption must not repeat itself.
643        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}