gunnar-crypt 1.5.1

Encrypted repositories: the content envelope, the per-repo key ring, its members, the server's plaintext check, each person's one encryption key and its unlock methods, and the ring's wrap to members' encryption keys
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
//! One encrypted file (blob). Two versions; this code writes v2 and reads both.
//!
//! **v2 — chunked, the version every new seal writes:**
//!
//! ```text
//! offset  0   8 bytes  magic        00 47 4E 43 52 59 50 54   ("\0GNCRYPT")
//!         8   1 byte   version      02
//!         9   4 bytes  epoch        big-endian
//!        13   4 bytes  chunk size   big-endian (1 MiB = 00 10 00 00)
//!        17  32 bytes  file nonce   HMAC-SHA256(nonce key, the whole plaintext)
//!        49   …        blocks       16-byte SIV tag ‖ ciphertext, one per chunk
//! ```
//!
//! * The plaintext is cut into **fixed chunks** of [`CHUNK`] bytes; the last
//!   one is shorter, and it is empty only when the whole file is (then it is
//!   the one block). Each block is sealed alone with **AES-256-SIV** (RFC 5297)
//!   under the epoch key, with associated data
//!   `header (49 bytes) ‖ block index (u64, big-endian) ‖ last (1 byte: 1 on
//!   the last block, else 0)`. That is the STREAM construction (Hoang,
//!   Reyhanitabar, Rogaway, Vizár, "Online Authenticated-Encryption and its
//!   Nonce-Reuse Misuse-Resistance", CRYPTO 2015) with a deterministic AEAD:
//!   blocks cannot be reordered (the index), a dropped tail or an appended
//!   block does not open (the last flag), and a filter can seal and open while
//!   it reads, holding one chunk, with no block count up front.
//! * **The file nonce** is in every block's associated data. It is a keyed MAC
//!   of the WHOLE plaintext, under a key derived from the epoch key
//!   (HKDF-SHA256, info [`NONCE_INFO`]). Without it, two files (or two
//!   versions of one file) that share an aligned 1 MiB block in the same epoch
//!   would show the same ciphertext block, and an observer would see which
//!   megabytes of a large file changed between commits. With it, any
//!   difference anywhere in a file changes every block. What is left is what
//!   v1 already showed: two EQUAL files in one epoch are equal envelopes. It
//!   costs one more read of the plaintext before the first block can be
//!   written; the git filter spools its input anyway (git's protocol hands the
//!   whole file over before it reads an answer), and MACs it while spooling.
//! * **Deterministic**, like v1: the same content under the same key gives the
//!   same bytes, so git's clean filter is stable and the blob id is a KEYED id.
//! * **Overhead**: 49 bytes per file plus 16 bytes per started MiB.
//!
//! **v1 — whole-file, still read:** `MAGIC | 01 | EPOCH (4) | SIV tag (16) |
//! ciphertext`, AES-256-SIV over the whole content with the 13 header bytes
//! as associated data. AES-SIV is two-pass, so a v1 envelope is opened in
//! memory. An unchanged file sealed v1 is never re-sealed (the clean filter
//! keeps the blob it already is), so no repository-wide diff appears.
//!
//! `MAGIC` starts with a NUL byte, so git treats every envelope as binary and
//! never tries a text diff or a text merge on ciphertext.
//!
//! The format checks ([`is_envelope`], [`plaintext_len`], [`epoch_of`]) need
//! no key and no cipher; a server uses them. Sealing and opening need the
//! `cipher` feature.

