Skip to main content

coven_core/
encryption.rs

1use std::collections::BTreeMap;
2use std::fmt;
3use std::str::FromStr;
4
5use crate::keys::KeyError;
6use chacha20poly1305::aead::generic_array::GenericArray;
7use chacha20poly1305::aead::{Aead, Payload};
8use chacha20poly1305::{KeyInit, XChaCha20Poly1305};
9use hkdf::Hkdf;
10use rand::RngCore;
11use sha2::{Digest, Sha256};
12use thiserror::Error;
13use tracing::info;
14
15/// XChaCha20-Poly1305 nonce size (24 bytes).
16pub const NONCE_SIZE: usize = 24;
17
18/// Poly1305 auth tag size (16 bytes).
19pub const TAG_SIZE: usize = 16;
20
21/// 64KB plaintext chunks
22pub const CHUNK_SIZE: usize = 65536;
23/// Each encrypted chunk: plaintext + 16-byte auth tag
24pub const ENCRYPTED_CHUNK_SIZE: usize = CHUNK_SIZE + TAG_SIZE;
25pub const INITIAL_KEY_GENERATION: u64 = 1;
26const AEAD_V2_LABEL: &[u8] = b"coven-aead-v2";
27
28/// The sealed app-data format version this build writes, and the only one it
29/// reads. The leading byte of every payload [`EncryptionService::seal_app_data`]
30/// produces; a payload naming any other version is refused
31/// ([`SealError::UnknownVersion`]) rather than guessed at.
32pub const APP_DATA_SEAL_VERSION: u8 = 1;
33
34/// The fixed header every sealed app-data payload carries ahead of its
35/// ciphertext: the version byte, then the sealing key's 8-byte fingerprint.
36const APP_DATA_FINGERPRINT_SIZE: usize = 8;
37const APP_DATA_HEADER_SIZE: usize = 1 + APP_DATA_FINGERPRINT_SIZE;
38
39/// Stable wire identity of one 32-byte encryption key: the first eight bytes
40/// of its SHA-256 digest, serialized as exactly sixteen lowercase hex digits.
41#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
42pub struct KeyFingerprint([u8; 8]);
43
44impl KeyFingerprint {
45    pub fn from_bytes(bytes: [u8; 8]) -> Self {
46        Self(bytes)
47    }
48
49    pub fn as_bytes(&self) -> &[u8; 8] {
50        &self.0
51    }
52}
53
54impl fmt::Debug for KeyFingerprint {
55    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
56        fmt::Display::fmt(self, formatter)
57    }
58}
59
60impl fmt::Display for KeyFingerprint {
61    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
62        formatter.write_str(&hex::encode(self.0))
63    }
64}
65
66impl FromStr for KeyFingerprint {
67    type Err = KeyFingerprintParseError;
68
69    fn from_str(value: &str) -> Result<Self, Self::Err> {
70        if value.len() != 16
71            || value
72                .bytes()
73                .any(|byte| !byte.is_ascii_digit() && !(b'a'..=b'f').contains(&byte))
74        {
75            return Err(KeyFingerprintParseError(value.to_string()));
76        }
77        let bytes: [u8; 8] = hex::decode(value)
78            .map_err(|_| KeyFingerprintParseError(value.to_string()))?
79            .try_into()
80            .map_err(|_| KeyFingerprintParseError(value.to_string()))?;
81        Ok(Self(bytes))
82    }
83}
84
85impl serde::Serialize for KeyFingerprint {
86    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
87    where
88        S: serde::Serializer,
89    {
90        serializer.serialize_str(&self.to_string())
91    }
92}
93
94impl<'de> serde::Deserialize<'de> for KeyFingerprint {
95    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
96    where
97        D: serde::Deserializer<'de>,
98    {
99        <String as serde::Deserialize>::deserialize(deserializer)?
100            .parse()
101            .map_err(serde::de::Error::custom)
102    }
103}
104
105#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
106#[error("key fingerprint must be exactly sixteen lowercase hexadecimal characters: {0:?}")]
107pub struct KeyFingerprintParseError(String);
108
109/// Generate a random 32-byte key.
110pub fn generate_random_key() -> [u8; 32] {
111    let mut key = [0u8; 32];
112    rand::rng().fill_bytes(&mut key);
113    key
114}
115
116/// The length of the encrypted blob [`EncryptionService::encrypt`] produces for
117/// a plaintext of `plaintext_len` bytes: the base nonce, the plaintext itself,
118/// and one 16-byte tag per chunk. An empty plaintext still produces one
119/// (tag-only) chunk. Lets a streaming upload know the final object size up
120/// front, before a byte is sealed.
121pub fn chunked_encrypted_len(plaintext_len: u64) -> u64 {
122    NONCE_SIZE as u64
123        + plaintext_len
124        + chunk_count_for_plaintext_len(plaintext_len) * TAG_SIZE as u64
125}
126
127/// Incremental encryptor that emits the same `[base_nonce][chunk_0][chunk_1]...`
128/// bytes as [`EncryptionService::encrypt`], one chunk at a time, so a large blob
129/// is sealed and uploaded without ever holding the whole plaintext or ciphertext
130/// in memory. `encrypt` is itself implemented on top of this, so the streaming
131/// and whole-buffer forms cannot drift.
132pub struct ChunkSealer {
133    cipher: XChaCha20Poly1305,
134    base_nonce: [u8; NONCE_SIZE],
135    aad_context: Vec<u8>,
136    total_chunks: u64,
137    next_index: u64,
138}
139
140impl ChunkSealer {
141    /// Start a sealer with a fresh random base nonce.
142    fn new(key: &[u8; 32], plaintext_len: u64, aad_context: &[u8]) -> Self {
143        let mut base_nonce = [0u8; NONCE_SIZE];
144        rand::rng().fill_bytes(&mut base_nonce);
145        Self {
146            cipher: XChaCha20Poly1305::new(GenericArray::from_slice(key)),
147            base_nonce,
148            aad_context: aad_context.to_vec(),
149            total_chunks: chunk_count_for_plaintext_len(plaintext_len),
150            next_index: 0,
151        }
152    }
153
154    /// The base nonce — the first [`NONCE_SIZE`] bytes of the encrypted blob,
155    /// emitted before any chunk.
156    pub fn base_nonce(&self) -> [u8; NONCE_SIZE] {
157        self.base_nonce
158    }
159
160    /// Seal one plaintext chunk (at most [`CHUNK_SIZE`] bytes) into its
161    /// ciphertext-plus-tag, advancing the chunk counter. A chunk longer than
162    /// `CHUNK_SIZE` would desync the framing the decryptor expects, so the caller
163    /// must split the plaintext on `CHUNK_SIZE` boundaries.
164    pub fn seal_chunk(&mut self, plaintext: &[u8]) -> Vec<u8> {
165        debug_assert!(
166            plaintext.len() <= CHUNK_SIZE,
167            "a sealed chunk must be at most CHUNK_SIZE bytes"
168        );
169        let nonce = chunk_nonce(&self.base_nonce, self.next_index);
170        let aad = chunk_aad(&self.aad_context, self.next_index, self.total_chunks);
171        self.next_index += 1;
172        self.cipher
173            .encrypt(
174                GenericArray::from_slice(&nonce),
175                Payload {
176                    msg: plaintext,
177                    aad: &aad,
178                },
179            )
180            .expect("encryption should not fail")
181    }
182}
183
184#[derive(Error, Debug)]
185pub enum EncryptionError {
186    #[error("Encryption failed: {0}")]
187    Encryption(String),
188    #[error("Decryption failed: {0}")]
189    Decryption(String),
190    #[error("Key management error: {0}")]
191    KeyManagement(String),
192    #[error("IO error: {0}")]
193    Io(#[from] std::io::Error),
194}
195
196/// Why sealing or opening a host's app-data failed.
197///
198/// Sealing can only fail before the cipher runs — the store has no master key
199/// to seal under. Opening adds the failures a stored payload can carry: a
200/// version this build does not read, a generation this keyring holds no key
201/// for, or an AEAD rejection (a wrong `aad`, a tampered or truncated payload).
202#[derive(Debug, Error)]
203pub enum SealError {
204    /// Custody unlocked no keyring: the store is locked, or a master key was
205    /// never established. The app-data counterpart of the sync engine's
206    /// master-key gate — `unlock` returning `None` is refused here, never
207    /// treated as an empty keyring to seal under.
208    #[error("no master key is established for this store (locked, or never initialized)")]
209    Locked,
210    /// Custody could not produce the keyring — a wrong passphrase, an
211    /// unreadable backing store. Distinct from [`Self::Locked`], which is a
212    /// legitimate absence rather than a failure.
213    #[error("custody error: {0}")]
214    Custody(#[from] KeyError),
215    /// The payload's leading version byte is not one this build seals or reads.
216    #[error("unsupported sealed app-data version {0}")]
217    UnknownVersion(u8),
218    /// The payload names a key (by fingerprint) this keyring does not hold: the
219    /// keyring predates the payload, or the payload was sealed under a foreign one.
220    #[error("sealed app-data names key {0}, which this keyring does not hold")]
221    UnknownKey(String),
222    /// The AEAD rejected the payload — a wrong `aad`, or a tampered or
223    /// truncated ciphertext. Surfaced as it happened, never masked.
224    #[error("app-data cryptography failed: {0}")]
225    Crypto(#[from] EncryptionError),
226}
227
228#[derive(serde::Serialize, serde::Deserialize)]
229struct StoredKeyring {
230    keys: Vec<StoredKeyringGeneration>,
231}
232
233#[derive(serde::Serialize, serde::Deserialize)]
234struct StoredKeyringGeneration {
235    generation: u64,
236    key_hex: String,
237}
238
239/// One key a keyring holds: its 32 bytes and the generation number that orders
240/// it. The key's fingerprint (not the generation) is its identity — two entries
241/// can share a generation number without colliding.
242#[derive(Clone)]
243struct KeyEntry {
244    generation: u64,
245    key: [u8; 32],
246}
247
248/// A store's master key material: every key it holds. This is the value custody
249/// implementations store, unlock, and re-protect — never a cipher. coven builds
250/// the [`EncryptionService`] cipher from it internally; custody never touches
251/// cipher machinery.
252#[derive(Clone)]
253pub struct MasterKeyring {
254    keys: BTreeMap<[u8; 8], KeyEntry>,
255}
256
257impl std::fmt::Debug for MasterKeyring {
258    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
259        f.debug_struct("MasterKeyring")
260            .field("keys", &"<redacted>")
261            .finish()
262    }
263}
264
265impl MasterKeyring {
266    /// One fresh generation-1 key.
267    pub fn generate() -> Self {
268        Self::from(EncryptionService::from_key(generate_random_key()))
269    }
270
271    /// Serialize to the stored keyring JSON — the same format
272    /// [`EncryptionService::to_keyring_string`] produces, since every
273    /// generation this type holds came from (or feeds) that cipher.
274    pub fn to_serialized(&self) -> String {
275        EncryptionService::from(self.clone())
276            .to_keyring_string()
277            .expect("a MasterKeyring always holds at least one generation")
278    }
279
280    /// Parse the stored master-key format [`Self::to_serialized`] produces.
281    pub fn from_serialized(s: &str) -> Result<Self, EncryptionError> {
282        EncryptionService::new(s).map(Self::from)
283    }
284
285    /// SHA-256 fingerprint of the seal key (the deterministically selected
286    /// key this keyring seals new data under), first 8 bytes hex-encoded. Short
287    /// enough to display in UI, long enough to detect wrong keys.
288    pub fn fingerprint(&self) -> String {
289        EncryptionService::from(self.clone()).fingerprint()
290    }
291}
292
293impl From<EncryptionService> for MasterKeyring {
294    fn from(service: EncryptionService) -> Self {
295        Self { keys: service.keys }
296    }
297}
298
299impl From<MasterKeyring> for EncryptionService {
300    fn from(keyring: MasterKeyring) -> Self {
301        EncryptionService { keys: keyring.keys }
302    }
303}
304
305/// The 8-byte fingerprint of a key: the first 8 bytes of its SHA-256. A keyring
306/// entry's identity, and what a sealed object names to say which key sealed it.
307fn key_fingerprint_bytes(key: &[u8; 32]) -> [u8; 8] {
308    let hash = Sha256::digest(key);
309    let mut fingerprint = [0u8; 8];
310    fingerprint.copy_from_slice(&hash[..8]);
311    fingerprint
312}
313
314/// Manages encryption keys and provides XChaCha20-Poly1305 encryption/decryption
315///
316/// This implements the security model described in the README:
317/// - Files are encrypted using XChaCha20-Poly1305 for authenticated encryption
318/// - Chunked format enables random-access decryption for efficient range reads
319#[derive(Clone)]
320pub struct EncryptionService {
321    // Keyed by key fingerprint, so two keys sharing a generation number (a fork
322    // from two owners rotating at once) coexist as distinct entries rather than
323    // one silently overwriting the other. The seal key is chosen deterministically
324    // (highest generation, then greatest fingerprint), so once every device holds
325    // the union of both, they all converge on one seal key. A sealed object names
326    // the key it was sealed under by fingerprint, so anything sealed under any key
327    // this keyring holds stays decryptable regardless of which key is current.
328    keys: BTreeMap<[u8; 8], KeyEntry>,
329}
330impl std::fmt::Debug for EncryptionService {
331    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
332        f.debug_struct("EncryptionService")
333            .field("keys", &"<redacted>")
334            .finish()
335    }
336}
337impl EncryptionService {
338    /// Create an encryption service from a serialized keyring.
339    pub fn new(stored_key: &str) -> Result<Self, EncryptionError> {
340        info!("Loading master key...");
341        EncryptionService::from_keyring_json(stored_key)
342    }
343
344    /// Create a new encryption service from a raw 32-byte key.
345    pub fn from_key(key: [u8; 32]) -> Self {
346        Self::from_key_at_generation(INITIAL_KEY_GENERATION, key)
347    }
348
349    pub fn from_key_at_generation(generation: u64, key: [u8; 32]) -> Self {
350        let mut keys = BTreeMap::new();
351        keys.insert(key_fingerprint_bytes(&key), KeyEntry { generation, key });
352        EncryptionService { keys }
353    }
354
355    pub fn from_keyring(
356        keys: impl IntoIterator<Item = (u64, [u8; 32])>,
357    ) -> Result<Self, EncryptionError> {
358        let keys: BTreeMap<[u8; 8], KeyEntry> = keys
359            .into_iter()
360            .map(|(generation, key)| (key_fingerprint_bytes(&key), KeyEntry { generation, key }))
361            .collect();
362        if keys.is_empty() {
363            return Err(EncryptionError::KeyManagement(
364                "keyring has no keys".to_string(),
365            ));
366        }
367        Ok(EncryptionService { keys })
368    }
369
370    /// The keyring entry this device seals new data under, chosen
371    /// deterministically fleet-wide: the highest generation number, and among
372    /// keys sharing that generation, the greatest fingerprint. Once the wraps of
373    /// a fork propagate so every device holds both keys, they all pick the same
374    /// one here — a fork converges instead of partitioning.
375    fn seal_entry(&self) -> (&[u8; 8], &KeyEntry) {
376        self.keys
377            .iter()
378            .max_by(|(fingerprint_a, a), (fingerprint_b, b)| {
379                a.generation
380                    .cmp(&b.generation)
381                    .then_with(|| fingerprint_a.cmp(fingerprint_b))
382            })
383            .expect("a keyring always holds at least one key")
384    }
385
386    pub fn current_generation(&self) -> u64 {
387        self.seal_entry().1.generation
388    }
389
390    /// How many keys this keyring holds. Two keys at the same generation count
391    /// as two — the count grows only when a genuinely new key is folded in.
392    pub fn key_count(&self) -> usize {
393        self.keys.len()
394    }
395
396    pub fn keyring_entries(&self) -> Vec<(u64, [u8; 32])> {
397        self.keys
398            .values()
399            .map(|entry| (entry.generation, entry.key))
400            .collect()
401    }
402
403    /// Union this keyring with `other`: every key either holds. A key already
404    /// present (same fingerprint) keeps its existing entry, so a merge never
405    /// drops a key already adopted and never rewrites one. This is how adoption
406    /// folds an incoming keyring in — keyrings merge, they never replace.
407    pub fn merged_with(&self, other: &EncryptionService) -> EncryptionService {
408        let mut keys = self.keys.clone();
409        for (fingerprint, entry) in &other.keys {
410            keys.entry(*fingerprint).or_insert_with(|| entry.clone());
411        }
412        EncryptionService { keys }
413    }
414
415    pub fn to_keyring_string(&self) -> Result<String, EncryptionError> {
416        let payload = StoredKeyring {
417            keys: self
418                .keys
419                .values()
420                .map(|entry| StoredKeyringGeneration {
421                    generation: entry.generation,
422                    key_hex: hex::encode(entry.key),
423                })
424                .collect(),
425        };
426        serde_json::to_string(&payload)
427            .map_err(|e| EncryptionError::KeyManagement(format!("serialize keyring: {e}")))
428    }
429
430    pub fn to_keyring_payload(&self) -> Result<Vec<u8>, EncryptionError> {
431        self.to_keyring_string().map(String::into_bytes)
432    }
433
434    pub fn from_keyring_payload(plaintext: Vec<u8>) -> Result<Self, EncryptionError> {
435        let keyring = String::from_utf8(plaintext).map_err(|e| {
436            EncryptionError::KeyManagement(format!("keyring payload is not UTF-8: {e}"))
437        })?;
438        EncryptionService::from_keyring_json(&keyring)
439    }
440
441    fn from_keyring_json(keyring: &str) -> Result<Self, EncryptionError> {
442        let payload: StoredKeyring = serde_json::from_str(keyring).map_err(|e| {
443            EncryptionError::KeyManagement(format!("keyring JSON is malformed: {e}"))
444        })?;
445        let mut keys = Vec::with_capacity(payload.keys.len());
446        for entry in payload.keys {
447            let key: [u8; 32] = hex::decode(&entry.key_hex)
448                .map_err(|e| {
449                    EncryptionError::KeyManagement(format!("keyring key is not hex: {e}"))
450                })?
451                .try_into()
452                .map_err(|_| {
453                    EncryptionError::KeyManagement("keyring key is not 32 bytes".to_string())
454                })?;
455            keys.push((entry.generation, key));
456        }
457        EncryptionService::from_keyring(keys)
458    }
459
460    /// The key with fingerprint `fingerprint`, if this keyring holds it. A sealed
461    /// object names its sealing key this way, so decryption resolves the key by
462    /// identity rather than by a generation number that a fork could reuse.
463    pub fn key_for_fingerprint(&self, fingerprint: &[u8; 8]) -> Result<[u8; 32], EncryptionError> {
464        self.keys.get(fingerprint).map(|e| e.key).ok_or_else(|| {
465            EncryptionError::KeyManagement(format!(
466                "no key with fingerprint {}",
467                hex::encode(fingerprint)
468            ))
469        })
470    }
471
472    pub fn service_for_fingerprint(
473        &self,
474        fingerprint: &[u8; 8],
475    ) -> Result<EncryptionService, EncryptionError> {
476        let key = self.key_for_fingerprint(fingerprint)?;
477        // The single-key service keeps the source key's generation so its own
478        // seal choices and any re-serialization stay consistent with the keyring
479        // it came from.
480        let generation = self
481            .keys
482            .get(fingerprint)
483            .expect("just resolved")
484            .generation;
485        Ok(EncryptionService::from_key_at_generation(generation, key))
486    }
487
488    pub fn with_appended_generation(
489        &self,
490        generation: u64,
491        key: [u8; 32],
492    ) -> Result<EncryptionService, EncryptionError> {
493        if generation <= self.current_generation() {
494            return Err(EncryptionError::KeyManagement(format!(
495                "new generation {generation} must be greater than current generation {}",
496                self.current_generation()
497            )));
498        }
499        let mut keys = self.keys.clone();
500        keys.insert(key_fingerprint_bytes(&key), KeyEntry { generation, key });
501        Ok(EncryptionService { keys })
502    }
503
504    /// SHA-256 fingerprint of the seal key, first 8 bytes hex-encoded (16 hex
505    /// chars). Short enough to display in UI, long enough to detect wrong keys.
506    pub fn fingerprint(&self) -> String {
507        hex::encode(self.seal_fingerprint())
508    }
509
510    /// The seal key's 8-byte fingerprint — what a sealed object records so a
511    /// later read resolves the exact key, whatever the keyring has become since.
512    pub fn seal_fingerprint(&self) -> [u8; 8] {
513        *self.seal_entry().0
514    }
515
516    pub fn seal_key_fingerprint(&self) -> KeyFingerprint {
517        KeyFingerprint::from_bytes(self.seal_fingerprint())
518    }
519
520    /// Return the raw 32-byte seal key.
521    pub fn key_bytes(&self) -> [u8; 32] {
522        self.seal_entry().1.key
523    }
524
525    /// Encrypt data using chunked XChaCha20-Poly1305 format.
526    /// Returns: [base_nonce: 24 bytes][ciphertext with auth tags]
527    /// For small data (single chunk), this is equivalent to standard AEAD.
528    /// For large data, each chunk is independently encrypted for random-access.
529    pub fn encrypt(&self, plaintext: &[u8], aad_context: &[u8]) -> Vec<u8> {
530        let mut sealer = self.sealer(plaintext.len() as u64, aad_context);
531        let mut output = sealer.base_nonce().to_vec();
532
533        // Empty plaintext still produces one chunk holding just the auth tag.
534        if plaintext.is_empty() {
535            output.extend(sealer.seal_chunk(&[]));
536            return output;
537        }
538
539        for chunk in plaintext.chunks(CHUNK_SIZE) {
540            output.extend(sealer.seal_chunk(chunk));
541        }
542
543        output
544    }
545
546    /// Decrypt data in chunked format: [nonce (24 bytes)][ciphertext chunks...]
547    pub fn decrypt(
548        &self,
549        encrypted_data: &[u8],
550        aad_context: &[u8],
551    ) -> Result<Vec<u8>, EncryptionError> {
552        let base_nonce = read_base_nonce(encrypted_data)?;
553        let layout = encrypted_chunk_layout(encrypted_data.len())?;
554        let key = self.key_bytes();
555        let cipher = XChaCha20Poly1305::new(GenericArray::from_slice(&key));
556
557        let mut result = Vec::with_capacity(decrypted_len_upper_bound(layout.data_len));
558        for chunk_index in 0..layout.total_chunks {
559            let (chunk_start, chunk_end) =
560                layout.chunk_bounds(encrypted_data.len(), chunk_index)?;
561            let chunk_data = &encrypted_data[chunk_start..chunk_end];
562            let decrypted = decrypt_chunk_with_cipher(
563                &cipher,
564                &base_nonce,
565                aad_context,
566                chunk_index as u64,
567                layout.total_chunks as u64,
568                chunk_data,
569            )
570            .map_err(|_| {
571                EncryptionError::Decryption(format!(
572                    "Authentication failed for chunk {}",
573                    chunk_index
574                ))
575            })?;
576            result.extend(decrypted);
577        }
578
579        Ok(result)
580    }
581
582    /// A streaming sealer over this service's key, for encrypting a blob
583    /// chunk-by-chunk straight into an upload. See [`ChunkSealer`].
584    pub fn sealer(&self, plaintext_len: u64, aad_context: &[u8]) -> ChunkSealer {
585        ChunkSealer::new(&self.key_bytes(), plaintext_len, aad_context)
586    }
587
588    /// Decrypt a specific chunk from chunked encrypted data.
589    /// Enables random-access decryption without reading preceding chunks.
590    pub fn decrypt_chunk(
591        &self,
592        ciphertext: &[u8],
593        chunk_index: usize,
594        aad_context: &[u8],
595    ) -> Result<Vec<u8>, EncryptionError> {
596        let base_nonce = read_base_nonce(ciphertext)?;
597        let layout = encrypted_chunk_layout(ciphertext.len())?;
598        let (chunk_start, chunk_end) = layout.chunk_bounds(ciphertext.len(), chunk_index)?;
599        let chunk_data = &ciphertext[chunk_start..chunk_end];
600
601        let key = self.key_bytes();
602        let cipher = XChaCha20Poly1305::new(GenericArray::from_slice(&key));
603        decrypt_chunk_with_cipher(
604            &cipher,
605            &base_nonce,
606            aad_context,
607            chunk_index as u64,
608            layout.total_chunks as u64,
609            chunk_data,
610        )
611        .map_err(|_| EncryptionError::Decryption("Authentication failed".to_string()))
612    }
613
614    /// Decrypt a plaintext byte range using nonce from DB and partial chunk data.
615    ///
616    /// This is the efficient method for encrypted range requests:
617    /// - `nonce`: 24-byte nonce stored in DB at import time
618    /// - `encrypted_chunks`: Raw encrypted chunk bytes (NO nonce prefix)
619    /// - `first_chunk_index`: Which chunk index the encrypted_chunks starts at
620    /// - `plaintext_start`, `plaintext_end`: Absolute byte positions in original file
621    ///
622    /// Example: To read plaintext bytes 500,000-600,000:
623    /// 1. Calculate needed chunks: `encrypted_chunk_range(500000, 600000)` -> chunks 7-9
624    /// 2. Fetch encrypted bytes from cloud at those positions
625    /// 3. Call `decrypt_range_with_offset(nonce, chunks, 7, 500000, 600000, source_size, aad_context)`
626    pub fn decrypt_range_with_offset(
627        &self,
628        nonce: &[u8],
629        encrypted_chunks: &[u8],
630        first_chunk_index: u64,
631        plaintext_start: u64,
632        plaintext_end: u64,
633        source_size: u64,
634        aad_context: &[u8],
635    ) -> Result<Vec<u8>, EncryptionError> {
636        if nonce.len() != NONCE_SIZE {
637            return Err(EncryptionError::Decryption(format!(
638                "Invalid nonce length: expected {}, got {}",
639                NONCE_SIZE,
640                nonce.len()
641            )));
642        }
643
644        if plaintext_start >= plaintext_end {
645            return Err(EncryptionError::Decryption(format!(
646                "Invalid range: start ({}) >= end ({})",
647                plaintext_start, plaintext_end
648            )));
649        }
650        if plaintext_end > source_size {
651            return Err(EncryptionError::Decryption(format!(
652                "Invalid range: end ({plaintext_end}) > source size ({source_size})"
653            )));
654        }
655
656        let base_nonce: [u8; NONCE_SIZE] = nonce
657            .try_into()
658            .map_err(|_| EncryptionError::Decryption("Invalid nonce".to_string()))?;
659
660        let key = self.key_bytes();
661        let cipher = XChaCha20Poly1305::new(GenericArray::from_slice(&key));
662
663        let start_chunk = plaintext_start / CHUNK_SIZE as u64;
664        let end_chunk = (plaintext_end.saturating_sub(1)) / CHUNK_SIZE as u64;
665        let total_chunks = chunk_count_for_plaintext_len(source_size);
666
667        let mut plaintext = Vec::new();
668
669        for absolute_chunk_idx in start_chunk..=end_chunk {
670            // Convert absolute chunk index to position in encrypted_chunks
671            let relative_idx = absolute_chunk_idx - first_chunk_index;
672            let chunk_start = (relative_idx as usize) * ENCRYPTED_CHUNK_SIZE;
673
674            // Handle last chunk which may be smaller
675            let chunk_end = if chunk_start + ENCRYPTED_CHUNK_SIZE > encrypted_chunks.len() {
676                encrypted_chunks.len()
677            } else {
678                chunk_start + ENCRYPTED_CHUNK_SIZE
679            };
680
681            if chunk_start >= encrypted_chunks.len() {
682                return Err(EncryptionError::Decryption(format!(
683                    "Chunk {} not in provided data (first_chunk_index={})",
684                    absolute_chunk_idx, first_chunk_index
685                )));
686            }
687
688            let chunk_data = &encrypted_chunks[chunk_start..chunk_end];
689            let decrypted = decrypt_chunk_with_cipher(
690                &cipher,
691                &base_nonce,
692                aad_context,
693                absolute_chunk_idx,
694                total_chunks,
695                chunk_data,
696            )
697            .map_err(|_| {
698                EncryptionError::Decryption(format!(
699                    "Authentication failed for chunk {}",
700                    absolute_chunk_idx
701                ))
702            })?;
703
704            plaintext.extend(decrypted);
705        }
706
707        // Slice to exact range within the decrypted chunks
708        let offset_in_first_chunk = (plaintext_start % CHUNK_SIZE as u64) as usize;
709        let len = (plaintext_end - plaintext_start) as usize;
710        let end = offset_in_first_chunk + len;
711
712        if end > plaintext.len() {
713            return Err(EncryptionError::Decryption(format!(
714                "Decrypted data too short: need {} bytes, got {}",
715                end,
716                plaintext.len()
717            )));
718        }
719
720        Ok(plaintext[offset_in_first_chunk..end].to_vec())
721    }
722
723    /// Derive a scoped encryption service.
724    ///
725    /// Uses HKDF: master_key + "coven-scope-v1:{scope_id}" -> 32-byte key.
726    /// Deterministic: same master + scope_id always gives the same key.
727    pub fn derive_scoped(&self, scope_id: &str) -> EncryptionService {
728        let derived = self.derive_key(&format!("coven-scope-v1:{scope_id}"));
729        EncryptionService::from_key_at_generation(self.current_generation(), derived)
730    }
731
732    pub fn derive_scoped_for_fingerprint(
733        &self,
734        fingerprint: &[u8; 8],
735        scope_id: &str,
736    ) -> Result<EncryptionService, EncryptionError> {
737        let key = self.key_for_fingerprint(fingerprint)?;
738        let generation = self
739            .keys
740            .get(fingerprint)
741            .expect("just resolved")
742            .generation;
743        let derived = derive_key_from(&key, &format!("coven-scope-v1:{scope_id}"));
744        Ok(EncryptionService::from_key_at_generation(
745            generation, derived,
746        ))
747    }
748
749    /// Derive a 32-byte key using HKDF-SHA256 with the given info label.
750    ///
751    /// The derivation is deterministic: same master key + same info string always
752    /// produces the same derived key.
753    ///
754    /// - Salt: the constant `"coven-hkdf-salt-v1"` (RFC 5869 permits a fixed,
755    ///   non-secret salt)
756    /// - IKM: master key
757    /// - Info: caller-provided label
758    pub fn derive_key(&self, info: &str) -> [u8; 32] {
759        derive_key_from(&self.key_bytes(), info)
760    }
761
762    /// Seal `plaintext` for storage in a host's own rows, under this keyring's
763    /// seal key:
764    ///
765    /// ```text
766    /// [0]     version = APP_DATA_SEAL_VERSION
767    /// [1..9]  the seal key's 8-byte fingerprint
768    /// [9..]   the chunked ciphertext `encrypt` produces under that key
769    /// ```
770    ///
771    /// Naming the key by fingerprint is what keeps the payload openable across
772    /// any number of later rotations and forks — [`Self::open_app_data`] resolves
773    /// whichever key the payload names, not the current one, and a key once held
774    /// is never dropped. `aad` binds the ciphertext to its context (the owning
775    /// row's primary key, say) and must be presented unchanged to open it.
776    ///
777    /// The body is the existing chunked format, so a large payload streams the
778    /// same way a blob does; there is no size cliff and no second cipher.
779    pub fn seal_app_data(&self, plaintext: &[u8], aad: &[u8]) -> Vec<u8> {
780        let fingerprint = self.seal_fingerprint();
781        let mut sealed = Vec::with_capacity(
782            APP_DATA_HEADER_SIZE + chunked_encrypted_len(plaintext.len() as u64) as usize,
783        );
784        sealed.push(APP_DATA_SEAL_VERSION);
785        sealed.extend_from_slice(&fingerprint);
786        sealed.extend(self.encrypt(plaintext, aad));
787        sealed
788    }
789
790    /// Open a payload [`Self::seal_app_data`] produced, under whichever key it
791    /// names — so a keyring that has rotated or merged a fork since still opens
792    /// everything it sealed before. A version this build does not read, or a key
793    /// this keyring does not hold, is a typed error; a wrong `aad` or a tampered
794    /// payload surfaces the AEAD failure through [`SealError::Crypto`].
795    pub fn open_app_data(&self, sealed: &[u8], aad: &[u8]) -> Result<Vec<u8>, SealError> {
796        let (fingerprint, ciphertext) = split_sealed_app_data(sealed)?;
797        self.service_for_fingerprint(&fingerprint)
798            // `service_for_fingerprint` fails only when the keyring holds no key
799            // with that fingerprint, so this names the cause exactly.
800            .map_err(|_| SealError::UnknownKey(hex::encode(fingerprint)))?
801            .decrypt(ciphertext, aad)
802            .map_err(SealError::Crypto)
803    }
804}
805
806/// Split a sealed app-data payload into the key fingerprint it names and its
807/// ciphertext body, refusing a version this build does not read.
808///
809/// A payload too short to hold the fixed header cannot name a version or a
810/// fingerprint, so it is a corrupt envelope — reported as a decryption failure
811/// rather than guessed at or padded.
812fn split_sealed_app_data(sealed: &[u8]) -> Result<([u8; 8], &[u8]), SealError> {
813    let (&version, rest) = sealed.split_first().ok_or_else(|| {
814        SealError::Crypto(EncryptionError::Decryption(
815            "sealed app-data payload is empty".to_string(),
816        ))
817    })?;
818    if version != APP_DATA_SEAL_VERSION {
819        return Err(SealError::UnknownVersion(version));
820    }
821    if rest.len() < APP_DATA_FINGERPRINT_SIZE {
822        return Err(SealError::Crypto(EncryptionError::Decryption(
823            "sealed app-data payload is truncated before its key fingerprint".to_string(),
824        )));
825    }
826    let (fingerprint, ciphertext) = rest.split_at(APP_DATA_FINGERPRINT_SIZE);
827    let fingerprint: [u8; 8] = fingerprint
828        .try_into()
829        .expect("split_at yields exactly APP_DATA_FINGERPRINT_SIZE bytes");
830    Ok((fingerprint, ciphertext))
831}
832
833fn derive_key_from(key: &[u8; 32], info: &str) -> [u8; 32] {
834    let hk = Hkdf::<Sha256>::new(Some(b"coven-hkdf-salt-v1"), key);
835    let mut okm = [0u8; 32];
836    hk.expand(info.as_bytes(), &mut okm)
837        .expect("32 bytes is a valid HKDF output length");
838    okm
839}
840
841#[derive(Clone, Copy)]
842struct EncryptedChunkLayout {
843    data_len: usize,
844    total_chunks: usize,
845    has_partial: bool,
846}
847
848impl EncryptedChunkLayout {
849    fn chunk_bounds(
850        self,
851        ciphertext_len: usize,
852        chunk_index: usize,
853    ) -> Result<(usize, usize), EncryptionError> {
854        if chunk_index >= self.total_chunks {
855            return Err(EncryptionError::Decryption(format!(
856                "Chunk index {} out of range (total chunks: {})",
857                chunk_index, self.total_chunks
858            )));
859        }
860
861        let chunk_start = NONCE_SIZE + chunk_index * ENCRYPTED_CHUNK_SIZE;
862        let chunk_end = if chunk_index == self.total_chunks - 1 && self.has_partial {
863            ciphertext_len
864        } else {
865            chunk_start + ENCRYPTED_CHUNK_SIZE
866        };
867        Ok((chunk_start, chunk_end))
868    }
869}
870
871fn read_base_nonce(ciphertext: &[u8]) -> Result<[u8; NONCE_SIZE], EncryptionError> {
872    if ciphertext.len() < NONCE_SIZE {
873        return Err(EncryptionError::Decryption(
874            "Ciphertext too short for nonce".to_string(),
875        ));
876    }
877
878    let mut base_nonce = [0u8; NONCE_SIZE];
879    base_nonce.copy_from_slice(&ciphertext[..NONCE_SIZE]);
880    Ok(base_nonce)
881}
882
883fn encrypted_chunk_layout(ciphertext_len: usize) -> Result<EncryptedChunkLayout, EncryptionError> {
884    if ciphertext_len < NONCE_SIZE {
885        return Err(EncryptionError::Decryption(
886            "Ciphertext too short for nonce".to_string(),
887        ));
888    }
889
890    let data_len = ciphertext_len - NONCE_SIZE;
891    let num_full_chunks = data_len / ENCRYPTED_CHUNK_SIZE;
892    let has_partial = !data_len.is_multiple_of(ENCRYPTED_CHUNK_SIZE);
893    let total_chunks = num_full_chunks + usize::from(has_partial);
894    Ok(EncryptedChunkLayout {
895        data_len,
896        total_chunks,
897        has_partial,
898    })
899}
900
901fn decrypted_len_upper_bound(encrypted_data_len: usize) -> usize {
902    let chunk_count = encrypted_data_len.div_ceil(ENCRYPTED_CHUNK_SIZE);
903    encrypted_data_len.saturating_sub(chunk_count * TAG_SIZE)
904}
905
906fn chunk_count_for_plaintext_len(plaintext_len: u64) -> u64 {
907    plaintext_len.div_ceil(CHUNK_SIZE as u64).max(1)
908}
909
910fn chunk_aad(aad_context: &[u8], chunk_index: u64, total_chunks: u64) -> Vec<u8> {
911    let mut aad = Vec::with_capacity(AEAD_V2_LABEL.len() + 8 + aad_context.len() + 16);
912    aad.extend_from_slice(AEAD_V2_LABEL);
913    aad.extend_from_slice(&(aad_context.len() as u64).to_le_bytes());
914    aad.extend_from_slice(aad_context);
915    aad.extend_from_slice(&chunk_index.to_le_bytes());
916    aad.extend_from_slice(&total_chunks.to_le_bytes());
917    aad
918}
919
920fn decrypt_chunk_with_cipher(
921    cipher: &XChaCha20Poly1305,
922    base_nonce: &[u8; NONCE_SIZE],
923    aad_context: &[u8],
924    chunk_index: u64,
925    total_chunks: u64,
926    chunk_data: &[u8],
927) -> Result<Vec<u8>, ()> {
928    let nonce = chunk_nonce(base_nonce, chunk_index);
929    let nonce_arr = GenericArray::from_slice(&nonce);
930    let aad = chunk_aad(aad_context, chunk_index, total_chunks);
931    cipher
932        .decrypt(
933            nonce_arr,
934            Payload {
935                msg: chunk_data,
936                aad: &aad,
937            },
938        )
939        .map_err(|_| ())
940}
941
942/// Derive nonce for chunk i: base_nonce XOR i (little-endian)
943fn chunk_nonce(base_nonce: &[u8; NONCE_SIZE], chunk_index: u64) -> [u8; NONCE_SIZE] {
944    let mut nonce = *base_nonce;
945    let index_bytes = chunk_index.to_le_bytes();
946    for i in 0..8 {
947        nonce[i] ^= index_bytes[i];
948    }
949    nonce
950}
951
952/// Calculate the encrypted byte range for a plaintext byte range.
953///
954/// Returns `(chunk_start, chunk_end)` - the byte positions in the encrypted file
955/// where the needed chunks are located. Does NOT include the nonce (first 24 bytes).
956///
957/// Use this for efficient range requests: fetch nonce separately (or from DB),
958/// then fetch just `chunk_start..chunk_end` from storage.
959pub fn encrypted_chunk_range(plaintext_start: u64, plaintext_end: u64) -> (u64, u64) {
960    let start_chunk = plaintext_start / CHUNK_SIZE as u64;
961    let end_chunk = (plaintext_end.saturating_sub(1)) / CHUNK_SIZE as u64;
962
963    let chunk_start = NONCE_SIZE as u64 + start_chunk * ENCRYPTED_CHUNK_SIZE as u64;
964    let chunk_end = NONCE_SIZE as u64 + (end_chunk + 1) * ENCRYPTED_CHUNK_SIZE as u64;
965
966    (chunk_start, chunk_end)
967}
968
969#[cfg(test)]
970mod tests {
971    use super::*;
972
973    const TEST_AAD: &[u8] = b"encryption-test-context";
974
975    fn test_key() -> [u8; 32] {
976        // Fixed test key for reproducibility
977        [
978            0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
979            0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
980            0x1c, 0x1d, 0x1e, 0x1f,
981        ]
982    }
983
984    fn create_test_service() -> EncryptionService {
985        EncryptionService::from_key(test_key())
986    }
987
988    fn decrypt_plaintext_range(
989        service: &EncryptionService,
990        full_ciphertext: &[u8],
991        source_size: u64,
992        plaintext_start: u64,
993        plaintext_end: u64,
994    ) -> Vec<u8> {
995        let nonce = &full_ciphertext[..NONCE_SIZE];
996        let (chunk_start, chunk_end) = encrypted_chunk_range(plaintext_start, plaintext_end);
997        let chunks_only = &full_ciphertext[chunk_start as usize..chunk_end as usize];
998        let first_chunk_index = (chunk_start - NONCE_SIZE as u64) / ENCRYPTED_CHUNK_SIZE as u64;
999        service
1000            .decrypt_range_with_offset(
1001                nonce,
1002                chunks_only,
1003                first_chunk_index,
1004                plaintext_start,
1005                plaintext_end,
1006                source_size,
1007                TEST_AAD,
1008            )
1009            .unwrap()
1010    }
1011
1012    #[test]
1013    fn test_roundtrip_small() {
1014        let service = create_test_service();
1015        let plaintext = b"Hello, world!";
1016
1017        let ciphertext = service.encrypt(plaintext, TEST_AAD);
1018        let decrypted = service.decrypt(&ciphertext, TEST_AAD).unwrap();
1019
1020        assert_eq!(decrypted, plaintext);
1021    }
1022
1023    /// The streaming sealer (base nonce + per-chunk `seal_chunk`) produces a blob
1024    /// the existing whole-buffer decryptor reads back unchanged, across the
1025    /// boundaries that matter: empty, sub-chunk, exact chunk, and several
1026    /// non-aligned chunks. `encrypt` is built on the sealer, so this also
1027    /// guards the streaming form against drifting from the stored format.
1028    #[test]
1029    fn streaming_sealer_matches_whole_buffer_format() {
1030        let service = create_test_service();
1031        for len in [
1032            0usize,
1033            1,
1034            CHUNK_SIZE - 1,
1035            CHUNK_SIZE,
1036            CHUNK_SIZE + 1,
1037            200_000,
1038        ] {
1039            let plaintext: Vec<u8> = (0..len).map(|i| (i % 251) as u8).collect();
1040
1041            // Seal incrementally, exactly as a streaming upload would.
1042            let mut sealer = service.sealer(plaintext.len() as u64, TEST_AAD);
1043            let mut streamed = sealer.base_nonce().to_vec();
1044            if plaintext.is_empty() {
1045                streamed.extend(sealer.seal_chunk(&[]));
1046            } else {
1047                for chunk in plaintext.chunks(CHUNK_SIZE) {
1048                    streamed.extend(sealer.seal_chunk(chunk));
1049                }
1050            }
1051
1052            assert_eq!(
1053                streamed.len() as u64,
1054                chunked_encrypted_len(len as u64),
1055                "predicted length wrong for len={len}"
1056            );
1057            assert_eq!(
1058                service.decrypt(&streamed, TEST_AAD).unwrap(),
1059                plaintext,
1060                "streamed ciphertext failed to round-trip for len={len}"
1061            );
1062        }
1063    }
1064
1065    /// `chunked_encrypted_len` predicts the exact byte length `encrypt`
1066    /// produces, across the chunk boundaries that matter — so a streaming upload
1067    /// can announce the final object size before sealing a byte.
1068    #[test]
1069    fn chunked_encrypted_len_matches_encrypt() {
1070        let service = create_test_service();
1071        for n in [
1072            0usize,
1073            1,
1074            CHUNK_SIZE - 1,
1075            CHUNK_SIZE,
1076            CHUNK_SIZE + 1,
1077            200_000,
1078        ] {
1079            let produced = service.encrypt(&vec![0u8; n], TEST_AAD).len() as u64;
1080            assert_eq!(
1081                chunked_encrypted_len(n as u64),
1082                produced,
1083                "predicted length wrong for n={n}"
1084            );
1085        }
1086    }
1087
1088    #[test]
1089    fn test_roundtrip_exact_chunk() {
1090        let service = create_test_service();
1091        let plaintext = vec![0x42u8; CHUNK_SIZE];
1092
1093        let ciphertext = service.encrypt(&plaintext, TEST_AAD);
1094        let decrypted = service.decrypt(&ciphertext, TEST_AAD).unwrap();
1095
1096        assert_eq!(decrypted, plaintext);
1097    }
1098
1099    #[test]
1100    fn test_roundtrip_multiple_chunks() {
1101        let service = create_test_service();
1102        // 2.5 chunks worth of data
1103        let plaintext: Vec<u8> = (0..CHUNK_SIZE * 2 + CHUNK_SIZE / 2)
1104            .map(|i| (i % 256) as u8)
1105            .collect();
1106
1107        let ciphertext = service.encrypt(&plaintext, TEST_AAD);
1108        let decrypted = service.decrypt(&ciphertext, TEST_AAD).unwrap();
1109
1110        assert_eq!(decrypted, plaintext);
1111    }
1112
1113    #[test]
1114    fn test_random_access_chunk() {
1115        let service = create_test_service();
1116        // 3 chunks: chunk 0 = 0x00, chunk 1 = 0x11, chunk 2 = 0x22
1117        let mut plaintext = vec![0x00u8; CHUNK_SIZE];
1118        plaintext.extend(vec![0x11u8; CHUNK_SIZE]);
1119        plaintext.extend(vec![0x22u8; CHUNK_SIZE]);
1120
1121        let ciphertext = service.encrypt(&plaintext, TEST_AAD);
1122
1123        // Decrypt only chunk 1 (middle chunk)
1124        let chunk1 = service.decrypt_chunk(&ciphertext, 1, TEST_AAD).unwrap();
1125        assert_eq!(chunk1, vec![0x11u8; CHUNK_SIZE]);
1126
1127        // Decrypt chunk 0
1128        let chunk0 = service.decrypt_chunk(&ciphertext, 0, TEST_AAD).unwrap();
1129        assert_eq!(chunk0, vec![0x00u8; CHUNK_SIZE]);
1130
1131        // Decrypt chunk 2
1132        let chunk2 = service.decrypt_chunk(&ciphertext, 2, TEST_AAD).unwrap();
1133        assert_eq!(chunk2, vec![0x22u8; CHUNK_SIZE]);
1134    }
1135
1136    #[test]
1137    fn test_random_access_partial_last_chunk() {
1138        let service = create_test_service();
1139        // 1 full chunk + partial chunk
1140        let mut plaintext = vec![0xAAu8; CHUNK_SIZE];
1141        plaintext.extend(vec![0xBBu8; 100]);
1142
1143        let ciphertext = service.encrypt(&plaintext, TEST_AAD);
1144
1145        let chunk0 = service.decrypt_chunk(&ciphertext, 0, TEST_AAD).unwrap();
1146        assert_eq!(chunk0, vec![0xAAu8; CHUNK_SIZE]);
1147
1148        let chunk1 = service.decrypt_chunk(&ciphertext, 1, TEST_AAD).unwrap();
1149        assert_eq!(chunk1, vec![0xBBu8; 100]);
1150    }
1151
1152    #[test]
1153    fn test_tamper_detection() {
1154        let service = create_test_service();
1155        let plaintext = b"Secret data";
1156
1157        let mut ciphertext = service.encrypt(plaintext, TEST_AAD);
1158
1159        // Tamper with the ciphertext (after nonce)
1160        let tamper_pos = NONCE_SIZE + 5;
1161        ciphertext[tamper_pos] ^= 0xFF;
1162
1163        let result = service.decrypt(&ciphertext, TEST_AAD);
1164        assert!(result.is_err());
1165    }
1166
1167    #[test]
1168    fn truncating_trailing_chunks_fails_to_decrypt() {
1169        let service = create_test_service();
1170        let plaintext: Vec<u8> = (0..CHUNK_SIZE * 3).map(|i| (i % 251) as u8).collect();
1171        let ciphertext = service.encrypt(&plaintext, TEST_AAD);
1172        let truncated = &ciphertext[..ciphertext.len() - ENCRYPTED_CHUNK_SIZE];
1173
1174        assert!(
1175            service.decrypt(truncated, TEST_AAD).is_err(),
1176            "a truncated multi-chunk object must fail, not return a short plaintext",
1177        );
1178    }
1179
1180    #[test]
1181    fn test_empty_plaintext() {
1182        let service = create_test_service();
1183        let plaintext = b"";
1184
1185        let ciphertext = service.encrypt(plaintext, TEST_AAD);
1186
1187        // Should just be nonce + auth tag
1188        assert_eq!(ciphertext.len(), NONCE_SIZE + TAG_SIZE);
1189
1190        let decrypted = service.decrypt(&ciphertext, TEST_AAD).unwrap();
1191        assert_eq!(decrypted, plaintext);
1192    }
1193
1194    #[test]
1195    fn test_single_byte() {
1196        let service = create_test_service();
1197        let plaintext = b"x";
1198
1199        let ciphertext = service.encrypt(plaintext, TEST_AAD);
1200        let decrypted = service.decrypt(&ciphertext, TEST_AAD).unwrap();
1201
1202        assert_eq!(decrypted, plaintext);
1203    }
1204
1205    #[test]
1206    fn test_encrypted_range_single_chunk() {
1207        // Plaintext bytes 0-100 are in chunk 0
1208        let (start, end) = encrypted_chunk_range(0, 100);
1209
1210        assert_eq!(start, NONCE_SIZE as u64);
1211        assert_eq!(end, NONCE_SIZE as u64 + ENCRYPTED_CHUNK_SIZE as u64);
1212    }
1213
1214    #[test]
1215    fn test_encrypted_range_spans_chunks() {
1216        // Plaintext bytes spanning chunk 0 and chunk 1
1217        let (start, end) = encrypted_chunk_range(CHUNK_SIZE as u64 - 10, CHUNK_SIZE as u64 + 10);
1218
1219        assert_eq!(start, NONCE_SIZE as u64);
1220        assert_eq!(end, NONCE_SIZE as u64 + 2 * ENCRYPTED_CHUNK_SIZE as u64);
1221    }
1222
1223    #[test]
1224    fn test_encrypted_range_middle_chunk() {
1225        // Plaintext bytes entirely within chunk 2
1226        let chunk2_start = CHUNK_SIZE as u64 * 2;
1227        let (start, end) = encrypted_chunk_range(chunk2_start + 10, chunk2_start + 100);
1228
1229        assert_eq!(start, NONCE_SIZE as u64 + 2 * ENCRYPTED_CHUNK_SIZE as u64);
1230        assert_eq!(end, NONCE_SIZE as u64 + 3 * ENCRYPTED_CHUNK_SIZE as u64);
1231    }
1232
1233    #[test]
1234    fn test_different_encryptions_different_ciphertext() {
1235        let service = create_test_service();
1236        let plaintext = b"Same message";
1237
1238        let ciphertext1 = service.encrypt(plaintext, TEST_AAD);
1239        let ciphertext2 = service.encrypt(plaintext, TEST_AAD);
1240
1241        // Different nonces = different ciphertext
1242        assert_ne!(ciphertext1, ciphertext2);
1243
1244        // Both decrypt to same plaintext
1245        assert_eq!(service.decrypt(&ciphertext1, TEST_AAD).unwrap(), plaintext);
1246        assert_eq!(service.decrypt(&ciphertext2, TEST_AAD).unwrap(), plaintext);
1247    }
1248
1249    #[test]
1250    fn test_chunk_index_out_of_range() {
1251        let service = create_test_service();
1252        let plaintext = vec![0u8; CHUNK_SIZE]; // Exactly 1 chunk
1253
1254        let ciphertext = service.encrypt(&plaintext, TEST_AAD);
1255
1256        // Chunk 0 should work
1257        assert!(service.decrypt_chunk(&ciphertext, 0, TEST_AAD).is_ok());
1258
1259        // Chunk 1 should fail
1260        assert!(service.decrypt_chunk(&ciphertext, 1, TEST_AAD).is_err());
1261    }
1262
1263    #[test]
1264    fn test_decrypt_range_within_single_chunk() {
1265        let service = create_test_service();
1266        // Create plaintext with recognizable pattern
1267        let plaintext: Vec<u8> = (0..CHUNK_SIZE).map(|i| (i % 256) as u8).collect();
1268
1269        let ciphertext = service.encrypt(&plaintext, TEST_AAD);
1270
1271        let decrypted =
1272            decrypt_plaintext_range(&service, &ciphertext, plaintext.len() as u64, 100, 200);
1273
1274        assert_eq!(decrypted.len(), 100);
1275        assert_eq!(decrypted, plaintext[100..200]);
1276    }
1277
1278    #[test]
1279    fn test_decrypt_range_spanning_chunks() {
1280        let service = create_test_service();
1281        // 3 chunks of data
1282        let plaintext: Vec<u8> = (0..CHUNK_SIZE * 3).map(|i| (i % 256) as u8).collect();
1283
1284        let ciphertext = service.encrypt(&plaintext, TEST_AAD);
1285
1286        // Range spanning from end of chunk 0 into chunk 1
1287        let start = CHUNK_SIZE as u64 - 100;
1288        let end = CHUNK_SIZE as u64 + 100;
1289        let decrypted =
1290            decrypt_plaintext_range(&service, &ciphertext, plaintext.len() as u64, start, end);
1291
1292        assert_eq!(decrypted.len(), 200);
1293        assert_eq!(decrypted, &plaintext[start as usize..end as usize]);
1294    }
1295
1296    #[test]
1297    fn test_decrypt_range_entire_middle_chunk() {
1298        let service = create_test_service();
1299        // 3 chunks, middle chunk filled with 0xBB
1300        let mut plaintext = vec![0xAAu8; CHUNK_SIZE];
1301        plaintext.extend(vec![0xBBu8; CHUNK_SIZE]);
1302        plaintext.extend(vec![0xCCu8; CHUNK_SIZE]);
1303
1304        let ciphertext = service.encrypt(&plaintext, TEST_AAD);
1305
1306        // Decrypt just the middle chunk
1307        let start = CHUNK_SIZE as u64;
1308        let end = (CHUNK_SIZE * 2) as u64;
1309        let decrypted =
1310            decrypt_plaintext_range(&service, &ciphertext, plaintext.len() as u64, start, end);
1311
1312        assert_eq!(decrypted, vec![0xBBu8; CHUNK_SIZE]);
1313    }
1314
1315    #[test]
1316    fn test_decrypt_range_with_partial_encrypted_data() {
1317        let service = create_test_service();
1318        // Create 3-chunk plaintext
1319        let plaintext: Vec<u8> = (0..CHUNK_SIZE * 3).map(|i| (i % 256) as u8).collect();
1320        let full_ciphertext = service.encrypt(&plaintext, TEST_AAD);
1321
1322        // Calculate encrypted range for plaintext bytes in chunk 1
1323        let plaintext_start = CHUNK_SIZE as u64 + 100;
1324        let plaintext_end = CHUNK_SIZE as u64 + 200;
1325        let nonce = &full_ciphertext[..NONCE_SIZE];
1326        let (chunk_start, chunk_end) = encrypted_chunk_range(plaintext_start, plaintext_end);
1327        let chunks_only = &full_ciphertext[chunk_start as usize..chunk_end as usize];
1328        let first_chunk_index = (chunk_start - NONCE_SIZE as u64) / ENCRYPTED_CHUNK_SIZE as u64;
1329        let decrypted = service
1330            .decrypt_range_with_offset(
1331                nonce,
1332                chunks_only,
1333                first_chunk_index,
1334                plaintext_start,
1335                plaintext_end,
1336                plaintext.len() as u64,
1337                TEST_AAD,
1338            )
1339            .unwrap();
1340
1341        assert_eq!(decrypted.len(), 100);
1342        assert_eq!(
1343            decrypted,
1344            &plaintext[plaintext_start as usize..plaintext_end as usize]
1345        );
1346    }
1347
1348    #[test]
1349    fn test_encrypted_chunk_range_returns_actual_bounds() {
1350        // For plaintext in chunk 5, should return just chunk 5's encrypted bytes
1351        // NOT starting from 0
1352        let chunk5_start = CHUNK_SIZE as u64 * 5;
1353        let chunk5_end = chunk5_start + 1000;
1354
1355        let (enc_start, enc_end) = encrypted_chunk_range(chunk5_start, chunk5_end);
1356
1357        // Should start at chunk 5's position, not 0
1358        let expected_start = NONCE_SIZE as u64 + 5 * ENCRYPTED_CHUNK_SIZE as u64;
1359        let expected_end = NONCE_SIZE as u64 + 6 * ENCRYPTED_CHUNK_SIZE as u64;
1360
1361        assert_eq!(
1362            enc_start, expected_start,
1363            "encrypted_chunk_range should return actual chunk start, not 0"
1364        );
1365        assert_eq!(enc_end, expected_end);
1366    }
1367
1368    #[test]
1369    fn test_encrypted_chunk_range_spanning_multiple_chunks() {
1370        // Range spanning chunks 3-5
1371        let start = CHUNK_SIZE as u64 * 3 + 100;
1372        let end = CHUNK_SIZE as u64 * 5 + 500;
1373
1374        let (enc_start, enc_end) = encrypted_chunk_range(start, end);
1375
1376        let expected_start = NONCE_SIZE as u64 + 3 * ENCRYPTED_CHUNK_SIZE as u64;
1377        let expected_end = NONCE_SIZE as u64 + 6 * ENCRYPTED_CHUNK_SIZE as u64;
1378
1379        assert_eq!(enc_start, expected_start);
1380        assert_eq!(enc_end, expected_end);
1381    }
1382
1383    #[test]
1384    fn test_decrypt_range_with_separate_nonce() {
1385        // This simulates production flow: nonce from DB + chunks from range request
1386        let service = create_test_service();
1387
1388        // Create 10-chunk plaintext with recognizable pattern
1389        let plaintext: Vec<u8> = (0..CHUNK_SIZE * 10).map(|i| (i % 256) as u8).collect();
1390        let full_ciphertext = service.encrypt(&plaintext, TEST_AAD);
1391
1392        // Extract nonce (this would come from DB in production)
1393        let nonce = &full_ciphertext[..NONCE_SIZE];
1394
1395        // We want plaintext bytes in chunk 7
1396        let plaintext_start = CHUNK_SIZE as u64 * 7 + 100;
1397        let plaintext_end = CHUNK_SIZE as u64 * 7 + 500;
1398
1399        // Get the encrypted chunk range (NOT starting from 0)
1400        let (chunk_start, chunk_end) = encrypted_chunk_range(plaintext_start, plaintext_end);
1401
1402        // Fetch just the needed chunks (simulating range request)
1403        let chunks_only = &full_ciphertext[chunk_start as usize..chunk_end as usize];
1404
1405        // First chunk index is 7 (the chunk our range starts in)
1406        let first_chunk_index = plaintext_start / CHUNK_SIZE as u64;
1407
1408        // Use the new method that handles offset chunks
1409        let decrypted = service
1410            .decrypt_range_with_offset(
1411                nonce,
1412                chunks_only,
1413                first_chunk_index,
1414                plaintext_start,
1415                plaintext_end,
1416                plaintext.len() as u64,
1417                TEST_AAD,
1418            )
1419            .unwrap();
1420
1421        assert_eq!(decrypted.len(), 400);
1422        assert_eq!(
1423            decrypted,
1424            &plaintext[plaintext_start as usize..plaintext_end as usize]
1425        );
1426    }
1427
1428    #[test]
1429    fn test_decrypt_range_with_offset_spanning_chunks() {
1430        // Test decrypting a range that spans multiple chunks
1431        let service = create_test_service();
1432
1433        let plaintext: Vec<u8> = (0..CHUNK_SIZE * 10).map(|i| (i % 256) as u8).collect();
1434        let full_ciphertext = service.encrypt(&plaintext, TEST_AAD);
1435        let nonce = &full_ciphertext[..NONCE_SIZE];
1436
1437        // Range spanning chunks 3, 4, 5
1438        let plaintext_start = CHUNK_SIZE as u64 * 3 + 1000;
1439        let plaintext_end = CHUNK_SIZE as u64 * 5 + 2000;
1440
1441        let (chunk_start, chunk_end) = encrypted_chunk_range(plaintext_start, plaintext_end);
1442        let chunks_only = &full_ciphertext[chunk_start as usize..chunk_end as usize];
1443        let first_chunk_index = plaintext_start / CHUNK_SIZE as u64;
1444
1445        let decrypted = service
1446            .decrypt_range_with_offset(
1447                nonce,
1448                chunks_only,
1449                first_chunk_index,
1450                plaintext_start,
1451                plaintext_end,
1452                plaintext.len() as u64,
1453                TEST_AAD,
1454            )
1455            .unwrap();
1456
1457        let expected_len = (plaintext_end - plaintext_start) as usize;
1458        assert_eq!(decrypted.len(), expected_len);
1459        assert_eq!(
1460            decrypted,
1461            &plaintext[plaintext_start as usize..plaintext_end as usize]
1462        );
1463    }
1464
1465    #[test]
1466    fn test_fingerprint_deterministic() {
1467        let service = create_test_service();
1468        assert_eq!(service.fingerprint(), service.fingerprint());
1469    }
1470
1471    #[test]
1472    fn test_fingerprint_different_keys() {
1473        let service1 = EncryptionService::from_key([0u8; 32]);
1474        let service2 = EncryptionService::from_key([1u8; 32]);
1475        assert_ne!(service1.fingerprint(), service2.fingerprint());
1476    }
1477
1478    #[test]
1479    fn key_fingerprint_wire_form_is_strict_lowercase_hex() {
1480        let fingerprint = create_test_service().seal_key_fingerprint();
1481        let serialized = serde_json::to_string(&fingerprint).expect("serialize fingerprint");
1482        assert_eq!(
1483            serde_json::from_str::<KeyFingerprint>(&serialized).unwrap(),
1484            fingerprint
1485        );
1486        assert!(fingerprint
1487            .to_string()
1488            .to_uppercase()
1489            .parse::<KeyFingerprint>()
1490            .is_err());
1491    }
1492
1493    #[test]
1494    fn derive_scoped_deterministic() {
1495        let service = create_test_service();
1496        let derived1 = service.derive_scoped("rel-123");
1497        let derived2 = service.derive_scoped("rel-123");
1498        assert_eq!(derived1.key_bytes(), derived2.key_bytes());
1499    }
1500
1501    #[test]
1502    fn derive_scoped_different_releases() {
1503        let service = create_test_service();
1504        let key_a = service.derive_scoped("rel-aaa").key_bytes();
1505        let key_b = service.derive_scoped("rel-bbb").key_bytes();
1506        assert_ne!(key_a, key_b);
1507    }
1508
1509    #[test]
1510    fn derive_scoped_different_master_keys() {
1511        let svc1 = EncryptionService::from_key([0u8; 32]);
1512        let svc2 = EncryptionService::from_key([1u8; 32]);
1513        let key1 = svc1.derive_scoped("rel-123").key_bytes();
1514        let key2 = svc2.derive_scoped("rel-123").key_bytes();
1515        assert_ne!(key1, key2);
1516    }
1517
1518    #[test]
1519    fn derive_scoped_roundtrip() {
1520        let master = create_test_service();
1521        let release_enc = master.derive_scoped("rel-456");
1522        let plaintext = b"test audio data for this release";
1523
1524        let encrypted = release_enc.encrypt(plaintext, TEST_AAD);
1525        let decrypted = release_enc.decrypt(&encrypted, TEST_AAD).unwrap();
1526        assert_eq!(decrypted, plaintext);
1527
1528        // Cannot decrypt with master key
1529        assert!(master.decrypt(&encrypted, TEST_AAD).is_err());
1530
1531        // Cannot decrypt with wrong release key
1532        let wrong_enc = master.derive_scoped("rel-999");
1533        assert!(wrong_enc.decrypt(&encrypted, TEST_AAD).is_err());
1534    }
1535
1536    #[test]
1537    fn master_keyring_from_serialized_accepts_the_current_keyring_format() {
1538        let keyring = MasterKeyring::generate();
1539        let serialized = keyring.to_serialized();
1540        let parsed =
1541            MasterKeyring::from_serialized(&serialized).expect("parse a generated keyring");
1542        assert_eq!(parsed.to_serialized(), serialized);
1543        assert_eq!(parsed.fingerprint(), keyring.fingerprint());
1544    }
1545
1546    #[test]
1547    fn master_keyring_from_serialized_rejects_raw_hex() {
1548        let raw_hex = hex::encode(test_key());
1549        assert!(MasterKeyring::from_serialized(&raw_hex).is_err());
1550    }
1551
1552    #[test]
1553    fn keyring_payload_requires_the_current_json_format() {
1554        let service = create_test_service()
1555            .with_appended_generation(2, [9u8; 32])
1556            .expect("append a generation");
1557        let payload = service
1558            .to_keyring_payload()
1559            .expect("serialize the current keyring payload");
1560        let parsed = EncryptionService::from_keyring_payload(payload)
1561            .expect("parse the current keyring payload");
1562
1563        assert_eq!(parsed.keyring_entries(), service.keyring_entries());
1564        assert!(EncryptionService::from_keyring_payload(test_key().to_vec()).is_err());
1565    }
1566
1567    #[test]
1568    fn master_keyring_and_encryption_service_convert_without_losing_generations() {
1569        let service = EncryptionService::from_key(test_key())
1570            .with_appended_generation(2, [9u8; 32])
1571            .expect("append a generation");
1572        let keyring: MasterKeyring = service.clone().into();
1573        assert_eq!(keyring.fingerprint(), service.fingerprint());
1574        assert_eq!(
1575            keyring.to_serialized(),
1576            service.to_keyring_string().unwrap()
1577        );
1578
1579        let round_tripped: EncryptionService = keyring.into();
1580        assert_eq!(round_tripped.current_generation(), 2);
1581        assert_eq!(round_tripped.keyring_entries(), service.keyring_entries(),);
1582    }
1583
1584    /// Two owners rotating at once mint two distinct keys at the SAME generation
1585    /// number. A keyring keyed on the generation number would keep only one of
1586    /// them; keyed on fingerprint, both coexist. Every device that folds in the
1587    /// union then selects the same seal key (highest generation, then greatest
1588    /// fingerprint), so a fork converges instead of partitioning — and because
1589    /// merge keeps every key, each side still opens data sealed under the other's.
1590    #[test]
1591    fn same_generation_fork_converges_on_one_seal_key_and_keeps_both() {
1592        let base = EncryptionService::from_key([1u8; 32]);
1593        let fork_a = base.with_appended_generation(2, [0xA0u8; 32]).unwrap();
1594        let fork_b = base.with_appended_generation(2, [0xB0u8; 32]).unwrap();
1595
1596        let a_then_b = fork_a.merged_with(&fork_b);
1597        let b_then_a = fork_b.merged_with(&fork_a);
1598        assert_eq!(
1599            a_then_b.fingerprint(),
1600            b_then_a.fingerprint(),
1601            "seal selection is order-independent, so both sides converge on one key",
1602        );
1603        assert_eq!(
1604            a_then_b.key_count(),
1605            3,
1606            "the base key and both forks are held"
1607        );
1608        assert_eq!(a_then_b.current_generation(), 2);
1609
1610        let sealed_a = fork_a.seal_app_data(b"from owner A", b"ctx");
1611        let sealed_b = fork_b.seal_app_data(b"from owner B", b"ctx");
1612        assert_eq!(
1613            a_then_b.open_app_data(&sealed_a, b"ctx").unwrap(),
1614            b"from owner A",
1615        );
1616        assert_eq!(
1617            a_then_b.open_app_data(&sealed_b, b"ctx").unwrap(),
1618            b"from owner B",
1619        );
1620    }
1621
1622    #[test]
1623    fn master_keyring_debug_redacts_keys() {
1624        let keyring = MasterKeyring::generate();
1625        let debug = format!("{keyring:?}");
1626        assert!(debug.contains("<redacted>"), "{debug}");
1627    }
1628
1629    // =========================================================================
1630    // App-data sealing
1631    // =========================================================================
1632
1633    /// What the pinned v1 fixture wraps: this payload sealed under [`test_key`]
1634    /// with this `aad`. The bytes are
1635    /// `[01][63 0d cd 29 66 c4 33 66][24-byte nonce][ciphertext ++ tag]` — the
1636    /// version, [`test_key`]'s 8-byte fingerprint, then the chunked ciphertext.
1637    const APP_DATA_V1_FIXTURE_PLAINTEXT: &[u8] = b"pinned app-data payload";
1638    const APP_DATA_V1_FIXTURE_AAD: &[u8] = b"pinned-app-data-context";
1639    const APP_DATA_V1_FIXTURE_HEX: &str = concat!(
1640        "01",
1641        "630dcd2966c43366",
1642        "2bdfe10d13cb397b648c2eb352bbadd92a19eafd8499b5c5",
1643        "b0d1e8eb56f757621ec41a78488c937427aac5df38b5e8af",
1644        "2b2b8c9155ead15242e0c87b00bbe8",
1645    );
1646
1647    /// The key fingerprint a sealed payload names, read straight out of its
1648    /// header — so the tests below assert the recorded key rather than trusting
1649    /// `open_app_data` to have picked the right one silently.
1650    fn sealed_fingerprint(sealed: &[u8]) -> [u8; 8] {
1651        sealed[1..9].try_into().expect("a sealed header")
1652    }
1653
1654    #[test]
1655    fn seal_app_data_round_trips_and_records_its_version_and_key() {
1656        let service = create_test_service();
1657        for payload in [b"".as_slice(), b"x", b"a longer app-data secret value"] {
1658            let sealed = service.seal_app_data(payload, TEST_AAD);
1659
1660            assert_eq!(sealed[0], APP_DATA_SEAL_VERSION, "the version byte leads");
1661            assert_eq!(
1662                sealed_fingerprint(&sealed),
1663                service.seal_fingerprint(),
1664                "the header names the key it sealed under",
1665            );
1666            assert_eq!(
1667                sealed.len(),
1668                APP_DATA_HEADER_SIZE + chunked_encrypted_len(payload.len() as u64) as usize,
1669                "the body is exactly the chunked ciphertext, behind the fixed header",
1670            );
1671            assert_eq!(service.open_app_data(&sealed, TEST_AAD).unwrap(), payload);
1672        }
1673    }
1674
1675    /// `aad` binds a payload to its context. Opening with a different one must
1676    /// fail, so a payload lifted into another row does not silently open there.
1677    #[test]
1678    fn open_app_data_rejects_a_different_aad() {
1679        let service = create_test_service();
1680        let sealed = service.seal_app_data(b"bound to row 42", b"row-42");
1681
1682        let error = service
1683            .open_app_data(&sealed, b"row-99")
1684            .expect_err("a different aad must not open the payload");
1685
1686        assert!(matches!(error, SealError::Crypto(_)), "{error:?}");
1687    }
1688
1689    #[test]
1690    fn open_app_data_rejects_a_flipped_ciphertext_byte() {
1691        let service = create_test_service();
1692        let mut sealed = service.seal_app_data(b"tamper with me", TEST_AAD);
1693        let last = sealed.len() - 1;
1694        sealed[last] ^= 0xFF;
1695
1696        let error = service
1697            .open_app_data(&sealed, TEST_AAD)
1698            .expect_err("a tampered payload must fail authentication");
1699
1700        assert!(matches!(error, SealError::Crypto(_)), "{error:?}");
1701    }
1702
1703    /// A version this build does not read is refused by name, never guessed at
1704    /// — the payload was written by a format we have no decoder for.
1705    #[test]
1706    fn open_app_data_rejects_an_unknown_version() {
1707        let service = create_test_service();
1708        let mut sealed = service.seal_app_data(b"a version-1 payload", TEST_AAD);
1709        sealed[0] = 2;
1710
1711        let error = service
1712            .open_app_data(&sealed, TEST_AAD)
1713            .expect_err("version 2 must be refused");
1714
1715        assert!(matches!(error, SealError::UnknownVersion(2)), "{error:?}");
1716    }
1717
1718    /// Rotation does not orphan already-sealed payloads. Each records the key it
1719    /// was sealed under by fingerprint, and a rotated keyring retains every
1720    /// earlier key, so it opens what it sealed before and after.
1721    #[test]
1722    fn open_app_data_survives_rotation_and_each_payload_names_its_key() {
1723        let before_rotation = create_test_service();
1724        let sealed_under_1 = before_rotation.seal_app_data(b"sealed before rotating", TEST_AAD);
1725
1726        let after_rotation = before_rotation
1727            .with_appended_generation(2, [9u8; 32])
1728            .expect("rotate the keyring");
1729        let sealed_under_2 = after_rotation.seal_app_data(b"sealed after rotating", TEST_AAD);
1730
1731        assert_eq!(
1732            sealed_fingerprint(&sealed_under_1),
1733            before_rotation.seal_fingerprint(),
1734        );
1735        assert_eq!(
1736            sealed_fingerprint(&sealed_under_2),
1737            after_rotation.seal_fingerprint(),
1738            "sealing after a rotation records the new seal key",
1739        );
1740
1741        assert_eq!(
1742            after_rotation
1743                .open_app_data(&sealed_under_1, TEST_AAD)
1744                .unwrap(),
1745            b"sealed before rotating",
1746            "the rotated keyring still opens what the old generation sealed",
1747        );
1748        assert_eq!(
1749            after_rotation
1750                .open_app_data(&sealed_under_2, TEST_AAD)
1751                .unwrap(),
1752            b"sealed after rotating",
1753        );
1754    }
1755
1756    /// A keyring that does not hold the key a payload names — it predates the
1757    /// payload, or the payload is foreign — is a typed error, not a panic and
1758    /// not a decrypt attempt under the wrong key.
1759    #[test]
1760    fn open_app_data_rejects_a_key_the_keyring_lacks() {
1761        let rotated = create_test_service()
1762            .with_appended_generation(2, [9u8; 32])
1763            .expect("rotate the keyring");
1764        let sealed_under_2 = rotated.seal_app_data(b"sealed under the rotated key", TEST_AAD);
1765
1766        let fresh_single_key = EncryptionService::from_key([7u8; 32]);
1767        let error = fresh_single_key
1768            .open_app_data(&sealed_under_2, TEST_AAD)
1769            .expect_err("a keyring without the sealing key must not open it");
1770
1771        assert!(matches!(error, SealError::UnknownKey(_)), "{error:?}");
1772    }
1773
1774    /// The sealed app-data format is a durable storage contract: a host's rows
1775    /// hold these bytes, so a build that stopped opening them would strand the
1776    /// data. This pins one payload sealed under [`test_key`] — if the version
1777    /// byte, the generation encoding, the chunk framing, or the AAD derivation
1778    /// ever changes, this stops opening and says so.
1779    ///
1780    /// Generated once from `seal_app_data` itself, then frozen. It is not
1781    /// re-derived at test time on purpose: a fixture that regenerates would
1782    /// still pass against a changed format and pin nothing.
1783    #[test]
1784    fn sealed_app_data_v1_fixture_still_opens() {
1785        let sealed = hex::decode(APP_DATA_V1_FIXTURE_HEX).expect("the fixture is valid hex");
1786
1787        assert_eq!(sealed[0], APP_DATA_SEAL_VERSION, "a version-1 payload");
1788        assert_eq!(
1789            sealed_fingerprint(&sealed),
1790            EncryptionService::from_key(test_key()).seal_fingerprint(),
1791        );
1792
1793        let opened = EncryptionService::from_key(test_key())
1794            .open_app_data(&sealed, APP_DATA_V1_FIXTURE_AAD)
1795            .expect("the pinned v1 payload must keep opening");
1796
1797        assert_eq!(opened, APP_DATA_V1_FIXTURE_PLAINTEXT);
1798    }
1799}