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