/// The first eight bytes of every envelope.
pub const MAGIC: [u8; 8] = *b"\0GNCRYPT";
/// The format version this code writes.
pub const VERSION: u8 = V2;
/// The whole-file format (read only).
pub const V1: u8 = 1;
/// The chunked format.
pub const V2: u8 = 2;
/// v1's header: `MAGIC + VERSION + EPOCH`.
pub const V1_HEADER_LEN: usize = 13;
/// v2's header: `MAGIC + VERSION + EPOCH + CHUNK SIZE + FILE NONCE`.
pub const V2_HEADER_LEN: usize = 49;
/// The bytes of a v2 header that come before the file nonce.
const V2_FIXED_LEN: usize = 17;
/// The file nonce.
pub const NONCE_LEN: usize = 32;
/// One SIV tag (per file in v1, per block in v2).
pub const TAG_LEN: usize = 16;
/// The smallest valid envelope: v1 of empty content.
pub const MIN_LEN: usize = V1_HEADER_LEN + TAG_LEN;
/// The plaintext bytes in one v2 block (the last block may be shorter).
pub const CHUNK: u32 = 1 << 20;
/// The largest chunk size a reader accepts: what it holds in memory at once.
pub const MAX_CHUNK: u32 = 16 << 20;
/// HKDF-SHA256 `info` for the key the file nonce is a MAC under.
pub const NONCE_INFO: &[u8] = b"gunnar-crypt envelope v2 file nonce";
/// Associated data of one v2 block: header ‖ index (8) ‖ last (1).
#[cfg(feature = "cipher")]
const AD_LEN: usize = V2_HEADER_LEN + 8 + 1;

/// Does `bytes` carry the envelope magic at all (any version)?
pub fn has_magic(bytes: &[u8]) -> bool {
    bytes.len() >= MAGIC.len() && bytes[..MAGIC.len()] == MAGIC
}

/// The plaintext length of an envelope that is `total` bytes long, read from
/// its first bytes (`head`: at least 17 for v2, 9 for v1) and its length
/// alone. `None` when it is not a well-formed envelope of a version this code
/// knows: no magic, an unknown version, an impossible chunk size, or a length
/// that no sequence of blocks has (cut inside a tag, or an empty last block
/// after full ones, which no sealer writes).
pub fn plaintext_len(head: &[u8], total: u64) -> Option<u64> {
    if !has_magic(head) || head.len() <= MAGIC.len() {
        return None;
    }
    match head[MAGIC.len()] {
        V1 => total.checked_sub(MIN_LEN as u64),
        V2 => {
            if head.len() < V2_FIXED_LEN {
                return None;
            }
            let chunk = u64::from(chunk_of(head));
            if chunk == 0 || chunk > u64::from(MAX_CHUNK) {
                return None;
            }
            let body = total.checked_sub(V2_HEADER_LEN as u64)?;
            let block = chunk + TAG_LEN as u64;
            let (full, rest) = (body / block, body % block);
            match rest {
                0 if full > 0 => Some(full * chunk),
                r if r < TAG_LEN as u64 => None,
                r if r == TAG_LEN as u64 && full > 0 => None,
                r => Some(full * chunk + r - TAG_LEN as u64),
            }
        }
        _ => None,
    }
}

/// Is `bytes` a well-formed envelope of a version this code knows (v1, v2)?
///
/// A format check only: it cannot tell a real envelope from bytes that merely
/// look like one. That is enough for a server, whose question is "did the
/// client run the filter", not "is this authentic" (only a key holder can
/// answer that, by opening it).
pub fn is_envelope(bytes: &[u8]) -> bool {
    plaintext_len(bytes, bytes.len() as u64).is_some()
}

/// The key epoch an envelope was sealed under, if `bytes` is an envelope.
pub fn epoch_of(bytes: &[u8]) -> Option<u32> {
    if !is_envelope(bytes) {
        return None;
    }
    let e = &bytes[MAGIC.len() + 1..V1_HEADER_LEN];
    Some(u32::from_be_bytes([e[0], e[1], e[2], e[3]]))
}

/// The format version of an envelope's first bytes (`None` without magic).
pub fn version_of(head: &[u8]) -> Option<u8> {
    (has_magic(head) && head.len() > MAGIC.len()).then(|| head[MAGIC.len()])
}

fn chunk_of(head: &[u8]) -> u32 {
    u32::from_be_bytes([head[13], head[14], head[15], head[16]])
}

