Skip to main content

gwk_kernel/blob/
container.rs

1//! The v1 blob container: an authenticated header followed by length-prefixed
2//! AEAD chunks (ADR 0003).
3//!
4//! ```text
5//! magic            8   b"GWKBLOB\0"
6//! format_version   2   u16 big-endian
7//! digest          32   raw SHA-256 of the plaintext
8//! byte_size        8   u64 big-endian, plaintext length
9//! stream_nonce    19   XChaCha20-Poly1305 nonce minus the BE32 stream suffix
10//! media_type_len   2   u16 big-endian
11//! kek_id_len       2   u16 big-endian
12//! media_type       n   UTF-8
13//! kek_id           n   UTF-8
14//! ------------------- everything above is the AAD -------------------
15//! [chunk_len u32 be][ciphertext] ...  1 MiB plaintext per chunk
16//! ```
17//!
18//! **The wrapped DEK is NOT in this file.** It travels beside the container, in
19//! the row that describes the blob, and that placement is what makes the two
20//! operations ADR 0003 requires cheap and atomic: rewrap replaces one column,
21//! and crypto-shred deletes one column. Both would otherwise have to rewrite a
22//! multi-gigabyte file — non-atomically — to change 72 bytes near its front.
23//! The consequence is stated plainly because it is load-bearing for backups: a
24//! container without its row is unreadable ciphertext, by design.
25//!
26//! Everything in the header is bound as associated data — into the DEK wrap AND
27//! into every chunk — so flipping a byte of the declared size, media type, or
28//! KEK label fails authentication rather than producing a differently-described
29//! blob. The final chunk is sealed with the stream's explicit last-block flag,
30//! which is what makes truncation detectable: a reader that hits EOF early
31//! tries to open a middle chunk as the last one, and the tag does not verify.
32
33use aead::array::Array;
34use aead::consts::{U19, U24, U32};
35use aead::{Aead, Generate, KeyInit, Payload};
36use aead_stream::{DecryptorBE32, EncryptorBE32, NewStream, StreamBE32, StreamPrimitive};
37use chacha20poly1305::XChaCha20Poly1305;
38use gwk_domain::blob::BLOB_CHUNK_BYTES;
39use gwk_domain::port::BlobError;
40use sha2::{Digest, Sha256};
41use zeroize::Zeroize;
42
43/// Identifies a GridWork blob on sight, including in a hex dump of a stray
44/// file that outlived its database.
45pub const MAGIC: [u8; 8] = *b"GWKBLOB\0";
46
47/// The only container version v1 writes or reads. A future format is a new
48/// number here, never a parser that tolerates both.
49pub const FORMAT_VERSION: u16 = 1;
50
51/// Bytes of key material per blob (ADR 0003).
52pub const DEK_BYTES: usize = 32;
53/// XChaCha20-Poly1305 nonce width, used whole for the DEK wrap.
54pub const WRAP_NONCE_BYTES: usize = 24;
55/// Poly1305 tag width, appended to every sealed thing.
56pub const TAG_BYTES: usize = 16;
57/// A wrapped DEK is the key plus the Poly1305 tag.
58pub const WRAPPED_DEK_BYTES: usize = DEK_BYTES + TAG_BYTES;
59/// `StreamBE32` spends 5 of the 24 nonce bytes on its counter and last-block
60/// flag, so the caller supplies the other 19.
61pub const STREAM_NONCE_BYTES: usize = WRAP_NONCE_BYTES - 5;
62/// Width of a chunk's length prefix.
63pub const CHUNK_LEN_BYTES: usize = 4;
64/// The largest a single chunk's ciphertext can legally be.
65pub const MAX_CIPHERTEXT_CHUNK_BYTES: usize = BLOB_CHUNK_BYTES + TAG_BYTES;
66/// Bytes one FULL chunk occupies on disk. Every chunk but the last is exactly
67/// this wide, which is what lets a ranged read compute where chunk N starts
68/// instead of walking the length prefixes to find out.
69pub const FRAMED_CHUNK_BYTES: u64 = (CHUNK_LEN_BYTES + MAX_CIPHERTEXT_CHUNK_BYTES) as u64;
70
71const DIGEST_BYTES: usize = 32;
72const FIXED_HEADER_BYTES: usize = MAGIC.len() + 2 + DIGEST_BYTES + 8 + STREAM_NONCE_BYTES + 2 + 2;
73
74/// How many bytes a header holding these two strings occupies.
75///
76/// A reader already knows both — they are columns on the blob's row — so it
77/// reads exactly this much rather than the 128 KiB a maximal header could
78/// reach. [`Header::decode`] still re-derives the length from the FILE's own
79/// `u16` fields, so a row that disagreed with its container is caught rather
80/// than believed.
81pub fn header_len(media_type: &str, kek_id: &str) -> usize {
82    FIXED_HEADER_BYTES + media_type.len() + kek_id.len()
83}
84
85/// How many chunks a blob of `byte_size` plaintext bytes was sealed into.
86///
87/// Never zero: an empty blob still gets one sealed chunk, or an empty container
88/// and a truncated one would be the same bytes on disk.
89pub fn chunk_count(byte_size: u64) -> u64 {
90    byte_size.div_ceil(BLOB_CHUNK_BYTES as u64).max(1)
91}
92
93/// Where chunk `index` begins, counted from the start of the container.
94pub fn chunk_offset(header_len: usize, index: u64) -> u64 {
95    header_len as u64 + index * FRAMED_CHUNK_BYTES
96}
97
98type Stream = StreamBE32<XChaCha20Poly1305>;
99type StreamNonce = aead_stream::Nonce<XChaCha20Poly1305, Stream>;
100
101fn integrity(reason: impl Into<String>) -> BlobError {
102    BlobError::Integrity(reason.into())
103}
104
105/// The authenticated header, decoded.
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct Header {
108    /// Raw SHA-256 over the plaintext — the same bytes the address hexes.
109    pub digest: [u8; DIGEST_BYTES],
110    pub byte_size: u64,
111    pub media_type: String,
112    /// Nonsecret label of the KEK that wrapped this blob's DEK.
113    pub kek_id: String,
114    pub stream_nonce: [u8; STREAM_NONCE_BYTES],
115}
116
117impl Header {
118    /// The exact bytes that open the container AND serve as associated data.
119    ///
120    /// One function for both, deliberately: if the AAD were assembled
121    /// separately from the bytes on disk, the two could disagree and the
122    /// authentication would be checking something the reader never parsed.
123    pub fn encode(&self) -> Vec<u8> {
124        let media = self.media_type.as_bytes();
125        let kek = self.kek_id.as_bytes();
126        let mut out = Vec::with_capacity(FIXED_HEADER_BYTES + media.len() + kek.len());
127        out.extend_from_slice(&MAGIC);
128        out.extend_from_slice(&FORMAT_VERSION.to_be_bytes());
129        out.extend_from_slice(&self.digest);
130        out.extend_from_slice(&self.byte_size.to_be_bytes());
131        out.extend_from_slice(&self.stream_nonce);
132        // Lengths are u16 and both fields are bounded well under it by
133        // `seal_with`, so the casts cannot truncate.
134        out.extend_from_slice(&(media.len() as u16).to_be_bytes());
135        out.extend_from_slice(&(kek.len() as u16).to_be_bytes());
136        out.extend_from_slice(media);
137        out.extend_from_slice(kek);
138        out
139    }
140
141    /// Parse a header, returning it and how many bytes it occupied.
142    ///
143    /// Strict: unknown magic, an unknown version, a length that runs past the
144    /// buffer, or non-UTF-8 in either string is a refusal. Nothing here is
145    /// recovered from or guessed at — the header is the thing every other byte
146    /// is authenticated against.
147    pub fn decode(bytes: &[u8]) -> Result<(Self, usize), BlobError> {
148        if bytes.len() < FIXED_HEADER_BYTES {
149            return Err(integrity("container is shorter than a header"));
150        }
151        // Fixed offsets, named once. Every slice below sits inside the length
152        // checked above, and spelling the boundaries out means the layout in
153        // the module docs and the parser can be read against each other.
154        const VERSION_AT: usize = MAGIC.len();
155        const DIGEST_AT: usize = VERSION_AT + 2;
156        const SIZE_AT: usize = DIGEST_AT + DIGEST_BYTES;
157        const NONCE_AT: usize = SIZE_AT + 8;
158        const MEDIA_LEN_AT: usize = NONCE_AT + STREAM_NONCE_BYTES;
159        const KEK_LEN_AT: usize = MEDIA_LEN_AT + 2;
160
161        if bytes[..MAGIC.len()] != MAGIC {
162            return Err(integrity("not a gwk blob container"));
163        }
164        let version = u16::from_be_bytes(
165            bytes[VERSION_AT..DIGEST_AT]
166                .try_into()
167                .map_err(|_| integrity("version"))?,
168        );
169        if version != FORMAT_VERSION {
170            return Err(integrity(format!(
171                "container format version {version}, expected {FORMAT_VERSION}"
172            )));
173        }
174        let digest: [u8; DIGEST_BYTES] = bytes[DIGEST_AT..SIZE_AT]
175            .try_into()
176            .map_err(|_| integrity("digest"))?;
177        let byte_size = u64::from_be_bytes(
178            bytes[SIZE_AT..NONCE_AT]
179                .try_into()
180                .map_err(|_| integrity("size"))?,
181        );
182        let stream_nonce: [u8; STREAM_NONCE_BYTES] = bytes[NONCE_AT..MEDIA_LEN_AT]
183            .try_into()
184            .map_err(|_| integrity("nonce"))?;
185        let media_len = usize::from(u16::from_be_bytes(
186            bytes[MEDIA_LEN_AT..KEK_LEN_AT]
187                .try_into()
188                .map_err(|_| integrity("len"))?,
189        ));
190        let kek_len = usize::from(u16::from_be_bytes(
191            bytes[KEK_LEN_AT..FIXED_HEADER_BYTES]
192                .try_into()
193                .map_err(|_| integrity("len"))?,
194        ));
195        let mut at = FIXED_HEADER_BYTES;
196        if bytes.len() < at + media_len + kek_len {
197            return Err(integrity("header runs past the container"));
198        }
199        let media_type = std::str::from_utf8(&bytes[at..at + media_len])
200            .map_err(|_| integrity("media type is not utf-8"))?
201            .to_owned();
202        at += media_len;
203        let kek_id = std::str::from_utf8(&bytes[at..at + kek_len])
204            .map_err(|_| integrity("kek id is not utf-8"))?
205            .to_owned();
206        at += kek_len;
207        Ok((
208            Self {
209                digest,
210                byte_size,
211                media_type,
212                kek_id,
213                stream_nonce,
214            },
215            at,
216        ))
217    }
218}
219
220/// A sealed blob: the container bytes, plus the key material that belongs in
221/// the database rather than the file.
222#[derive(Debug, Clone, PartialEq, Eq)]
223pub struct Sealed {
224    pub container: Vec<u8>,
225    pub wrap_nonce: [u8; WRAP_NONCE_BYTES],
226    pub wrapped_dek: [u8; WRAPPED_DEK_BYTES],
227    /// Lowercase 64-hex of the plaintext digest — the blob's address.
228    pub digest_hex: String,
229}
230
231/// Seal `plaintext` with caller-supplied key material.
232///
233/// Every random input is a parameter so the format has exact golden vectors: a
234/// function that mints its own DEK cannot be pinned by a test, and an
235/// unpinnable byte format silently drifts. [`seal`] is the thin wrapper that
236/// generates them for real use.
237pub fn seal_with(
238    plaintext: &[u8],
239    media_type: &str,
240    kek: &[u8; DEK_BYTES],
241    kek_id: &str,
242    dek: &[u8; DEK_BYTES],
243    wrap_nonce: &[u8; WRAP_NONCE_BYTES],
244    stream_nonce: &[u8; STREAM_NONCE_BYTES],
245) -> Result<Sealed, BlobError> {
246    if media_type.len() > u16::MAX as usize || kek_id.len() > u16::MAX as usize {
247        return Err(integrity("media type or kek id is too long to encode"));
248    }
249    let digest: [u8; DIGEST_BYTES] = Sha256::digest(plaintext).into();
250    let header = Header {
251        digest,
252        byte_size: plaintext.len() as u64,
253        media_type: media_type.to_owned(),
254        kek_id: kek_id.to_owned(),
255        stream_nonce: *stream_nonce,
256    };
257    let aad = header.encode();
258
259    let wrapped_dek = wrap_dek(dek, kek, wrap_nonce, &aad)?;
260
261    let mut container = aad.clone();
262    let mut encryptor =
263        EncryptorBE32::<XChaCha20Poly1305>::new(&Array(*dek), &StreamNonce::from(*stream_nonce));
264
265    // An empty blob still gets one sealed chunk: zero chunks would make an
266    // empty blob and a truncated one the same bytes on disk.
267    let mut chunks: Vec<&[u8]> = plaintext.chunks(BLOB_CHUNK_BYTES).collect();
268    if chunks.is_empty() {
269        chunks.push(&[]);
270    }
271    // Split before the loop because `encrypt_last` consumes the encryptor —
272    // which is the type system enforcing that a stream has exactly one final
273    // chunk and nothing follows it.
274    let Some((last, rest)) = chunks.split_last() else {
275        return Err(integrity("no chunk to seal"));
276    };
277    for chunk in rest {
278        let sealed = encryptor
279            .encrypt_next(Payload {
280                msg: chunk,
281                aad: &aad,
282            })
283            .map_err(|_| integrity("chunk encryption failed"))?;
284        push_chunk(&mut container, &sealed)?;
285    }
286    let sealed = encryptor
287        .encrypt_last(Payload {
288            msg: last,
289            aad: &aad,
290        })
291        .map_err(|_| integrity("final chunk encryption failed"))?;
292    push_chunk(&mut container, &sealed)?;
293
294    Ok(Sealed {
295        container,
296        wrap_nonce: *wrap_nonce,
297        wrapped_dek,
298        digest_hex: hex_lower(&digest),
299    })
300}
301
302/// Seal `plaintext`, minting a fresh DEK and nonces from the system CSPRNG.
303pub fn seal(
304    plaintext: &[u8],
305    media_type: &str,
306    kek: &[u8; DEK_BYTES],
307    kek_id: &str,
308) -> Result<Sealed, BlobError> {
309    let mut dek: Array<u8, U32> = generate()?;
310    let wrap_nonce: Array<u8, U24> = generate()?;
311    let stream_nonce: Array<u8, U19> = generate()?;
312    let sealed = seal_with(
313        plaintext,
314        media_type,
315        kek,
316        kek_id,
317        &dek.0,
318        &wrap_nonce.0,
319        &stream_nonce.0,
320    );
321    // The wrapped copy is the only one that should outlive this call.
322    dek.zeroize();
323    sealed
324}
325
326/// Open a container, given the key material stored beside it.
327///
328/// Returns the plaintext only if the header authenticates, every chunk
329/// authenticates in order, the final chunk was sealed as final, and the
330/// plaintext hashes to the digest the header claims.
331pub fn open(
332    container: &[u8],
333    kek: &[u8; DEK_BYTES],
334    wrap_nonce: &[u8; WRAP_NONCE_BYTES],
335    wrapped_dek: &[u8; WRAPPED_DEK_BYTES],
336) -> Result<(Header, Vec<u8>), BlobError> {
337    let (header, header_len) = Header::decode(container)?;
338    let aad = &container[..header_len];
339    let mut dek = unwrap_dek(wrapped_dek, kek, wrap_nonce, aad)?;
340
341    let mut decryptor = DecryptorBE32::<XChaCha20Poly1305>::new(
342        &Array(dek),
343        &StreamNonce::from(header.stream_nonce),
344    );
345    dek.zeroize();
346
347    // Frame the whole container before decrypting any of it: the framing is
348    // unauthenticated, so a bad length must be a structural refusal rather
349    // than something discovered halfway through a stream.
350    let mut framed: Vec<&[u8]> = Vec::new();
351    let mut at = header_len;
352    while at < container.len() {
353        let (chunk, next) = read_chunk(container, at)?;
354        framed.push(chunk);
355        at = next;
356    }
357    let Some((last, rest)) = framed.split_last() else {
358        return Err(integrity("container has no chunks"));
359    };
360
361    let mut plaintext = Vec::with_capacity(header.byte_size as usize);
362    for chunk in rest {
363        let part = decryptor
364            .decrypt_next(Payload { msg: chunk, aad })
365            .map_err(|_| integrity("chunk failed authentication"))?;
366        plaintext.extend_from_slice(&part);
367    }
368    // Sealed with the stream's last-block flag, so a truncated container
369    // arrives here holding what was a MIDDLE chunk and the tag does not
370    // verify — which is exactly how truncation is caught.
371    let part = decryptor
372        .decrypt_last(Payload { msg: last, aad })
373        .map_err(|_| integrity("final chunk failed authentication: tampered or truncated"))?;
374    plaintext.extend_from_slice(&part);
375
376    if plaintext.len() as u64 != header.byte_size {
377        return Err(integrity(format!(
378            "container holds {} plaintext bytes, header declares {}",
379            plaintext.len(),
380            header.byte_size
381        )));
382    }
383    let actual: [u8; DIGEST_BYTES] = Sha256::digest(&plaintext).into();
384    if actual != header.digest {
385        return Err(integrity("plaintext does not hash to the declared digest"));
386    }
387    Ok((header, plaintext))
388}
389
390/// Rewrap this blob's DEK under a new KEK without touching its ciphertext.
391///
392/// The AAD is the container's own header bytes, which do not change — the KEK
393/// LABEL lives in the header, so a rewrap under a differently-labeled key would
394/// invalidate it. Rotation therefore keeps the label and changes the key behind
395/// it, which is what "rewrap changes only the wrapped DEK" means in practice.
396pub fn rewrap(
397    container: &[u8],
398    old_kek: &[u8; DEK_BYTES],
399    new_kek: &[u8; DEK_BYTES],
400    wrap_nonce: &[u8; WRAP_NONCE_BYTES],
401    wrapped_dek: &[u8; WRAPPED_DEK_BYTES],
402    new_wrap_nonce: &[u8; WRAP_NONCE_BYTES],
403) -> Result<[u8; WRAPPED_DEK_BYTES], BlobError> {
404    let (_, header_len) = Header::decode(container)?;
405    let aad = &container[..header_len];
406    let mut dek = unwrap_dek(wrapped_dek, old_kek, wrap_nonce, aad)?;
407    let rewrapped = wrap_dek(&dek, new_kek, new_wrap_nonce, aad);
408    dek.zeroize();
409    rewrapped
410}
411
412/// Decrypt ONE chunk, without touching the ones before it.
413///
414/// This is what makes a ranged read cost the chunks it asked for rather than
415/// every chunk preceding them. `StreamBE32` is a stateless primitive keyed by
416/// position — the sequential [`open`] path is a convenience over exactly this
417/// operation, not a different construction — so chunk N authenticates alone.
418///
419/// `last` is bound into the tag, so it is not a hint: passing `false` for the
420/// real final chunk fails, and so does passing `true` for a middle one. That is
421/// the property that catches a truncated container, and it only holds if the
422/// caller derives `last` from the header's declared size rather than from where
423/// the file happens to end.
424pub fn open_chunk(
425    aad: &[u8],
426    dek: &[u8; DEK_BYTES],
427    stream_nonce: &[u8; STREAM_NONCE_BYTES],
428    position: u32,
429    last: bool,
430    ciphertext: &[u8],
431) -> Result<Vec<u8>, BlobError> {
432    Stream::new(&Array(*dek), &StreamNonce::from(*stream_nonce))
433        .decrypt(
434            position,
435            last,
436            Payload {
437                msg: ciphertext,
438                aad,
439            },
440        )
441        .map_err(|_| integrity("chunk failed authentication: tampered or truncated"))
442}
443
444pub(crate) fn wrap_dek(
445    dek: &[u8; DEK_BYTES],
446    kek: &[u8; DEK_BYTES],
447    nonce: &[u8; WRAP_NONCE_BYTES],
448    aad: &[u8],
449) -> Result<[u8; WRAPPED_DEK_BYTES], BlobError> {
450    let sealed = XChaCha20Poly1305::new(&Array(*kek))
451        .encrypt(&Array(*nonce), Payload { msg: dek, aad })
452        .map_err(|_| integrity("wrapping the data key failed"))?;
453    sealed
454        .try_into()
455        .map_err(|_| integrity("wrapped key is the wrong length"))
456}
457
458pub(crate) fn unwrap_dek(
459    wrapped: &[u8; WRAPPED_DEK_BYTES],
460    kek: &[u8; DEK_BYTES],
461    nonce: &[u8; WRAP_NONCE_BYTES],
462    aad: &[u8],
463) -> Result<[u8; DEK_BYTES], BlobError> {
464    let dek = XChaCha20Poly1305::new(&Array(*kek))
465        .decrypt(
466            &Array(*nonce),
467            Payload {
468                msg: wrapped.as_slice(),
469                aad,
470            },
471        )
472        // Deliberately not distinguished from a tampered header: the same
473        // failure covers "wrong KEK" and "header edited", and telling them
474        // apart tells an attacker which half of the guess was right.
475        .map_err(|_| integrity("unwrapping the data key failed: wrong kek or altered header"))?;
476    dek.try_into()
477        .map_err(|_| integrity("unwrapped key is the wrong length"))
478}
479
480fn push_chunk(out: &mut Vec<u8>, chunk: &[u8]) -> Result<(), BlobError> {
481    let len = u32::try_from(chunk.len()).map_err(|_| integrity("chunk is too large to frame"))?;
482    out.extend_from_slice(&len.to_be_bytes());
483    out.extend_from_slice(chunk);
484    Ok(())
485}
486
487fn read_chunk(container: &[u8], at: usize) -> Result<(&[u8], usize), BlobError> {
488    let end = at + 4;
489    if container.len() < end {
490        return Err(integrity("truncated chunk length"));
491    }
492    let len = u32::from_be_bytes(
493        container[at..end]
494            .try_into()
495            .map_err(|_| integrity("chunk length"))?,
496    ) as usize;
497    let stop = end
498        .checked_add(len)
499        .ok_or_else(|| integrity("chunk length overflows the container"))?;
500    if container.len() < stop {
501        return Err(integrity("chunk runs past the container"));
502    }
503    Ok((&container[end..stop], stop))
504}
505
506/// Fresh bytes from the system CSPRNG, sized by the type at the call site.
507///
508/// A failure here is a Storage error rather than an integrity one: the machine
509/// could not produce randomness, which says nothing about any blob.
510pub(crate) fn generate<N: aead::array::ArraySize>() -> Result<Array<u8, N>, BlobError> {
511    Array::try_generate()
512        .map_err(|e| BlobError::Storage(format!("system randomness unavailable: {e}")))
513}
514
515pub(crate) fn hex_lower(bytes: &[u8]) -> String {
516    let mut out = String::with_capacity(bytes.len() * 2);
517    for byte in bytes {
518        out.push_str(&format!("{byte:02x}"));
519    }
520    out
521}
522
523#[cfg(test)]
524mod tests {
525    use super::*;
526
527    const KEK: [u8; DEK_BYTES] = [0x11; DEK_BYTES];
528    const DEK: [u8; DEK_BYTES] = [0x22; DEK_BYTES];
529    const WRAP_NONCE: [u8; WRAP_NONCE_BYTES] = [0x33; WRAP_NONCE_BYTES];
530    const STREAM_NONCE: [u8; STREAM_NONCE_BYTES] = [0x44; STREAM_NONCE_BYTES];
531
532    fn seal_fixture(plaintext: &[u8]) -> Sealed {
533        seal_with(
534            plaintext,
535            "application/json",
536            &KEK,
537            "kek-test",
538            &DEK,
539            &WRAP_NONCE,
540            &STREAM_NONCE,
541        )
542        .expect("seal")
543    }
544
545    #[test]
546    fn a_sealed_blob_opens_to_exactly_what_went_in() {
547        for plaintext in [
548            b"".to_vec(),
549            b"one chunk".to_vec(),
550            vec![0xab; BLOB_CHUNK_BYTES],         // exactly one chunk
551            vec![0xcd; BLOB_CHUNK_BYTES + 1],     // spills to a second
552            vec![0xef; BLOB_CHUNK_BYTES * 2 + 7], // three, last one short
553        ] {
554            let sealed = seal_fixture(&plaintext);
555            let (header, opened) = open(
556                &sealed.container,
557                &KEK,
558                &sealed.wrap_nonce,
559                &sealed.wrapped_dek,
560            )
561            .expect("open");
562            assert_eq!(opened, plaintext);
563            assert_eq!(header.byte_size, plaintext.len() as u64);
564            assert_eq!(header.media_type, "application/json");
565            assert_eq!(header.kek_id, "kek-test");
566        }
567    }
568
569    /// The whole container for `b"gridwork"`, byte for byte.
570    ///
571    /// Every structural field was verified BY HAND against the layout in the
572    /// module docs, and the plaintext digest against `sha256sum` and `openssl
573    /// dgst` independently of this code. The 24 trailing AEAD bytes are pinned
574    /// by observation — the honest scope of a golden when this crate is the
575    /// only implementation of the format.
576    ///
577    /// ```text
578    /// 47574b424c4f4200  magic "GWKBLOB\0"
579    /// 0001              format version 1
580    /// 43e2..5d1d        sha256("gridwork"), independently verified
581    /// 0000000000000008  byte_size 8
582    /// 44 x19            stream nonce
583    /// 0010 0008         media_type_len 16, kek_id_len 8
584    /// 6170..6f6e        "application/json"
585    /// 6b65..7374        "kek-test"
586    /// 00000018          chunk length 24 = 8 plaintext + 16 tag
587    /// 658b..5722        ciphertext and tag
588    /// ```
589    #[test]
590    fn the_format_is_pinned_byte_for_byte() {
591        // A golden, not a round trip: a round trip still passes when both
592        // halves drift together, which is exactly how a byte format breaks
593        // silently between releases.
594        let sealed = seal_fixture(b"gridwork");
595        let hex: String = sealed
596            .container
597            .iter()
598            .map(|b| format!("{b:02x}"))
599            .collect();
600        assert_eq!(
601            hex,
602            "47574b424c4f4200000143e27ffff32e033107110d7303fa2fcf4eb11ee1cfe54f5017f9a653\
603             084c5d1d00000000000000084444444444444444444444444444444444444400100008617070\
604             6c69636174696f6e2f6a736f6e6b656b2d7465737400000018658bce0c2761f96df661755b52\
605             019f8186b90db3313e5722"
606                .replace([' ', '\n'], "")
607        );
608        // The digest is the address, and it is the one field a second
609        // implementation must agree on byte for byte.
610        assert_eq!(
611            sealed.digest_hex,
612            "43e27ffff32e033107110d7303fa2fcf4eb11ee1cfe54f5017f9a653084c5d1d"
613        );
614        // 97 header (8+2+32+8+19+2+2+16+8) + 4 length + 8 plaintext + 16 tag.
615        assert_eq!(sealed.container.len(), 125);
616        assert_eq!(sealed.wrapped_dek.len(), WRAPPED_DEK_BYTES);
617    }
618
619    #[test]
620    fn the_header_is_associated_data_so_editing_it_fails_the_open() {
621        let sealed = seal_fixture(b"payload");
622        // Every field, one at a time. The offsets follow the layout in the
623        // module docs; a change there should break this test loudly.
624        for (label, at) in [
625            ("version", 9),
626            ("digest", 12),
627            ("byte_size", 44),
628            ("stream_nonce", 52),
629            ("media_type", 79),
630            ("kek_id", 92),
631        ] {
632            let mut tampered = sealed.container.clone();
633            tampered[at] ^= 0xff;
634            let err =
635                open(&tampered, &KEK, &sealed.wrap_nonce, &sealed.wrapped_dek).expect_err(label);
636            assert!(matches!(err, BlobError::Integrity(_)), "{label}: {err:?}");
637        }
638    }
639
640    #[test]
641    fn a_flipped_ciphertext_byte_fails_authentication() {
642        let sealed = seal_fixture(b"payload");
643        let mut tampered = sealed.container.clone();
644        let last = tampered.len() - 1;
645        tampered[last] ^= 0x01;
646        assert!(matches!(
647            open(&tampered, &KEK, &sealed.wrap_nonce, &sealed.wrapped_dek),
648            Err(BlobError::Integrity(_))
649        ));
650    }
651
652    #[test]
653    fn truncation_is_detected_because_the_last_chunk_is_sealed_as_last() {
654        // Two chunks, then drop the second entirely. The remaining chunk is a
655        // structurally valid container whose only flaw is that its final chunk
656        // was encrypted as a middle one.
657        let plaintext = vec![0x5a; BLOB_CHUNK_BYTES + 32];
658        let sealed = seal_fixture(&plaintext);
659        let (_, header_len) = Header::decode(&sealed.container).expect("header");
660        let (first, after_first) = read_chunk(&sealed.container, header_len).expect("chunk");
661        assert!(after_first < sealed.container.len(), "expected two chunks");
662
663        let mut truncated = sealed.container[..header_len].to_vec();
664        push_chunk(&mut truncated, first).expect("frame");
665        let err = open(&truncated, &KEK, &sealed.wrap_nonce, &sealed.wrapped_dek)
666            .expect_err("truncated container");
667        assert!(matches!(err, BlobError::Integrity(_)), "{err:?}");
668    }
669
670    #[test]
671    fn the_wrong_kek_cannot_open_it_and_says_nothing_about_why() {
672        let sealed = seal_fixture(b"secret");
673        let wrong = [0x99; DEK_BYTES];
674        let err = open(
675            &sealed.container,
676            &wrong,
677            &sealed.wrap_nonce,
678            &sealed.wrapped_dek,
679        )
680        .expect_err("wrong kek");
681        let BlobError::Integrity(message) = err else {
682            panic!("expected an integrity failure");
683        };
684        assert!(message.contains("wrong kek or altered header"), "{message}");
685    }
686
687    #[test]
688    fn rewrapping_changes_only_the_key_not_the_ciphertext() {
689        let sealed = seal_fixture(b"rotate me");
690        let new_kek = [0x77; DEK_BYTES];
691        let new_nonce = [0x88; WRAP_NONCE_BYTES];
692        let rewrapped = rewrap(
693            &sealed.container,
694            &KEK,
695            &new_kek,
696            &sealed.wrap_nonce,
697            &sealed.wrapped_dek,
698            &new_nonce,
699        )
700        .expect("rewrap");
701
702        assert_ne!(rewrapped, sealed.wrapped_dek);
703        // The container is untouched, which is the whole point: rotation must
704        // not rewrite blob bytes.
705        let (_, opened) =
706            open(&sealed.container, &new_kek, &new_nonce, &rewrapped).expect("open rewrapped");
707        assert_eq!(opened, b"rotate me");
708        // And the old KEK no longer opens it.
709        assert!(open(&sealed.container, &KEK, &new_nonce, &rewrapped).is_err());
710    }
711
712    #[test]
713    fn a_generated_seal_uses_fresh_material_every_time() {
714        let a = seal(b"same bytes", "text/plain", &KEK, "kek-test").expect("seal");
715        let b = seal(b"same bytes", "text/plain", &KEK, "kek-test").expect("seal");
716        // Same plaintext, same address — that is what content addressing means.
717        assert_eq!(a.digest_hex, b.digest_hex);
718        // Different ciphertext, because nonce reuse under one key is the one
719        // mistake this construction cannot survive.
720        assert_ne!(a.container, b.container);
721        assert_ne!(a.wrap_nonce, b.wrap_nonce);
722        let (_, opened) = open(&a.container, &KEK, &a.wrap_nonce, &a.wrapped_dek).expect("open");
723        assert_eq!(opened, b"same bytes");
724    }
725
726    #[test]
727    fn a_chunk_can_be_opened_where_it_lies_without_reading_the_ones_before_it() {
728        // Three chunks, the last one short — the shape a ranged read has to
729        // navigate. If the computed offsets or the last-block flag were wrong
730        // for any position, that chunk would fail to authenticate.
731        let plaintext: Vec<u8> = (0..BLOB_CHUNK_BYTES * 2 + 7)
732            .map(|i| (i % 251) as u8)
733            .collect();
734        let sealed = seal_fixture(&plaintext);
735        let (header, header_len) = Header::decode(&sealed.container).expect("header");
736        let aad = &sealed.container[..header_len];
737        let dek = unwrap_dek(&sealed.wrapped_dek, &KEK, &sealed.wrap_nonce, aad).expect("unwrap");
738
739        let count = chunk_count(header.byte_size);
740        assert_eq!(count, 3);
741        let mut reassembled = Vec::new();
742        for index in 0..count {
743            let at = chunk_offset(header_len, index) as usize;
744            let (chunk, _) = read_chunk(&sealed.container, at).expect("frame");
745            let part = open_chunk(
746                aad,
747                &dek,
748                &header.stream_nonce,
749                index as u32,
750                index == count - 1,
751                chunk,
752            )
753            .expect("chunk opens on its own");
754            reassembled.extend_from_slice(&part);
755        }
756        assert_eq!(reassembled, plaintext);
757
758        // The last-block flag is authenticated, not advisory: a reader that
759        // guessed it from where the file ends would accept a truncation.
760        let (middle, _) =
761            read_chunk(&sealed.container, chunk_offset(header_len, 0) as usize).expect("frame");
762        assert!(
763            open_chunk(aad, &dek, &header.stream_nonce, 0, true, middle).is_err(),
764            "chunk 0 must not open as the final chunk"
765        );
766        let (final_chunk, _) =
767            read_chunk(&sealed.container, chunk_offset(header_len, 2) as usize).expect("frame");
768        assert!(
769            open_chunk(aad, &dek, &header.stream_nonce, 2, false, final_chunk).is_err(),
770            "the final chunk must not open as a middle one"
771        );
772        // Nor at someone else's position.
773        assert!(
774            open_chunk(aad, &dek, &header.stream_nonce, 1, false, middle).is_err(),
775            "chunk 0 must not open at position 1"
776        );
777    }
778
779    #[test]
780    fn a_container_that_is_not_one_is_refused_before_any_crypto() {
781        for (label, bytes) in [
782            ("empty", Vec::new()),
783            ("short", vec![0u8; 10]),
784            ("bad magic", {
785                let mut v = seal_fixture(b"x").container;
786                v[0] = b'X';
787                v
788            }),
789            ("future version", {
790                let mut v = seal_fixture(b"x").container;
791                v[9] = 0x02;
792                v
793            }),
794        ] {
795            assert!(
796                matches!(Header::decode(&bytes), Err(BlobError::Integrity(_))),
797                "{label} decoded"
798            );
799        }
800    }
801}