Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

crates/gunnar-crypt/src/envelope.rs

//! 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,
};