/// Why an envelope did not open.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OpenError {
    /// The bytes are not an envelope (no magic, or too short).
    NotEnvelope,
    /// An envelope of a format version this code does not know.
    UnsupportedVersion(u8),
    /// Sealed under an epoch the key ring does not hold: either a newer
    /// rotation this clone has not unlocked yet, or a ring the reader was
    /// never given.
    UnknownEpoch(u32),
    /// A tag did not verify: wrong key, or the bytes were changed, cut,
    /// reordered or added to.
    Corrupt,
}

impl std::fmt::Display for OpenError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            OpenError::NotEnvelope => write!(f, "not a gunnar-crypt envelope"),
            OpenError::UnsupportedVersion(v) => {
                write!(
                    f,
                    "gunnar-crypt envelope version {v} is newer than this gunnar"
                )
            }
            OpenError::UnknownEpoch(e) => write!(
                f,
                "sealed under key epoch {e}, which this key ring does not hold \
                 (run `gunnar crypt unlock` after pulling the newest .gunnar-crypt/keys)"
            ),
            OpenError::Corrupt => write!(
                f,
                "the envelope did not verify: wrong key, or the content was altered"
            ),
        }
    }
}

impl std::error::Error for OpenError {}

/// Why a streamed open stopped: the envelope, or the reader/writer.
#[derive(Debug)]
pub enum StreamError {
    /// The envelope did not open. Blocks before the failing one may already
    /// have been written: a caller that must not show unverified output keeps
    /// it until this returns `Ok`, or reports the failure after it (git's
    /// filter protocol has `status=error` after content for exactly this).
    Open(OpenError),
    /// Reading the envelope or writing the plaintext failed.
    Io(std::io::Error),
}

impl std::fmt::Display for StreamError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            StreamError::Open(e) => e.fmt(f),
            StreamError::Io(e) => e.fmt(f),
        }
    }
}

impl std::error::Error for StreamError {}

impl From<std::io::Error> for StreamError {
    fn from(e: std::io::Error) -> Self {
        StreamError::Io(e)
    }
}

impl From<OpenError> for StreamError {
    fn from(e: OpenError) -> Self {
        StreamError::Open(e)
    }
}

/// Read until `buf` is full or the reader ends; the number of bytes read.
#[cfg(feature = "cipher")]
fn read_full(r: &mut impl std::io::Read, buf: &mut [u8]) -> std::io::Result<usize> {
    let mut n = 0;
    while n < buf.len() {
        match r.read(&mut buf[n..]) {
            Ok(0) => break,
            Ok(k) => n += k,
            Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
            Err(e) => return Err(e),
        }
    }
    Ok(n)
}

#[cfg(feature = "cipher")]
mod cipher {
    use std::io::{Read, Write};

    use super::*;
    use crate::keyring::{DataKey, Keyring};
    use aes_siv::aead::KeyInit;
    use aes_siv::siv::Aes256Siv;
    use aes_siv::Tag;
    use hmac::{Hmac, Mac};
    use sha2::Sha256;
    use zeroize::Zeroizing;

    fn siv(key: &DataKey) -> Aes256Siv {
        Aes256Siv::new(key.bytes().into())
    }

    /// The v2 file nonce, computed as the plaintext goes by (one pass, any
    /// split). Feed it exactly the bytes that are then sealed.
    #[derive(Clone)]
    pub struct FileNonce(Hmac<Sha256>);

    impl FileNonce {
        /// A nonce under `key`'s epoch.
        pub fn new(key: &DataKey) -> FileNonce {
            let mut nonce_key = Zeroizing::new([0u8; 32]);
            hkdf::Hkdf::<Sha256>::new(None, key.bytes())
                .expand(NONCE_INFO, &mut nonce_key[..])
                .expect("32 bytes is a valid HKDF-SHA256 length");
            FileNonce(
                <Hmac<Sha256> as hmac::KeyInit>::new_from_slice(&nonce_key[..])
                    .expect("HMAC takes a key of any length"),
            )
        }

        /// More plaintext.
        pub fn update(&mut self, bytes: &[u8]) {
            self.0.update(bytes);
        }

        /// The nonce.
        pub fn finish(self) -> [u8; NONCE_LEN] {
            self.0.finalize().into_bytes().into()
        }
    }

