1use 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
43pub const MAGIC: [u8; 8] = *b"GWKBLOB\0";
46
47pub const FORMAT_VERSION: u16 = 1;
50
51pub const DEK_BYTES: usize = 32;
53pub const WRAP_NONCE_BYTES: usize = 24;
55pub const TAG_BYTES: usize = 16;
57pub const WRAPPED_DEK_BYTES: usize = DEK_BYTES + TAG_BYTES;
59pub const STREAM_NONCE_BYTES: usize = WRAP_NONCE_BYTES - 5;
62pub const CHUNK_LEN_BYTES: usize = 4;
64pub const MAX_CIPHERTEXT_CHUNK_BYTES: usize = BLOB_CHUNK_BYTES + TAG_BYTES;
66pub 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
74pub fn header_len(media_type: &str, kek_id: &str) -> usize {
82 FIXED_HEADER_BYTES + media_type.len() + kek_id.len()
83}
84
85pub fn chunk_count(byte_size: u64) -> u64 {
90 byte_size.div_ceil(BLOB_CHUNK_BYTES as u64).max(1)
91}
92
93pub 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#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct Header {
108 pub digest: [u8; DIGEST_BYTES],
110 pub byte_size: u64,
111 pub media_type: String,
112 pub kek_id: String,
114 pub stream_nonce: [u8; STREAM_NONCE_BYTES],
115}
116
117impl Header {
118 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 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 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 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#[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 pub digest_hex: String,
229}
230
231pub 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 let mut chunks: Vec<&[u8]> = plaintext.chunks(BLOB_CHUNK_BYTES).collect();
268 if chunks.is_empty() {
269 chunks.push(&[]);
270 }
271 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
302pub 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 dek.zeroize();
323 sealed
324}
325
326pub 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 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 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
390pub 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
412pub 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 .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
506pub(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], vec![0xcd; BLOB_CHUNK_BYTES + 1], vec![0xef; BLOB_CHUNK_BYTES * 2 + 7], ] {
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 #[test]
590 fn the_format_is_pinned_byte_for_byte() {
591 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 assert_eq!(
611 sealed.digest_hex,
612 "43e27ffff32e033107110d7303fa2fcf4eb11ee1cfe54f5017f9a653084c5d1d"
613 );
614 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 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 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 let (_, opened) =
706 open(&sealed.container, &new_kek, &new_nonce, &rewrapped).expect("open rewrapped");
707 assert_eq!(opened, b"rotate me");
708 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 assert_eq!(a.digest_hex, b.digest_hex);
718 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 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 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 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}