Skip to main content

coven_keys/
encryption.rs

1use std::collections::BTreeMap;
2use std::fmt;
3use std::num::{NonZeroU32, NonZeroU64};
4use std::str::FromStr;
5
6use crate::keys::KeyError;
7use chacha20poly1305::aead::generic_array::GenericArray;
8use chacha20poly1305::aead::{Aead, Payload};
9use chacha20poly1305::{KeyInit, XChaCha20Poly1305};
10use hkdf::Hkdf;
11use rand::RngCore;
12use sha2::{Digest, Sha256};
13use thiserror::Error;
14use tracing::info;
15
16/// XChaCha20-Poly1305 nonce size (24 bytes).
17pub(crate) const NONCE_SIZE: usize = 24;
18
19/// Poly1305 auth tag size (16 bytes).
20pub const TAG_SIZE: usize = 16;
21
22/// 64KB plaintext chunks
23pub const CHUNK_SIZE: usize = 65536;
24pub const INITIAL_KEY_GENERATION: u64 = 1;
25
26const KEY_TAG_MARKER: &[u8; 3] = b"CKF";
27
28/// The key-tag format this build writes, and the only one it reads. A tag
29/// naming any other version is refused rather than guessed at.
30const KEY_TAG_VERSION: u8 = 1;
31
32/// The cleartext prefix every sealed payload carries ahead of its ciphertext,
33/// naming the key it is under:
34///
35/// ```text
36/// [0..3]  marker `CKF`
37/// [3]     format version
38/// [4..36] the key's full SHA-256 fingerprint
39/// ```
40///
41/// Naming the key rather than assuming the current one is what keeps a payload
42/// openable across any number of later rotations and forks: a reader resolves
43/// whichever key the payload names, and a key once held is never dropped.
44/// Every sealed form in the system — a host's app data, a stored blob, an
45/// encrypted protocol object — carries this one tag, so which key a stored byte
46/// string wants is one question with one answer, not a per-producer convention.
47///
48/// The tag says only *which* key; how to reach that key stays with the caller,
49/// because it differs by kind — app data resolves the fingerprint against the
50/// keyring directly, a scoped blob re-derives its scope key from the master key
51/// the fingerprint names.
52pub struct KeyTag;
53
54impl KeyTag {
55    pub const LEN: usize = KEY_TAG_MARKER.len() + 1 + 32;
56
57    pub fn write(fingerprint: &[u8; 32]) -> Vec<u8> {
58        Self::tagged(fingerprint, KEY_TAG_VERSION)
59    }
60
61    /// A tag claiming a version this build does not write, for tests that
62    /// assert a reader refuses one instead of guessing at its layout.
63    #[cfg(test)]
64    pub(crate) fn write_version_for_test(fingerprint: &[u8; 32], version: u8) -> Vec<u8> {
65        Self::tagged(fingerprint, version)
66    }
67
68    fn tagged(fingerprint: &[u8; 32], version: u8) -> Vec<u8> {
69        let mut tag = Vec::with_capacity(Self::LEN);
70        tag.extend_from_slice(KEY_TAG_MARKER);
71        tag.push(version);
72        tag.extend_from_slice(fingerprint);
73        tag
74    }
75
76    /// Split `stored` into the key fingerprint its tag names and the body that
77    /// follows it.
78    pub fn read(stored: &[u8]) -> Result<([u8; 32], &[u8]), KeyTagError> {
79        if stored.len() < Self::LEN {
80            return Err(KeyTagError::Truncated);
81        }
82        let (tag, body) = stored.split_at(Self::LEN);
83        let (marker, versioned) = tag.split_at(KEY_TAG_MARKER.len());
84        if marker != KEY_TAG_MARKER {
85            return Err(KeyTagError::Unmarked);
86        }
87        let (&version, fingerprint) = versioned
88            .split_first()
89            .expect("a key tag holds its version byte");
90        if version != KEY_TAG_VERSION {
91            return Err(KeyTagError::UnknownVersion(version));
92        }
93        Ok((
94            fingerprint
95                .try_into()
96                .expect("a key tag holds 32 fingerprint bytes"),
97            body,
98        ))
99    }
100}
101
102/// What a stored payload's leading key tag can fail to be.
103#[derive(Debug, Error)]
104pub enum KeyTagError {
105    #[error("sealed payload is too short to carry a key tag")]
106    Truncated,
107    #[error("sealed payload carries no key tag")]
108    Unmarked,
109    #[error("unsupported key tag version {0}")]
110    UnknownVersion(u8),
111}
112
113/// A tag naming a version this build does not read is its own answer to the
114/// host — "this payload is newer than me", not "decryption failed". Every other
115/// malformed tag is a corrupt envelope, which reads as a decryption failure.
116impl From<KeyTagError> for SealError {
117    fn from(error: KeyTagError) -> Self {
118        match error {
119            KeyTagError::UnknownVersion(version) => Self::UnknownVersion(version),
120            KeyTagError::Truncated | KeyTagError::Unmarked => Self::Crypto(error.into()),
121        }
122    }
123}
124
125/// Stable wire identity of one 32-byte encryption key: its full SHA-256 digest,
126/// serialized as exactly 64 lowercase hex digits.
127#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
128pub struct KeyFingerprint([u8; 32]);
129
130impl KeyFingerprint {
131    pub fn from_bytes(bytes: [u8; 32]) -> Self {
132        Self(bytes)
133    }
134
135    pub fn as_bytes(&self) -> &[u8; 32] {
136        &self.0
137    }
138}
139
140impl fmt::Debug for KeyFingerprint {
141    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
142        fmt::Display::fmt(self, formatter)
143    }
144}
145
146impl fmt::Display for KeyFingerprint {
147    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
148        formatter.write_str(&hex::encode(self.0))
149    }
150}
151
152impl FromStr for KeyFingerprint {
153    type Err = KeyFingerprintParseError;
154
155    fn from_str(value: &str) -> Result<Self, Self::Err> {
156        if value.len() != 64
157            || value
158                .bytes()
159                .any(|byte| !byte.is_ascii_digit() && !(b'a'..=b'f').contains(&byte))
160        {
161            return Err(KeyFingerprintParseError(value.to_string()));
162        }
163        let bytes: [u8; 32] = hex::decode(value)
164            .map_err(|_| KeyFingerprintParseError(value.to_string()))?
165            .try_into()
166            .map_err(|_| KeyFingerprintParseError(value.to_string()))?;
167        Ok(Self(bytes))
168    }
169}
170
171impl serde::Serialize for KeyFingerprint {
172    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
173    where
174        S: serde::Serializer,
175    {
176        serializer.serialize_str(&self.to_string())
177    }
178}
179
180impl<'de> serde::Deserialize<'de> for KeyFingerprint {
181    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
182    where
183        D: serde::Deserializer<'de>,
184    {
185        <String as serde::Deserialize>::deserialize(deserializer)?
186            .parse()
187            .map_err(serde::de::Error::custom)
188    }
189}
190
191#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
192#[error("key fingerprint must be exactly 64 lowercase hexadecimal characters: {0:?}")]
193pub struct KeyFingerprintParseError(String);
194
195/// Generate a random 32-byte key.
196pub fn generate_random_key() -> [u8; 32] {
197    let mut key = [0u8; 32];
198    rand::rng().fill_bytes(&mut key);
199    key
200}
201
202/// The sealed length of a whole-object payload of `plaintext_len` bytes — what
203/// a streaming upload declares before a byte is sealed.
204pub fn chunked_encrypted_len(plaintext_len: u64) -> u64 {
205    whole_object_header(plaintext_len).sealed_len()
206}
207
208/// The header a whole-object payload is sealed under: the build's chunk size,
209/// and a random base nonce it stores in the clear.
210///
211/// A whole object is addressed by nothing the cipher can see — a protocol
212/// object's slot, a host row's primary key — so it cannot derive a nonce base
213/// that is guaranteed unique per plaintext. It stores a random one instead.
214fn whole_object_header(plaintext_len: u64) -> SealedBlobHeader {
215    SealedBlobHeader::new(
216        DEFAULT_BLOB_CHUNK_SIZE,
217        plaintext_len,
218        &NoncePolicy::RandomStored,
219    )
220}
221
222/// One key's chunked AEAD: the cipher, and the base nonce that chunk `n`'s own
223/// nonce derives from. Each caller keeps its own additional data — this is the
224/// pair every one of them seals and opens with, so nothing repeats the nonce
225/// derivation or the AEAD call.
226struct ChunkCipher {
227    cipher: XChaCha20Poly1305,
228    base_nonce: [u8; NONCE_SIZE],
229}
230
231impl ChunkCipher {
232    fn new(key: &[u8; 32], base_nonce: [u8; NONCE_SIZE]) -> Self {
233        Self {
234            cipher: XChaCha20Poly1305::new(GenericArray::from_slice(key)),
235            base_nonce,
236        }
237    }
238
239    fn seal(&self, index: u64, aad: &[u8], plaintext: &[u8]) -> Vec<u8> {
240        self.cipher
241            .encrypt(
242                GenericArray::from_slice(&chunk_nonce(&self.base_nonce, index)),
243                Payload {
244                    msg: plaintext,
245                    aad,
246                },
247            )
248            .expect("encryption should not fail")
249    }
250
251    /// Open chunk `index`, or `None` when its bytes do not authenticate under
252    /// `aad`. The AEAD reports no more than that, so each caller names the
253    /// failure in its own terms.
254    fn open(&self, index: u64, aad: &[u8], sealed: &[u8]) -> Option<Vec<u8>> {
255        self.cipher
256            .decrypt(
257                GenericArray::from_slice(&chunk_nonce(&self.base_nonce, index)),
258                Payload { msg: sealed, aad },
259            )
260            .ok()
261    }
262}
263
264/// The sealed-blob format version this build writes, and the only one it reads.
265/// The leading byte of every blob header; a blob naming any other version is
266/// refused rather than guessed at.
267pub(crate) const SEALED_BLOB_VERSION: u8 = 1;
268
269/// The chunk size a blob is sealed at when the host configures none. A read
270/// honors whatever its own header records, so this is only ever the *writer's*
271/// choice and can change without touching a blob already stored.
272pub const DEFAULT_BLOB_CHUNK_SIZE: NonZeroU32 = NonZeroU32::new(64 * 1024).expect("64 KiB");
273
274/// `[version: 1][nonce policy: 1][chunk_size: 4 LE][plaintext_len: 8 LE]` — the
275/// fixed part of the header every sealed payload carries ahead of its first
276/// chunk. A payload under [`NoncePolicy::RandomStored`] follows it with the
277/// 24-byte base nonce; [`SealedBlobHeader::prefix_len`] is the whole
278/// of it either way.
279pub const SEALED_BLOB_HEADER_LEN: usize = 1 + 1 + 4 + 8;
280
281const BLOB_AEAD_LABEL: &[u8] = b"coven-blob-aead-v1";
282const BLOB_NONCE_INFO: &[u8] = b"coven-blob-nonce-v1";
283
284const DERIVED_NONCE_TAG: u8 = 0;
285const RANDOM_NONCE_TAG: u8 = 1;
286
287/// Where a sealed payload's base nonce comes from — the choice every caller
288/// that seals or opens one states outright.
289///
290/// # Invariant: a derived base must be unique per plaintext
291///
292/// XChaCha20-Poly1305 offers no margin for nonce reuse. Two different
293/// plaintexts sealed under one key and one nonce leak their XOR and forfeit
294/// authentication, and that failure is silent — everything still encrypts,
295/// decrypts, and round-trips. [`Self::DerivedFromContext`] is therefore only
296/// safe while its `context` differs whenever the plaintext does, which is why
297/// the context is part of the policy rather than something a caller can forget
298/// to pass, and why the choice is a named variant rather than a flag or a
299/// default.
300#[derive(Clone, Debug, PartialEq, Eq)]
301pub enum NoncePolicy {
302    /// A base nonce drawn at random and written into the header, ahead of the
303    /// chunks. Safe however the payload is addressed, at the cost of
304    /// 24 stored bytes and a base only the stored header carries.
305    RandomStored,
306    /// A base nonce derived by HKDF from the sealing key and `context`, stored
307    /// nowhere, so the same payload always seals to the same bytes and a reader
308    /// that knows the context can open any chunk without reading a base first.
309    ///
310    /// `context` must differ whenever the plaintext does. It holds for a blob
311    /// because the context is minted from the blob's semantic key, which for an
312    /// opaque blob is `{namespace}/opaque/{locator_hash}`, and the locator hash
313    /// covers the plaintext hash — so two different plaintexts cannot share a
314    /// context without a SHA-256 collision. Re-sealing identical bytes under an
315    /// identical context reproduces an identical base, which is fine: it
316    /// reproduces identical ciphertext, not a second message under one nonce.
317    ///
318    /// **Any change to how a payload is addressed must preserve that.** A
319    /// locator that stopped folding in the plaintext hash, or a context minted
320    /// from something that outlives the payload's content (a bare row id, a
321    /// stable path), would let one key seal two different plaintexts under one
322    /// nonce.
323    DerivedFromContext { context: Vec<u8> },
324}
325
326impl NoncePolicy {
327    fn tag(&self) -> u8 {
328        match self {
329            Self::RandomStored => RANDOM_NONCE_TAG,
330            Self::DerivedFromContext { .. } => DERIVED_NONCE_TAG,
331        }
332    }
333}
334
335/// The nonce base a sealed payload carries in its own header: the base itself
336/// when the writer drew one at random, nothing when the writer derived it and
337/// the reader has to derive the same one.
338#[derive(Clone, Copy, Debug, PartialEq, Eq)]
339enum StoredNonceBase {
340    Derived,
341    Random([u8; NONCE_SIZE]),
342}
343
344/// What a sealed payload's header says about its own layout: where its base
345/// nonce comes from, the chunk size it was sealed at, and the plaintext length
346/// it covers. Every other offset in the object is arithmetic over those, so a
347/// payload describes its own shape and nothing per-chunk is stored.
348///
349/// The header travels in the clear (a reader must know the chunk size before it
350/// can open anything) but is bound into every chunk's AAD, so altering it makes
351/// the first chunk fail to open rather than silently re-framing the object.
352#[derive(Clone, Copy, Debug, PartialEq, Eq)]
353pub struct SealedBlobHeader {
354    base: StoredNonceBase,
355    chunk_size: NonZeroU32,
356    plaintext_len: u64,
357}
358
359/// Why a sealed blob's header or one of its chunks could not be opened.
360#[derive(Clone, Debug, PartialEq, Eq, Error)]
361pub enum SealedBlobError {
362    #[error("sealed blob header names version {0}, which this build does not read")]
363    UnknownVersion(u8),
364    #[error("sealed blob header names nonce policy {0}, which this build does not read")]
365    UnknownNoncePolicy(u8),
366    #[error("sealed blob stores nonce policy {stored} but is being opened as {requested}")]
367    NoncePolicyMismatch { stored: u8, requested: u8 },
368    #[error("sealed blob header is {0} bytes, expected {SEALED_BLOB_HEADER_LEN}")]
369    ShortHeader(usize),
370    #[error("sealed blob header names chunk size 0")]
371    ZeroChunkSize,
372    #[error("sealed blob chunk {index} is {actual} bytes, expected {expected}")]
373    ChunkLength {
374        index: u64,
375        expected: usize,
376        actual: usize,
377    },
378    #[error("sealed blob chunk {index} failed authentication")]
379    ChunkAuthentication { index: u64 },
380    #[error("sealed blob range {start}..{end} lies outside its {plaintext_len}-byte plaintext")]
381    RangeOutOfBounds {
382        start: u64,
383        end: u64,
384        plaintext_len: u64,
385    },
386}
387
388impl SealedBlobHeader {
389    /// Describe a payload of `plaintext_len` bytes sealed at `chunk_size` under
390    /// `policy`. A random-stored policy draws its base nonce here, so the header
391    /// is complete — and its [`Self::sealed_len`] known — before any chunk is
392    /// sealed.
393    pub fn new(chunk_size: NonZeroU32, plaintext_len: u64, policy: &NoncePolicy) -> Self {
394        let base = match policy {
395            NoncePolicy::RandomStored => {
396                let mut nonce = [0u8; NONCE_SIZE];
397                rand::rng().fill_bytes(&mut nonce);
398                StoredNonceBase::Random(nonce)
399            }
400            NoncePolicy::DerivedFromContext { .. } => StoredNonceBase::Derived,
401        };
402        Self {
403            base,
404            chunk_size,
405            plaintext_len,
406        }
407    }
408
409    pub fn parse(bytes: &[u8]) -> Result<Self, SealedBlobError> {
410        if bytes.len() < SEALED_BLOB_HEADER_LEN {
411            return Err(SealedBlobError::ShortHeader(bytes.len()));
412        }
413        if bytes[0] != SEALED_BLOB_VERSION {
414            return Err(SealedBlobError::UnknownVersion(bytes[0]));
415        }
416        let base = match bytes[1] {
417            DERIVED_NONCE_TAG => StoredNonceBase::Derived,
418            RANDOM_NONCE_TAG => {
419                let end = SEALED_BLOB_HEADER_LEN + NONCE_SIZE;
420                if bytes.len() < end {
421                    return Err(SealedBlobError::ShortHeader(bytes.len()));
422                }
423                StoredNonceBase::Random(
424                    bytes[SEALED_BLOB_HEADER_LEN..end]
425                        .try_into()
426                        .expect("NONCE_SIZE base nonce bytes"),
427                )
428            }
429            other => return Err(SealedBlobError::UnknownNoncePolicy(other)),
430        };
431        let chunk_size = NonZeroU32::new(u32::from_le_bytes(
432            bytes[2..6].try_into().expect("four header bytes"),
433        ))
434        .ok_or(SealedBlobError::ZeroChunkSize)?;
435        let plaintext_len =
436            u64::from_le_bytes(bytes[6..14].try_into().expect("eight header bytes"));
437        Ok(Self {
438            base,
439            chunk_size,
440            plaintext_len,
441        })
442    }
443
444    pub fn to_bytes(self) -> Vec<u8> {
445        let mut bytes = Vec::with_capacity(self.prefix_len() as usize);
446        bytes.push(SEALED_BLOB_VERSION);
447        bytes.push(match self.base {
448            StoredNonceBase::Derived => DERIVED_NONCE_TAG,
449            StoredNonceBase::Random(_) => RANDOM_NONCE_TAG,
450        });
451        bytes.extend_from_slice(&self.chunk_size.get().to_le_bytes());
452        bytes.extend_from_slice(&self.plaintext_len.to_le_bytes());
453        if let StoredNonceBase::Random(nonce) = self.base {
454            bytes.extend_from_slice(&nonce);
455        }
456        bytes
457    }
458
459    /// How many bytes the header occupies ahead of the first chunk: the fixed
460    /// part, plus the base nonce when the payload stores one.
461    pub fn prefix_len(self) -> u64 {
462        SEALED_BLOB_HEADER_LEN as u64
463            + match self.base {
464                StoredNonceBase::Derived => 0,
465                StoredNonceBase::Random(_) => NONCE_SIZE as u64,
466            }
467    }
468
469    /// The base nonce chunk `n`'s own nonce derives from, under `policy` and
470    /// `key`. A policy that disagrees with what the header records is refused:
471    /// the bytes were sealed under the other one, and guessing which would mean
472    /// opening a payload the caller did not ask for.
473    fn base_nonce(
474        self,
475        key: &[u8; 32],
476        policy: &NoncePolicy,
477    ) -> Result<[u8; NONCE_SIZE], SealedBlobError> {
478        match (self.base, policy) {
479            (StoredNonceBase::Random(nonce), NoncePolicy::RandomStored) => Ok(nonce),
480            (StoredNonceBase::Derived, NoncePolicy::DerivedFromContext { context }) => {
481                Ok(derive_nonce_base(key, context))
482            }
483            (stored, policy) => Err(SealedBlobError::NoncePolicyMismatch {
484                stored: match stored {
485                    StoredNonceBase::Derived => DERIVED_NONCE_TAG,
486                    StoredNonceBase::Random(_) => RANDOM_NONCE_TAG,
487                },
488                requested: policy.tag(),
489            }),
490        }
491    }
492
493    pub fn chunk_size(self) -> NonZeroU32 {
494        self.chunk_size
495    }
496
497    pub fn plaintext_len(self) -> u64 {
498        self.plaintext_len
499    }
500
501    /// Chunk `index`'s AAD: the format label, this complete header, the blob's
502    /// context, and the index. Binding the header makes a rewritten chunk size
503    /// or plaintext length fail the first open; binding the context and index
504    /// makes a chunk refuse to open as a different blob's chunk, or as a
505    /// different position in its own.
506    fn chunk_aad(self, aad_context: &[u8], index: u64) -> Vec<u8> {
507        let header = self.to_bytes();
508        let mut aad =
509            Vec::with_capacity(BLOB_AEAD_LABEL.len() + header.len() + 16 + aad_context.len());
510        aad.extend_from_slice(BLOB_AEAD_LABEL);
511        aad.extend_from_slice(&header);
512        aad.extend_from_slice(&(aad_context.len() as u64).to_le_bytes());
513        aad.extend_from_slice(aad_context);
514        aad.extend_from_slice(&index.to_le_bytes());
515        aad
516    }
517
518    /// How many chunks the plaintext occupies. An empty blob still seals one
519    /// tag-only chunk, so opening it authenticates its emptiness rather than
520    /// trusting a zero-length object.
521    pub fn chunk_count(self) -> u64 {
522        self.plaintext_len
523            .div_ceil(u64::from(self.chunk_size.get()))
524            .max(1)
525    }
526
527    /// The plaintext length chunk `index` carries — the chunk size for every
528    /// chunk but the last, which holds the remainder.
529    pub(crate) fn chunk_plaintext_len(self, index: u64) -> u64 {
530        let start = index.saturating_mul(u64::from(self.chunk_size.get()));
531        self.plaintext_len
532            .saturating_sub(start)
533            .min(u64::from(self.chunk_size.get()))
534    }
535
536    /// The sealed length of chunk `index`: its plaintext plus one tag.
537    pub fn sealed_chunk_len(self, index: u64) -> u64 {
538        self.chunk_plaintext_len(index) + TAG_SIZE as u64
539    }
540
541    /// The whole sealed body: the header followed by every chunk. What a
542    /// streaming upload declares as its length before a byte is sealed.
543    pub fn sealed_len(self) -> u64 {
544        self.prefix_len() + self.plaintext_len + self.chunk_count() * TAG_SIZE as u64
545    }
546
547    /// The chunks covering plaintext `start..end`. A caller reads exactly these
548    /// and no others; the range must lie inside the plaintext.
549    pub fn covering_chunks(
550        self,
551        start: u64,
552        end: u64,
553    ) -> Result<std::ops::Range<u64>, SealedBlobError> {
554        if start > end || end > self.plaintext_len {
555            return Err(SealedBlobError::RangeOutOfBounds {
556                start,
557                end,
558                plaintext_len: self.plaintext_len,
559            });
560        }
561        if start == end {
562            return Ok(0..0);
563        }
564        let chunk_size = u64::from(self.chunk_size.get());
565        Ok((start / chunk_size)..((end - 1) / chunk_size + 1))
566    }
567
568    /// Where `chunks` sit in the sealed object, as an offset span measured from
569    /// the object's first byte. The header is included in the offset, so this is
570    /// what a ranged cloud read asks for verbatim.
571    pub fn sealed_span(self, chunks: std::ops::Range<u64>) -> std::ops::Range<u64> {
572        let full = u64::from(self.chunk_size.get()) + TAG_SIZE as u64;
573        let start = self.prefix_len() + chunks.start * full;
574        let mut end = start;
575        for index in chunks {
576            end += self.sealed_chunk_len(index);
577        }
578        start..end
579    }
580
581    /// Split `chunks` into the runs one ranged read each should fetch: as many
582    /// chunks as fit in `window` stored bytes, and never fewer than one (a chunk
583    /// wider than the window still takes exactly one request). The window is how
584    /// a reader trades round-trips against per-request size without changing what
585    /// bytes it asks for — the union of the runs is always exactly `chunks`.
586    pub fn request_runs(
587        self,
588        chunks: std::ops::Range<u64>,
589        window: NonZeroU64,
590    ) -> Vec<std::ops::Range<u64>> {
591        let mut runs = Vec::new();
592        let mut start = chunks.start;
593        while start < chunks.end {
594            let mut end = start;
595            let mut span = 0u64;
596            while end < chunks.end {
597                let next = span.saturating_add(self.sealed_chunk_len(end));
598                if end > start && next > window.get() {
599                    break;
600                }
601                span = next;
602                end += 1;
603            }
604            runs.push(start..end);
605            start = end;
606        }
607        runs
608    }
609
610    /// Where `chunks` sit in the plaintext.
611    pub fn plaintext_span(self, chunks: std::ops::Range<u64>) -> std::ops::Range<u64> {
612        let chunk_size = u64::from(self.chunk_size.get());
613        let start = (chunks.start * chunk_size).min(self.plaintext_len);
614        let end = (chunks.end * chunk_size).min(self.plaintext_len);
615        start..end
616    }
617}
618
619/// The base nonce [`NoncePolicy::DerivedFromContext`] produces: HKDF over the
620/// sealing key, bound to the payload's context. Chunk `n` uses this base XOR
621/// `n`, so no nonce is stored. The uniqueness the derivation rests on is the
622/// policy's invariant.
623fn derive_nonce_base(key: &[u8; 32], context: &[u8]) -> [u8; NONCE_SIZE] {
624    let mut info = Vec::with_capacity(BLOB_NONCE_INFO.len() + context.len());
625    info.extend_from_slice(BLOB_NONCE_INFO);
626    info.extend_from_slice(context);
627    let hk = Hkdf::<Sha256>::new(Some(b"coven-hkdf-salt-v1"), key);
628    let mut base = [0u8; NONCE_SIZE];
629    hk.expand(&info, &mut base)
630        .expect("24 bytes is a valid HKDF output length");
631    base
632}
633
634/// Seals one blob's chunks in order, so an upload streams without ever holding
635/// the whole plaintext or ciphertext. The header it emits first is what a later
636/// read needs to compute every chunk offset.
637pub struct SealedBlobSealer {
638    cipher: ChunkCipher,
639    header: SealedBlobHeader,
640    aad_context: Vec<u8>,
641    next_index: u64,
642}
643
644impl SealedBlobSealer {
645    fn new(
646        key: &[u8; 32],
647        header: SealedBlobHeader,
648        policy: &NoncePolicy,
649        aad_context: &[u8],
650    ) -> Result<Self, SealedBlobError> {
651        Ok(Self {
652            cipher: ChunkCipher::new(key, header.base_nonce(key, policy)?),
653            header,
654            aad_context: aad_context.to_vec(),
655            next_index: 0,
656        })
657    }
658
659    pub fn header(&self) -> SealedBlobHeader {
660        self.header
661    }
662
663    /// Seal the next chunk. The caller splits the plaintext on the header's
664    /// chunk boundaries; a chunk of any other length would desync the framing
665    /// every reader computes from the header.
666    pub fn seal_chunk(&mut self, plaintext: &[u8]) -> Vec<u8> {
667        let index = self.next_index;
668        debug_assert_eq!(
669            plaintext.len() as u64,
670            self.header.chunk_plaintext_len(index),
671            "a sealed chunk carries exactly the plaintext its header assigns it",
672        );
673        self.next_index += 1;
674        let aad = self.header.chunk_aad(&self.aad_context, index);
675        self.cipher.seal(index, &aad, plaintext)
676    }
677}
678
679/// Opens a sealed blob's chunks in any order. A chunk that opens is authentic —
680/// the tag covers its bytes, its position, and the header that framed it — so
681/// decryption is the whole verification and no separate hash is read.
682pub struct SealedBlobOpener {
683    cipher: ChunkCipher,
684    header: SealedBlobHeader,
685    aad_context: Vec<u8>,
686}
687
688impl SealedBlobOpener {
689    fn new(
690        key: &[u8; 32],
691        header: SealedBlobHeader,
692        policy: &NoncePolicy,
693        aad_context: &[u8],
694    ) -> Result<Self, SealedBlobError> {
695        Ok(Self {
696            cipher: ChunkCipher::new(key, header.base_nonce(key, policy)?),
697            header,
698            aad_context: aad_context.to_vec(),
699        })
700    }
701
702    pub fn header(&self) -> SealedBlobHeader {
703        self.header
704    }
705
706    /// Open chunk `index` from exactly its sealed bytes. A length the header
707    /// does not assign that index is refused before the cipher runs — the bytes
708    /// are not that chunk, whatever they authenticate as.
709    pub fn open_chunk(&self, index: u64, sealed: &[u8]) -> Result<Vec<u8>, SealedBlobError> {
710        let expected = self.header.sealed_chunk_len(index);
711        if sealed.len() as u64 != expected {
712            return Err(SealedBlobError::ChunkLength {
713                index,
714                expected: expected as usize,
715                actual: sealed.len(),
716            });
717        }
718        let aad = self.header.chunk_aad(&self.aad_context, index);
719        self.cipher
720            .open(index, &aad, sealed)
721            .ok_or(SealedBlobError::ChunkAuthentication { index })
722    }
723
724    /// Open every chunk in `chunks` from the contiguous sealed bytes covering
725    /// them — the span [`SealedBlobHeader::sealed_span`] names for the same
726    /// range — and return their whole plaintext. Chunks are opened one at a
727    /// time, so a tampered chunk fails only the reads that touch it.
728    pub fn open_chunks(
729        &self,
730        chunks: std::ops::Range<u64>,
731        sealed: &[u8],
732    ) -> Result<Vec<u8>, SealedBlobError> {
733        let window = self.header.plaintext_span(chunks.clone());
734        let mut plaintext = Vec::with_capacity((window.end - window.start) as usize);
735        let mut offset = 0usize;
736        for index in chunks {
737            let len = self.header.sealed_chunk_len(index) as usize;
738            let sealed_chunk = sealed.get(offset..offset + len).ok_or({
739                SealedBlobError::ChunkLength {
740                    index,
741                    expected: len,
742                    actual: sealed.len().saturating_sub(offset),
743                }
744            })?;
745            offset += len;
746            plaintext.extend(self.open_chunk(index, sealed_chunk)?);
747        }
748        Ok(plaintext)
749    }
750}
751
752#[derive(Error, Debug)]
753pub enum EncryptionError {
754    #[error("sealed blob could not be opened: {0}")]
755    SealedBlob(#[from] SealedBlobError),
756    #[error("sealed payload key tag is invalid: {0}")]
757    KeyTag(#[from] KeyTagError),
758    #[error("serialize keyring: {0}")]
759    SerializeKeyring(#[source] serde_json::Error),
760    #[error("keyring JSON is malformed: {0}")]
761    ParseKeyring(#[source] serde_json::Error),
762    #[error("keyring payload is not UTF-8: {0}")]
763    KeyringUtf8(#[source] std::string::FromUtf8Error),
764    #[error("keyring key is not valid hexadecimal: {0}")]
765    KeyHex(#[source] hex::FromHexError),
766    #[error("keyring key has length {actual}, expected 32")]
767    KeyLength { actual: usize },
768    #[error("keyring has no keys")]
769    EmptyKeyring,
770    #[error("cannot merge keys into a plaintext cloud home")]
771    PlaintextKeyring,
772    #[error("key fingerprint {fingerprint} identifies conflicting entries at generations {existing_generation} and {new_generation}")]
773    FingerprintConflict {
774        fingerprint: KeyFingerprint,
775        existing_generation: u64,
776        new_generation: u64,
777    },
778    #[error("no key with fingerprint {0}")]
779    UnknownKeyFingerprint(KeyFingerprint),
780    #[error("new generation {new_generation} must be greater than current generation {current_generation}")]
781    NonIncreasingGeneration {
782        new_generation: u64,
783        current_generation: u64,
784    },
785}
786
787/// Why sealing or opening a host's app-data failed.
788///
789/// Sealing can only fail before the cipher runs — the store has no master key
790/// to seal under. Opening adds the failures a stored payload can carry: a
791/// version this build does not read, a generation this keyring holds no key
792/// for, or an AEAD rejection (a wrong `aad`, a tampered or truncated payload).
793#[derive(Debug, Error)]
794pub enum SealError {
795    /// Custody unlocked no keyring: the store is locked, or a master key was
796    /// never established. The app-data counterpart of the sync engine's
797    /// master-key gate — `unlock` returning `None` is refused here, never
798    /// treated as an empty keyring to seal under.
799    #[error("no master key is established for this store (locked, or never initialized)")]
800    Locked,
801    /// Custody could not produce the keyring — a wrong passphrase, an
802    /// unreadable backing store. Distinct from [`Self::Locked`], which is a
803    /// legitimate absence rather than a failure.
804    #[error("custody error: {0}")]
805    Custody(#[from] KeyError),
806    /// The payload's leading version byte is not one this build seals or reads.
807    #[error("unsupported sealed app-data version {0}")]
808    UnknownVersion(u8),
809    /// The payload names a key (by fingerprint) this keyring does not hold: the
810    /// keyring predates the payload, or the payload was sealed under a foreign one.
811    #[error("sealed app-data names key {0}, which this keyring does not hold")]
812    UnknownKey(String),
813    /// The AEAD rejected the payload — a wrong `aad`, or a tampered or
814    /// truncated ciphertext. Surfaced as it happened, never masked.
815    #[error("app-data cryptography failed: {0}")]
816    Crypto(#[from] EncryptionError),
817}
818
819#[derive(serde::Serialize, serde::Deserialize)]
820struct StoredKeyring {
821    keys: Vec<StoredKeyringGeneration>,
822}
823
824#[derive(serde::Serialize, serde::Deserialize)]
825struct StoredKeyringGeneration {
826    generation: u64,
827    key_hex: String,
828}
829
830/// One key a keyring holds: its 32 bytes and the generation number that orders
831/// it. The key's fingerprint (not the generation) is its identity — two entries
832/// can share a generation number without colliding.
833#[derive(Clone, PartialEq, Eq)]
834struct KeyEntry {
835    generation: u64,
836    key: [u8; 32],
837}
838
839/// The key a keyring seals new data under: the highest generation, and among
840/// equal generations the greatest fingerprint. Deterministic, so every device
841/// holding the same keys converges on the same choice.
842///
843/// Selecting it is a property of the key material, which is why both the
844/// custody-facing [`MasterKeyring`] and the [`EncryptionService`] cipher read it
845/// here rather than one building the other to ask.
846fn seal_entry_of(keys: &BTreeMap<KeyFingerprint, KeyEntry>) -> (&KeyFingerprint, &KeyEntry) {
847    keys.iter()
848        .max_by(|(fingerprint_a, a), (fingerprint_b, b)| {
849            a.generation
850                .cmp(&b.generation)
851                .then_with(|| fingerprint_a.cmp(fingerprint_b))
852        })
853        .expect("a keyring always holds at least one key")
854}
855
856/// The stored keyring JSON for `keys` — the one on-disk form, written by
857/// whichever type holds the material.
858fn keyring_string(keys: &BTreeMap<KeyFingerprint, KeyEntry>) -> Result<String, EncryptionError> {
859    let payload = StoredKeyring {
860        keys: keys
861            .values()
862            .map(|entry| StoredKeyringGeneration {
863                generation: entry.generation,
864                key_hex: hex::encode(entry.key),
865            })
866            .collect(),
867    };
868    serde_json::to_string(&payload).map_err(EncryptionError::SerializeKeyring)
869}
870
871/// A store's master key material: every key it holds. This is the value custody
872/// implementations store, unlock, and re-protect — never a cipher. coven builds
873/// the [`EncryptionService`] cipher from it internally; custody never touches
874/// cipher machinery.
875#[derive(Clone)]
876pub struct MasterKeyring {
877    keys: BTreeMap<KeyFingerprint, KeyEntry>,
878}
879
880impl std::fmt::Debug for MasterKeyring {
881    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
882        f.debug_struct("MasterKeyring")
883            .field("keys", &"<redacted>")
884            .finish()
885    }
886}
887
888impl MasterKeyring {
889    /// One fresh generation-1 key.
890    pub fn generate() -> Self {
891        Self::from(EncryptionService::from_key(generate_random_key()))
892    }
893
894    /// Serialize to the stored keyring JSON — the same format
895    /// [`EncryptionService::to_keyring_string`] produces, since both write the
896    /// same material.
897    pub fn to_serialized(&self) -> String {
898        keyring_string(&self.keys).expect("a MasterKeyring always holds at least one generation")
899    }
900
901    /// Parse the stored master-key format [`Self::to_serialized`] produces.
902    pub fn from_serialized(s: &str) -> Result<Self, EncryptionError> {
903        EncryptionService::new(s).map(Self::from)
904    }
905
906    /// SHA-256 fingerprint of the seal key (the deterministically selected
907    /// key this keyring seals new data under), hex-encoded in full.
908    pub fn fingerprint(&self) -> String {
909        hex::encode(seal_entry_of(&self.keys).0.as_bytes())
910    }
911}
912
913impl From<EncryptionService> for MasterKeyring {
914    fn from(service: EncryptionService) -> Self {
915        Self { keys: service.keys }
916    }
917}
918
919impl From<MasterKeyring> for EncryptionService {
920    fn from(keyring: MasterKeyring) -> Self {
921        EncryptionService { keys: keyring.keys }
922    }
923}
924
925/// The full SHA-256 digest of a key. A keyring entry's identity, and what a
926/// sealed object names to say which key sealed it.
927fn key_fingerprint(key: &[u8; 32]) -> KeyFingerprint {
928    KeyFingerprint::from_bytes(Sha256::digest(key).into())
929}
930
931fn insert_key_entry(
932    keys: &mut BTreeMap<KeyFingerprint, KeyEntry>,
933    fingerprint: KeyFingerprint,
934    entry: KeyEntry,
935) -> Result<(), EncryptionError> {
936    match keys.get(&fingerprint) {
937        None => {
938            keys.insert(fingerprint, entry);
939            Ok(())
940        }
941        Some(existing) if existing == &entry => Ok(()),
942        Some(existing) => Err(EncryptionError::FingerprintConflict {
943            fingerprint,
944            existing_generation: existing.generation,
945            new_generation: entry.generation,
946        }),
947    }
948}
949
950/// Manages encryption keys and provides XChaCha20-Poly1305 encryption/decryption
951///
952/// This implements the security model described in the README:
953/// - Files are encrypted using XChaCha20-Poly1305 for authenticated encryption
954/// - Chunked format enables random-access decryption for efficient range reads
955#[derive(Clone)]
956pub struct EncryptionService {
957    // Keyed by key fingerprint, so two keys sharing a generation number (a fork
958    // from two owners rotating at once) coexist as distinct entries rather than
959    // one silently overwriting the other. The seal key is chosen deterministically
960    // (highest generation, then greatest fingerprint), so once every device holds
961    // the union of both, they all converge on one seal key. A sealed object names
962    // the key it was sealed under by fingerprint, so anything sealed under any key
963    // this keyring holds stays decryptable regardless of which key is current.
964    keys: BTreeMap<KeyFingerprint, KeyEntry>,
965}
966impl std::fmt::Debug for EncryptionService {
967    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
968        f.debug_struct("EncryptionService")
969            .field("keys", &"<redacted>")
970            .finish()
971    }
972}
973impl EncryptionService {
974    /// Create an encryption service from a serialized keyring.
975    pub fn new(stored_key: &str) -> Result<Self, EncryptionError> {
976        info!("Loading master key...");
977        EncryptionService::from_keyring_json(stored_key)
978    }
979
980    /// Create a new encryption service from a raw 32-byte key.
981    pub fn from_key(key: [u8; 32]) -> Self {
982        Self::from_key_at_generation(INITIAL_KEY_GENERATION, key)
983    }
984
985    pub fn from_key_at_generation(generation: u64, key: [u8; 32]) -> Self {
986        let mut keys = BTreeMap::new();
987        keys.insert(key_fingerprint(&key), KeyEntry { generation, key });
988        EncryptionService { keys }
989    }
990
991    pub fn from_keyring(
992        keys: impl IntoIterator<Item = (u64, [u8; 32])>,
993    ) -> Result<Self, EncryptionError> {
994        let mut keyring = BTreeMap::new();
995        for (generation, key) in keys {
996            insert_key_entry(
997                &mut keyring,
998                key_fingerprint(&key),
999                KeyEntry { generation, key },
1000            )?;
1001        }
1002        if keyring.is_empty() {
1003            return Err(EncryptionError::EmptyKeyring);
1004        }
1005        Ok(EncryptionService { keys: keyring })
1006    }
1007
1008    /// The keyring entry this device seals new data under, chosen
1009    /// deterministically fleet-wide: the highest generation number, and among
1010    /// keys sharing that generation, the greatest fingerprint. Once the wraps of
1011    /// a fork propagate so every device holds both keys, they all pick the same
1012    /// one here — a fork converges instead of partitioning.
1013    fn seal_entry(&self) -> (&KeyFingerprint, &KeyEntry) {
1014        seal_entry_of(&self.keys)
1015    }
1016
1017    pub fn current_generation(&self) -> u64 {
1018        self.seal_entry().1.generation
1019    }
1020
1021    /// How many keys this keyring holds. Two keys at the same generation count
1022    /// as two — the count grows only when a genuinely new key is folded in.
1023    pub fn key_count(&self) -> usize {
1024        self.keys.len()
1025    }
1026
1027    pub fn keyring_entries(&self) -> Vec<(u64, [u8; 32])> {
1028        self.keys
1029            .values()
1030            .map(|entry| (entry.generation, entry.key))
1031            .collect()
1032    }
1033
1034    /// Union this keyring with `other`: every distinct key either holds.
1035    /// Identical entries deduplicate. The same fingerprint naming different key
1036    /// bytes or generations is invalid rather than silently choosing one entry.
1037    pub fn merged_with(
1038        &self,
1039        other: &EncryptionService,
1040    ) -> Result<EncryptionService, EncryptionError> {
1041        let mut keys = self.keys.clone();
1042        for (fingerprint, entry) in &other.keys {
1043            insert_key_entry(&mut keys, *fingerprint, entry.clone())?;
1044        }
1045        Ok(EncryptionService { keys })
1046    }
1047
1048    pub fn to_keyring_string(&self) -> Result<String, EncryptionError> {
1049        keyring_string(&self.keys)
1050    }
1051
1052    pub fn to_keyring_payload(&self) -> Result<Vec<u8>, EncryptionError> {
1053        self.to_keyring_string().map(String::into_bytes)
1054    }
1055
1056    pub fn from_keyring_payload(plaintext: Vec<u8>) -> Result<Self, EncryptionError> {
1057        let keyring = String::from_utf8(plaintext).map_err(EncryptionError::KeyringUtf8)?;
1058        EncryptionService::from_keyring_json(&keyring)
1059    }
1060
1061    fn from_keyring_json(keyring: &str) -> Result<Self, EncryptionError> {
1062        let payload: StoredKeyring =
1063            serde_json::from_str(keyring).map_err(EncryptionError::ParseKeyring)?;
1064        let mut keys = Vec::with_capacity(payload.keys.len());
1065        for entry in payload.keys {
1066            let key = hex::decode(&entry.key_hex).map_err(EncryptionError::KeyHex)?;
1067            let actual = key.len();
1068            let key: [u8; 32] = key
1069                .try_into()
1070                .map_err(|_| EncryptionError::KeyLength { actual })?;
1071            keys.push((entry.generation, key));
1072        }
1073        EncryptionService::from_keyring(keys)
1074    }
1075
1076    /// The key with fingerprint `fingerprint`, if this keyring holds it. A sealed
1077    /// object names its sealing key this way, so decryption resolves the key by
1078    /// identity rather than by a generation number that a fork could reuse.
1079    pub(crate) fn key_for_fingerprint(
1080        &self,
1081        fingerprint: &[u8; 32],
1082    ) -> Result<[u8; 32], EncryptionError> {
1083        let fingerprint = KeyFingerprint::from_bytes(*fingerprint);
1084        self.keys
1085            .get(&fingerprint)
1086            .map(|e| e.key)
1087            .ok_or(EncryptionError::UnknownKeyFingerprint(fingerprint))
1088    }
1089
1090    pub fn service_for_fingerprint(
1091        &self,
1092        fingerprint: &[u8; 32],
1093    ) -> Result<EncryptionService, EncryptionError> {
1094        let key = self.key_for_fingerprint(fingerprint)?;
1095        // The single-key service keeps the source key's generation so its own
1096        // seal choices and any re-serialization stay consistent with the keyring
1097        // it came from.
1098        let generation = self
1099            .keys
1100            .get(&KeyFingerprint::from_bytes(*fingerprint))
1101            .expect("just resolved")
1102            .generation;
1103        Ok(EncryptionService::from_key_at_generation(generation, key))
1104    }
1105
1106    pub fn with_appended_generation(
1107        &self,
1108        generation: u64,
1109        key: [u8; 32],
1110    ) -> Result<EncryptionService, EncryptionError> {
1111        if generation <= self.current_generation() {
1112            return Err(EncryptionError::NonIncreasingGeneration {
1113                new_generation: generation,
1114                current_generation: self.current_generation(),
1115            });
1116        }
1117        let mut keys = self.keys.clone();
1118        insert_key_entry(
1119            &mut keys,
1120            key_fingerprint(&key),
1121            KeyEntry { generation, key },
1122        )?;
1123        Ok(EncryptionService { keys })
1124    }
1125
1126    /// Full SHA-256 fingerprint of the seal key, hex-encoded.
1127    pub fn fingerprint(&self) -> String {
1128        hex::encode(self.seal_fingerprint())
1129    }
1130
1131    /// The seal key's full SHA-256 fingerprint — what a sealed object records
1132    /// so a later read resolves the exact key, whatever the keyring has become.
1133    pub fn seal_fingerprint(&self) -> [u8; 32] {
1134        *self.seal_entry().0.as_bytes()
1135    }
1136
1137    pub fn seal_key_fingerprint(&self) -> KeyFingerprint {
1138        KeyFingerprint::from_bytes(self.seal_fingerprint())
1139    }
1140
1141    /// Return the raw 32-byte seal key.
1142    pub fn key_bytes(&self) -> [u8; 32] {
1143        self.seal_entry().1.key
1144    }
1145
1146    /// Seal `plaintext` whole, under a fresh random base nonce this build
1147    /// stores in the header. The one format: a payload read whole and a blob
1148    /// read by range differ only in where their base nonce comes from.
1149    pub fn encrypt(&self, plaintext: &[u8], aad_context: &[u8]) -> Vec<u8> {
1150        let header = whole_object_header(plaintext.len() as u64);
1151        let mut sealer = self
1152            .blob_sealer(header, &NoncePolicy::RandomStored, aad_context)
1153            .expect("a header built for RandomStored opens under it");
1154        let mut output = header.to_bytes();
1155        // An empty plaintext still seals one chunk, holding just its tag, so
1156        // opening it authenticates its emptiness.
1157        if plaintext.is_empty() {
1158            output.extend(sealer.seal_chunk(&[]));
1159            return output;
1160        }
1161        for chunk in plaintext.chunks(header.chunk_size().get() as usize) {
1162            output.extend(sealer.seal_chunk(chunk));
1163        }
1164        output
1165    }
1166
1167    /// Open a payload [`Self::encrypt`] sealed, reading it whole.
1168    pub fn decrypt(
1169        &self,
1170        encrypted_data: &[u8],
1171        aad_context: &[u8],
1172    ) -> Result<Vec<u8>, EncryptionError> {
1173        let header = SealedBlobHeader::parse(encrypted_data)?;
1174        let opener = self.blob_opener(header, &NoncePolicy::RandomStored, aad_context)?;
1175        let body = encrypted_data
1176            .get(header.prefix_len() as usize..)
1177            .expect("a parsed header fits the payload it was parsed from");
1178        Ok(opener.open_chunks(0..header.chunk_count(), body)?)
1179    }
1180
1181    /// A sealer for one payload, framed by `header` and based on `policy`. The
1182    /// header travels in the clear ahead of the chunks; every chunk's AAD binds
1183    /// the header, the payload's context, and the chunk index.
1184    pub fn blob_sealer(
1185        &self,
1186        header: SealedBlobHeader,
1187        policy: &NoncePolicy,
1188        aad_context: &[u8],
1189    ) -> Result<SealedBlobSealer, SealedBlobError> {
1190        SealedBlobSealer::new(&self.key_bytes(), header, policy, aad_context)
1191    }
1192
1193    /// The opener for a payload whose header has been read. Random access: any
1194    /// chunk opens without the ones before it.
1195    pub fn blob_opener(
1196        &self,
1197        header: SealedBlobHeader,
1198        policy: &NoncePolicy,
1199        aad_context: &[u8],
1200    ) -> Result<SealedBlobOpener, SealedBlobError> {
1201        SealedBlobOpener::new(&self.key_bytes(), header, policy, aad_context)
1202    }
1203
1204    /// Derive a scoped encryption service.
1205    ///
1206    /// Uses HKDF: master_key + "coven-scope-v1:{scope_id}" -> 32-byte key.
1207    /// Deterministic: same master + scope_id always gives the same key.
1208    pub fn derive_scoped(&self, scope_id: &str) -> EncryptionService {
1209        let derived = derive_key_from(&self.key_bytes(), &format!("coven-scope-v1:{scope_id}"));
1210        EncryptionService::from_key_at_generation(self.current_generation(), derived)
1211    }
1212
1213    pub fn derive_scoped_for_fingerprint(
1214        &self,
1215        fingerprint: &[u8; 32],
1216        scope_id: &str,
1217    ) -> Result<EncryptionService, EncryptionError> {
1218        let key = self.key_for_fingerprint(fingerprint)?;
1219        let generation = self
1220            .keys
1221            .get(&KeyFingerprint::from_bytes(*fingerprint))
1222            .expect("just resolved")
1223            .generation;
1224        let derived = derive_key_from(&key, &format!("coven-scope-v1:{scope_id}"));
1225        Ok(EncryptionService::from_key_at_generation(
1226            generation, derived,
1227        ))
1228    }
1229
1230    /// Seal `plaintext` for storage in a host's own rows, under this keyring's
1231    /// seal key: a [`KeyTag`] naming that key, then the chunked ciphertext
1232    /// `encrypt` produces under it.
1233    ///
1234    /// `aad` binds the ciphertext to its context (the owning row's primary key,
1235    /// say) and must be presented unchanged to open it.
1236    ///
1237    /// The body is the existing chunked format, so a large payload streams the
1238    /// same way a blob does; there is no size cliff and no second cipher.
1239    pub fn seal_app_data(&self, plaintext: &[u8], aad: &[u8]) -> Vec<u8> {
1240        let mut sealed = KeyTag::write(&self.seal_fingerprint());
1241        sealed.reserve(chunked_encrypted_len(plaintext.len() as u64) as usize);
1242        sealed.extend(self.encrypt(plaintext, aad));
1243        sealed
1244    }
1245
1246    /// Open a payload [`Self::seal_app_data`] produced, under whichever key it
1247    /// names — so a keyring that has rotated or merged a fork since still opens
1248    /// everything it sealed before. A version this build does not read, or a key
1249    /// this keyring does not hold, is a typed error; a wrong `aad` or a tampered
1250    /// payload surfaces the AEAD failure through [`SealError::Crypto`].
1251    pub fn open_app_data(&self, sealed: &[u8], aad: &[u8]) -> Result<Vec<u8>, SealError> {
1252        let (fingerprint, ciphertext) = KeyTag::read(sealed)?;
1253        self.service_for_fingerprint(&fingerprint)
1254            // `service_for_fingerprint` fails only when the keyring holds no key
1255            // with that fingerprint, so this names the cause exactly.
1256            .map_err(|_| SealError::UnknownKey(hex::encode(fingerprint)))?
1257            .decrypt(ciphertext, aad)
1258            .map_err(SealError::Crypto)
1259    }
1260}
1261
1262/// Derive a 32-byte key using HKDF-SHA256 with the given info label.
1263///
1264/// The derivation is deterministic: same input key + same info string always
1265/// produces the same derived key.
1266///
1267/// - Salt: the constant `"coven-hkdf-salt-v1"` (RFC 5869 permits a fixed,
1268///   non-secret salt)
1269/// - IKM: `key`
1270/// - Info: caller-provided label
1271fn derive_key_from(key: &[u8; 32], info: &str) -> [u8; 32] {
1272    let hk = Hkdf::<Sha256>::new(Some(b"coven-hkdf-salt-v1"), key);
1273    let mut okm = [0u8; 32];
1274    hk.expand(info.as_bytes(), &mut okm)
1275        .expect("32 bytes is a valid HKDF output length");
1276    okm
1277}
1278
1279/// Derive nonce for chunk i: base_nonce XOR i (little-endian)
1280fn chunk_nonce(base_nonce: &[u8; NONCE_SIZE], chunk_index: u64) -> [u8; NONCE_SIZE] {
1281    let mut nonce = *base_nonce;
1282    let index_bytes = chunk_index.to_le_bytes();
1283    for i in 0..8 {
1284        nonce[i] ^= index_bytes[i];
1285    }
1286    nonce
1287}
1288
1289#[cfg(test)]
1290#[path = "encryption_tests.rs"]
1291mod tests;