    /// The file nonce of `plaintext` under `key`.
    pub fn file_nonce(key: &DataKey, plaintext: &[u8]) -> [u8; NONCE_LEN] {
        let mut n = FileNonce::new(key);
        n.update(plaintext);
        n.finish()
    }

    fn header_v2(epoch: u32, chunk: u32, nonce: &[u8; NONCE_LEN]) -> [u8; V2_HEADER_LEN] {
        let mut h = [0u8; V2_HEADER_LEN];
        h[..8].copy_from_slice(&MAGIC);
        h[8] = V2;
        h[9..13].copy_from_slice(&epoch.to_be_bytes());
        h[13..17].copy_from_slice(&chunk.to_be_bytes());
        h[17..].copy_from_slice(nonce);
        h
    }

    fn block_ad(header: &[u8; V2_HEADER_LEN], index: u64, last: bool) -> [u8; AD_LEN] {
        let mut ad = [0u8; AD_LEN];
        ad[..V2_HEADER_LEN].copy_from_slice(header);
        ad[V2_HEADER_LEN..V2_HEADER_LEN + 8].copy_from_slice(&index.to_be_bytes());
        ad[AD_LEN - 1] = u8::from(last);
        ad
    }

    /// The first allocation for a block buffer when the input's length is
    /// not known: a small file (or a short envelope that declares a large
    /// chunk) never costs a whole chunk.
    const FIRST_FILL: usize = 64 << 10;

    /// A block buffer: `size` bytes now, zeroed on drop (it holds plaintext).
    fn block_buf(size: usize) -> Zeroizing<Vec<u8>> {
        Zeroizing::new(vec![0u8; size])
    }

    /// Read into `buf[at..]` until it holds `want` bytes or the reader ends,
    /// growing `buf` (doubling, never past `want`; each old buffer is zeroed
    /// as it is dropped) only while the input proves to be that long. The
    /// bytes now in `buf`.
    fn fill(
        r: &mut impl Read,
        buf: &mut Zeroizing<Vec<u8>>,
        mut at: usize,
        want: usize,
    ) -> std::io::Result<usize> {
        loop {
            let end = buf.len().min(want);
            at += read_full(r, &mut buf[at..end])?;
            if at < end || end == want {
                return Ok(at);
            }
            let mut bigger = block_buf((buf.len() * 2).min(want));
            bigger[..at].copy_from_slice(&buf[..at]);
            *buf = bigger;
        }
    }

    /// **Seal a stream** under `key` (v2, [`CHUNK`]-byte blocks), writing the
    /// envelope to `out` as it reads. `nonce` is the [`FileNonce`] of the very
    /// bytes `plaintext` yields: compute it on a first pass (the git filter
    /// does while it spools). Holds one chunk in memory. Returns the bytes
    /// written.
    pub fn seal_stream(
        key: &DataKey,
        nonce: &[u8; NONCE_LEN],
        plaintext: &mut impl Read,
        out: &mut impl Write,
    ) -> std::io::Result<u64> {
        seal_stream_chunked(key, CHUNK, nonce, plaintext, out)
    }

    fn seal_stream_chunked(
        key: &DataKey,
        chunk: u32,
        nonce: &[u8; NONCE_LEN],
        plaintext: &mut impl Read,
        out: &mut impl Write,
    ) -> std::io::Result<u64> {
        assert!(
            chunk > 0 && chunk <= MAX_CHUNK,
            "a chunk size readers accept"
        );
        let header = header_v2(key.epoch(), chunk, nonce);
        out.write_all(&header)?;
        let mut written = V2_HEADER_LEN as u64;
        let mut siv = siv(key);
        let chunk = chunk as usize;
        // [tag | chunk]: as long as the input proves to be, up to one chunk.
        let want = TAG_LEN + chunk;
        let mut buf = block_buf(want.min(TAG_LEN + FIRST_FILL));
        let mut n = fill(plaintext, &mut buf, TAG_LEN, want)? - TAG_LEN;
        let mut index: u64 = 0;
        loop {
            // A full chunk is the last one only if nothing follows it.
            let mut next = [0u8; 1];
            let last = n < chunk || read_full(plaintext, &mut next)? == 0;
            let ad = block_ad(&header, index, last);
            let tag = siv
                .encrypt_inout_detached([&ad[..]], (&mut buf[TAG_LEN..TAG_LEN + n]).into())
                .expect("AES-SIV takes any length and one header");
            buf[..TAG_LEN].copy_from_slice(tag.as_slice());
            out.write_all(&buf[..TAG_LEN + n])?;
            written += (TAG_LEN + n) as u64;
            if last {
                return Ok(written);
            }
            index += 1;
            buf[TAG_LEN] = next[0];
            n = fill(plaintext, &mut buf, TAG_LEN + 1, want)? - TAG_LEN;
        }
    }

    /// Seal `plaintext` under `key` (v2). Deterministic: same key, same
    /// content, same bytes out.
    pub fn seal(key: &DataKey, plaintext: &[u8]) -> Vec<u8> {
        seal_with_chunk(key, CHUNK, plaintext)
    }

    /// [`seal`] with another chunk size: for tests that want many blocks
    /// from a few bytes. gunnar itself always seals with [`CHUNK`].
    #[doc(hidden)]
    pub fn seal_with_chunk(key: &DataKey, chunk: u32, plaintext: &[u8]) -> Vec<u8> {
        let nonce = file_nonce(key, plaintext);
        let mut out = Vec::new();
        seal_into_chunked(key, chunk, &nonce, plaintext, &mut out);
        out
    }

    /// Seal `plaintext` (v2) under `key`, whose [`FileNonce`] is `nonce`,
    /// APPENDED to `out`: each block is encrypted in place in `out`, so no
    /// other buffer holds the file. What the filter does with a file small
    /// enough to stay in memory.
    pub fn seal_into(key: &DataKey, nonce: &[u8; NONCE_LEN], plaintext: &[u8], out: &mut Vec<u8>) {
        seal_into_chunked(key, CHUNK, nonce, plaintext, out)
    }

    fn seal_into_chunked(
        key: &DataKey,
        chunk: u32,
        nonce: &[u8; NONCE_LEN],
        plaintext: &[u8],
        out: &mut Vec<u8>,
    ) {
        assert!(
            chunk > 0 && chunk <= MAX_CHUNK,
            "a chunk size readers accept"
        );
        let header = header_v2(key.epoch(), chunk, nonce);
        let chunk = chunk as usize;
        let blocks = plaintext.len().div_ceil(chunk).max(1);
        // Reserved up front: `out` never moves (and leaves no copy of the
        // plaintext behind) while blocks are encrypted in it.
        out.reserve(V2_HEADER_LEN + blocks * TAG_LEN + plaintext.len());
        out.extend_from_slice(&header);
        let mut siv = siv(key);
        for i in 0..blocks {
            let piece = &plaintext
                [(i * chunk).min(plaintext.len())..((i + 1) * chunk).min(plaintext.len())];
            let at = out.len();
            out.extend_from_slice(&[0u8; TAG_LEN]);
            out.extend_from_slice(piece);
            let ad = block_ad(&header, i as u64, i + 1 == blocks);
            let tag = siv
                .encrypt_inout_detached([&ad[..]], (&mut out[at + TAG_LEN..]).into())
                .expect("AES-SIV takes any length and one header");
            out[at..at + TAG_LEN].copy_from_slice(tag.as_slice());
        }
    }

    /// Seal `plaintext` as a v1 envelope (whole file). Nothing in gunnar
    /// seals v1 any more; this stays for the v1 test vectors and for tests
    /// of the v1 → v2 transition.
    #[doc(hidden)]
    pub fn seal_v1(key: &DataKey, plaintext: &[u8]) -> Vec<u8> {
        let mut h = [0u8; V1_HEADER_LEN];
        h[..8].copy_from_slice(&MAGIC);
        h[8] = V1;
        h[9..].copy_from_slice(&key.epoch().to_be_bytes());
        let ct = siv(key)
            .encrypt([&h[..]], plaintext)
            .expect("AES-SIV takes any length and one header");
        let mut out = Vec::with_capacity(V1_HEADER_LEN + ct.len());
        out.extend_from_slice(&h);
        out.extend_from_slice(&ct);
        out
    }

    /// **Open a stream**: read an envelope (v1 or v2) from `envelope` and
    /// write its plaintext to `out`. A v2 envelope is opened one block at a
    /// time (one chunk in memory); each block is verified before it is
    /// written, and a cut, reordered or extended envelope fails at the block
    /// where it shows — after the blocks before it were written (see
    /// [`StreamError::Open`]). A v1 envelope is read whole (it was sealed
    /// whole). Returns the plaintext bytes written.
    pub fn open_stream(
        ring: &Keyring,
        envelope: &mut impl Read,
        out: &mut impl Write,
    ) -> Result<u64, StreamError> {
        let mut head = [0u8; V2_HEADER_LEN];
        let got = read_full(envelope, &mut head[..MAGIC.len() + 1])?;
        if got < MAGIC.len() + 1 || !has_magic(&head) {
            return Err(OpenError::NotEnvelope.into());
        }
        match head[MAGIC.len()] {
            V1 => {
                let mut all = head[..MAGIC.len() + 1].to_vec();
                envelope.read_to_end(&mut all)?;
                let plain = Zeroizing::new(open_v1(ring, &all)?);
                out.write_all(&plain)?;
                Ok(plain.len() as u64)
            }
            V2 => {
                let got = read_full(envelope, &mut head[MAGIC.len() + 1..])?;
                if got < V2_HEADER_LEN - MAGIC.len() - 1 {
                    return Err(OpenError::NotEnvelope.into());
                }
                open_v2_blocks(ring, &head, envelope, out)
            }
            v => Err(OpenError::UnsupportedVersion(v).into()),
        }
    }

    /// The epoch's key for a header, or why there is none.
    fn key_for<'r>(ring: &'r Keyring, head: &[u8]) -> Result<&'r DataKey, OpenError> {
        let epoch = u32::from_be_bytes([head[9], head[10], head[11], head[12]]);
        ring.get(epoch).ok_or(OpenError::UnknownEpoch(epoch))
    }

    fn open_v2_blocks(
        ring: &Keyring,
        header: &[u8; V2_HEADER_LEN],
        envelope: &mut impl Read,
        out: &mut impl Write,
    ) -> Result<u64, StreamError> {
        let chunk = chunk_of(header);
        if chunk == 0 || chunk > MAX_CHUNK {
            return Err(OpenError::Corrupt.into());
        }
        let key = key_for(ring, header)?;
        let mut siv = siv(key);
        let block = TAG_LEN + chunk as usize;
        // One block at most, and only as much as the envelope proves to have.
        let mut buf = block_buf(block.min(FIRST_FILL));
        let mut n = fill(envelope, &mut buf, 0, block)?;
        let mut index: u64 = 0;
        let mut written: u64 = 0;
        loop {
            if n < TAG_LEN {
                // No block at all, or one cut inside its tag.
                return Err(OpenError::Corrupt.into());
            }
            let mut next = [0u8; 1];
            let last = n < block || read_full(envelope, &mut next)? == 0;
            if last && n == TAG_LEN && index > 0 {
                // An empty last block after full ones: never sealed so.
                return Err(OpenError::Corrupt.into());
            }
            let tag = Tag::try_from(&buf[..TAG_LEN]).expect("16 bytes");
            let ad = block_ad(header, index, last);
            siv.decrypt_inout_detached([&ad[..]], (&mut buf[TAG_LEN..n]).into(), &tag)
                .map_err(|_| OpenError::Corrupt)?;
            out.write_all(&buf[TAG_LEN..n])?;
            written += (n - TAG_LEN) as u64;
            if last {
                return Ok(written);
            }
            index = index.checked_add(1).ok_or(OpenError::Corrupt)?;
            buf[0] = next[0];
            n = fill(envelope, &mut buf, 1, block)?;
        }
    }

    fn open_v1(ring: &Keyring, envelope: &[u8]) -> Result<Vec<u8>, OpenError> {
        if envelope.len() < MIN_LEN {
            return Err(OpenError::NotEnvelope);
        }
        let key = key_for(ring, envelope)?;
        siv(key)
            .decrypt([&envelope[..V1_HEADER_LEN]], &envelope[V1_HEADER_LEN..])
            .map_err(|_| OpenError::Corrupt)
    }

    /// Open an envelope (v1 or v2) in memory with whichever epoch key it
    /// names.
    pub fn open(ring: &Keyring, envelope: &[u8]) -> Result<Vec<u8>, OpenError> {
        let mut out = Vec::new();
        open_into(ring, envelope, &mut out)?;
        Ok(out)
    }

    /// [`open`], APPENDING the plaintext to `out`; each block is decrypted in
    /// place in `out`, which grows by the plaintext's length at most once.
    /// On an error nothing is appended (what a failing block left is zeroed).
    pub fn open_into(ring: &Keyring, envelope: &[u8], out: &mut Vec<u8>) -> Result<(), OpenError> {
        if !has_magic(envelope) || envelope.len() <= MAGIC.len() {
            return Err(OpenError::NotEnvelope);
        }
        match envelope[MAGIC.len()] {
            V1 => {
                let plain = Zeroizing::new(open_v1(ring, envelope)?);
                out.extend_from_slice(&plain);
                Ok(())
            }
            V2 => {
                if envelope.len() < V2_HEADER_LEN {
                    return Err(OpenError::NotEnvelope);
                }
                let header: &[u8; V2_HEADER_LEN] =
                    envelope[..V2_HEADER_LEN].try_into().expect("checked above");
                // The same order of checks as the streamed opener, so both
                // name the same error: the chunk size, the epoch, the shape.
                let chunk = chunk_of(header);
                if chunk == 0 || chunk > MAX_CHUNK {
                    return Err(OpenError::Corrupt);
                }
                let key = key_for(ring, header)?;
                let len =
                    plaintext_len(envelope, envelope.len() as u64).ok_or(OpenError::Corrupt)?;
                let body = &envelope[V2_HEADER_LEN..];
                let block = TAG_LEN + chunk as usize;
                let blocks = body.len().div_ceil(block);
                let start = out.len();
                out.reserve(len as usize);
                let mut siv = siv(key);
                for i in 0..blocks {
                    let b = &body[i * block..((i + 1) * block).min(body.len())];
                    let tag = Tag::try_from(&b[..TAG_LEN]).expect("16 bytes");
                    let at = out.len();
                    out.extend_from_slice(&b[TAG_LEN..]);
                    let ad = block_ad(header, i as u64, i + 1 == blocks);
                    if siv
                        .decrypt_inout_detached([&ad[..]], (&mut out[at..]).into(), &tag)
                        .is_err()
                    {
                        zeroize::Zeroize::zeroize(&mut out[start..]);
                        out.truncate(start);
                        return Err(OpenError::Corrupt);
                    }
                }
                Ok(())
            }
            v => Err(OpenError::UnsupportedVersion(v)),
        }
    }

    /// Would sealing `plaintext_len` bytes under `ring`'s newest key give
    /// exactly the envelope whose header is `head`? True only for a v2
    /// envelope under the newest epoch with gunnar's chunk size: then a
    /// re-seal of unchanged content is the same bytes (determinism), and the
    /// clean filter need not open the old one to keep it.
    pub fn is_current(ring: &Keyring, head: &[u8]) -> bool {
        head.len() >= V2_FIXED_LEN
            && has_magic(head)
            && head[MAGIC.len()] == V2
            && chunk_of(head) == CHUNK
            && u32::from_be_bytes([head[9], head[10], head[11], head[12]]) == ring.newest().epoch()
    }
}

#[cfg(feature = "cipher")]
pub use cipher::{
    file_nonce, is_current, open, open_into, open_stream, seal, seal_into, seal_stream, seal_v1,
    seal_with_chunk, FileNonce,
};