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/tests/chunked.rs

//! **Envelope v2, chunked** — run native (`cargo test`) AND wasm32
//! (`tests/wasm-cross.sh`), like `vectors.rs`: the CLI's filter and the
//! browser console open the same bytes.
//!
//! * The vectors (`tests/vectors/cases-v2.txt`, TEST KEYS ONLY) are fixed
//!   key + plaintext → fixed bytes. Small ones are spelled out in hex; the
//!   multi-MiB ones are a generated plaintext and the SHA-256 of the envelope.
//!   They are written by a second, independent implementation of the format
//!   (`tests/vectors/check-v2.py --write`: Python `cryptography`'s AESSIV,
//!   HKDF and HMAC, no gunnar code), so this suite checks the Rust build
//!   against bytes it did not make.
//! * The STREAM properties: blocks cannot be reordered, a dropped tail or an
//!   appended block does not open, a flipped bit anywhere does not open.
//! * The leak the file nonce closes: an aligned 1 MiB block shared by two
//!   files (or two versions of one) seals differently.
//! * v1 still opens; streamed and in-memory agree for any read size.

#[cfg(target_arch = "wasm32")]
use wasm_bindgen_test::wasm_bindgen_test as test;

use base64::engine::general_purpose::STANDARD as B64;
use base64::Engine as _;
use gunnar_crypt::envelope::{
    self, OpenError, StreamError, CHUNK, MAX_CHUNK, TAG_LEN, V2_HEADER_LEN,
};
use gunnar_crypt::keyring::Keyring;
use sha2::{Digest as _, Sha256};

const KEYRING: &str = include_str!("vectors/keyring.txt");
const CASES_V1: &str = include_str!("vectors/cases.txt");
const CASES_V2: &str = include_str!("vectors/cases-v2.txt");

fn ring() -> Keyring {
    Keyring::parse(KEYRING).unwrap()
}

fn hex(b: &[u8]) -> String {
    b.iter().map(|x| format!("{x:02x}")).collect()
}

fn unhex(s: &str) -> Vec<u8> {
    (0..s.len())
        .step_by(2)
        .map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
        .collect()
}

/// The plaintext of a generated case: deterministic, and no two aligned
/// blocks of it are equal.
fn generated(len: usize) -> Vec<u8> {
    (0..len)
        .map(|i| ((i as u32).wrapping_mul(2_654_435_761) >> 24) as u8 ^ (i >> 20) as u8)
        .collect()
}

/// What a v2 case expects of the sealed bytes.
enum Expect {
    Bytes(Vec<u8>),
    Digest { len: usize, sha256: String },
}

struct Case {
    name: String,
    epoch: u32,
    chunk: u32,
    plain: Vec<u8>,
    expect: Expect,
}

fn cases_v2() -> Vec<Case> {
    CASES_V2
        .lines()
        .filter(|l| !l.trim().is_empty() && !l.starts_with('#'))
        .map(|l| {
            let f: Vec<&str> = l.split(' ').collect();
            let (kind, name, epoch, chunk) = (f[0], f[1], f[2], f[3]);
            let (plain, expect) = match kind {
                "hex" => {
                    let plain = if f[4] == "-" {
                        Vec::new()
                    } else {
                        B64.decode(f[4]).unwrap()
                    };
                    (plain, Expect::Bytes(unhex(f[5])))
                }
                "gen" => {
                    let (len, sha) = f[5].split_once(':').unwrap();
                    (
                        generated(f[4].parse().unwrap()),
                        Expect::Digest {
                            len: len.parse().unwrap(),
                            sha256: sha.to_string(),
                        },
                    )
                }
                other => panic!("unknown case kind {other}"),
            };
            Case {
                name: name.to_string(),
                epoch: epoch.parse().unwrap(),
                chunk: chunk.parse().unwrap(),
                plain,
                expect,
            }
        })
        .collect()
}

fn seal_case(ring: &Keyring, c: &Case) -> Vec<u8> {
    envelope::seal_with_chunk(ring.get(c.epoch).unwrap(), c.chunk, &c.plain)
}

#[test]
fn sealing_v2_on_this_target_gives_the_vector_bytes() {
    let ring = ring();
    let cases = cases_v2();
    assert!(cases.len() >= 10, "the vector file is read");
    for c in &cases {
        let sealed = seal_case(&ring, c);
        match &c.expect {
            Expect::Bytes(b) => assert_eq!(hex(&sealed), hex(b), "case {}", c.name),
            Expect::Digest { len, sha256 } => {
                assert_eq!(sealed.len(), *len, "case {}", c.name);
                assert_eq!(&hex(&Sha256::digest(&sealed)), sha256, "case {}", c.name);
            }
        }
        if c.chunk == CHUNK {
            assert_eq!(envelope::seal(ring.get(c.epoch).unwrap(), &c.plain), sealed);
        }
    }
}

#[test]
fn the_v2_vector_envelopes_open_on_this_target() {
    let ring = ring();
    for c in cases_v2() {
        let env = match c.expect {
            Expect::Bytes(ref b) => b.clone(),
            Expect::Digest { .. } => seal_case(&ring, &c),
        };
        assert!(envelope::is_envelope(&env), "case {}", c.name);
        assert_eq!(envelope::epoch_of(&env), Some(c.epoch), "case {}", c.name);
        assert_eq!(
            envelope::plaintext_len(&env, env.len() as u64),
            Some(c.plain.len() as u64),
            "case {}",
            c.name
        );
        assert_eq!(
            envelope::open(&ring, &env).unwrap(),
            c.plain,
            "case {}",
            c.name
        );
        let mut out = Vec::new();
        envelope::open_stream(&ring, &mut &env[..], &mut out).unwrap();
        assert_eq!(out, c.plain, "case {} streamed", c.name);
    }
}

#[test]
fn v1_envelopes_still_open_in_memory_and_streamed() {
    let ring = ring();
    for l in CASES_V1
        .lines()
        .filter(|l| !l.is_empty() && !l.starts_with('#'))
    {
        let mut p = l.split(' ');
        let name = p.next().unwrap();
        let plain = B64.decode(p.next().unwrap()).unwrap();
        let env = unhex(p.next().unwrap());
        assert_eq!(envelope::version_of(&env), Some(envelope::V1), "{name}");
        assert_eq!(envelope::open(&ring, &env).unwrap(), plain, "{name}");
        let mut out = Vec::new();
        envelope::open_stream(&ring, &mut &env[..], &mut out).unwrap();
        assert_eq!(out, plain, "{name} streamed");
        assert_eq!(
            envelope::plaintext_len(&env, env.len() as u64),
            Some(plain.len() as u64)
        );
        // The deterministic v1 seal is still the v1 bytes (the vectors'
        // own writer; nothing in gunnar seals v1 any more).
        let epoch = envelope::epoch_of(&env).unwrap();
        assert_eq!(envelope::seal_v1(ring.get(epoch).unwrap(), &plain), env);
    }
}

#[test]
fn new_seals_are_v2_with_1_mib_chunks() {
    let ring = ring();
    let e = envelope::seal(ring.newest(), b"x");
    assert_eq!(envelope::version_of(&e), Some(envelope::V2));
    assert_eq!(envelope::VERSION, envelope::V2);
    assert_eq!(CHUNK, 1 << 20);
    assert_eq!(&e[13..17], &[0x00, 0x10, 0x00, 0x00]);
}

#[test]
fn overhead_is_49_bytes_plus_16_per_started_mib() {
    let ring = ring();
    let mib = CHUNK as usize;
    for (len, blocks) in [
        (0usize, 1usize),
        (1, 1),
        (mib - 1, 1),
        (mib, 1),
        (mib + 1, 2),
        (2 * mib, 2),
        (2 * mib + 7, 3),
    ] {
        let e = envelope::seal(ring.newest(), &generated(len));
        assert_eq!(
            e.len(),
            V2_HEADER_LEN + blocks * TAG_LEN + len,
            "{len} bytes"
        );
    }
}

/// A reader that hands out at most `step` bytes per call, varying.
struct Dribble<'a> {
    data: &'a [u8],
    n: usize,
}

impl std::io::Read for Dribble<'_> {
    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
        self.n = self.n % 7 + 1;
        let k = self.n.min(buf.len()).min(self.data.len());
        buf[..k].copy_from_slice(&self.data[..k]);
        self.data = &self.data[k..];
        Ok(k)
    }
}

#[test]
fn streamed_and_in_memory_agree_for_any_read_size() {
    let ring = ring();
    let key = ring.newest();
    let mib = CHUNK as usize;
    for len in [0, 5, mib, mib + 3] {
        let plain = generated(len);
        let whole = envelope::seal(key, &plain);
        let mut nonce = envelope::FileNonce::new(key);
        for piece in plain.chunks(4097) {
            nonce.update(piece);
        }
        let nonce = nonce.finish();
        assert_eq!(nonce, envelope::file_nonce(key, &plain));
        let mut streamed = Vec::new();
        let n = envelope::seal_stream(
            key,
            &nonce,
            &mut Dribble { data: &plain, n: 0 },
            &mut streamed,
        )
        .unwrap();
        assert_eq!(n as usize, whole.len());
        assert_eq!(streamed, whole, "{len} bytes");
        let mut opened = Vec::new();
        let n =
            envelope::open_stream(&ring, &mut Dribble { data: &whole, n: 0 }, &mut opened).unwrap();
        assert_eq!(n as usize, len);
        assert_eq!(opened, plain);
    }
}

/// A small multi-block envelope: 16-byte chunks, `len` bytes of plaintext.
fn small(ring: &Keyring, len: usize) -> Vec<u8> {
    envelope::seal_with_chunk(ring.newest(), 16, &generated(len))
}

fn block(env: &[u8], i: usize) -> std::ops::Range<usize> {
    let start = V2_HEADER_LEN + i * (TAG_LEN + 16);
    start..(start + TAG_LEN + 16).min(env.len())
}

fn refused(ring: &Keyring, env: &[u8]) -> OpenError {
    let mut sink = Vec::new();
    let streamed = envelope::open_stream(ring, &mut &env[..], &mut sink);
    let mem = envelope::open(ring, env).expect_err("must not open");
    match streamed {
        Err(StreamError::Open(e)) => assert_eq!(e, mem, "streamed and in memory agree"),
        other => panic!("streamed open must refuse too: {other:?}"),
    }
    mem
}

#[test]
fn blocks_cannot_be_reordered() {
    let ring = ring();
    let env = small(&ring, 64 + 5);
    assert_eq!(envelope::open(&ring, &env).unwrap(), generated(69));
    let mut swapped = env.clone();
    let (a, b) = (block(&env, 0), block(&env, 1));
    swapped[a.clone()].copy_from_slice(&env[b.clone()]);
    swapped[b].copy_from_slice(&env[a]);
    assert!(envelope::is_envelope(&swapped), "the shape is unchanged");
    assert_eq!(refused(&ring, &swapped), OpenError::Corrupt);
}

#[test]
fn a_dropped_tail_does_not_open() {
    let ring = ring();
    // Cut at a block boundary: the shape is still well formed, so only the
    // last flag can tell.
    for len in [64usize, 69] {
        let env = small(&ring, len);
        let blocks = len.div_ceil(16);
        for keep in 1..blocks {
            let cut = &env[..block(&env, keep).start];
            assert!(envelope::is_envelope(cut), "{len}: {keep} blocks");
            assert_eq!(
                refused(&ring, cut),
                OpenError::Corrupt,
                "{len}: {keep} blocks"
            );
        }
        // No block at all.
        assert!(envelope::open(&ring, &env[..V2_HEADER_LEN]).is_err());
    }
}

#[test]
fn an_appended_block_does_not_open() {
    let ring = ring();
    let env = small(&ring, 64);
    let mut longer = env.clone();
    longer.extend_from_slice(&env[block(&env, 0)]);
    assert_eq!(refused(&ring, &longer), OpenError::Corrupt);
    // An empty block after a full last one: not even the right shape.
    let mut empty = env.clone();
    empty.extend_from_slice(&[0u8; TAG_LEN]);
    assert!(!envelope::is_envelope(&empty));
    assert_eq!(refused(&ring, &empty), OpenError::Corrupt);
    // A block from ANOTHER file at the same index: the file nonce differs.
    let other = envelope::seal_with_chunk(ring.newest(), 16, &[7u8; 64]);
    let mut spliced = env.clone();
    spliced[block(&env, 1)].copy_from_slice(&other[block(&other, 1)]);
    assert_eq!(refused(&ring, &spliced), OpenError::Corrupt);
}

#[test]
fn every_flipped_bit_is_refused() {
    let ring = ring();
    let env = small(&ring, 50);
    for i in 0..env.len() {
        for bit in 0..8 {
            let mut e = env.clone();
            e[i] ^= 1 << bit;
            assert!(
                envelope::open(&ring, &e).is_err(),
                "byte {i} bit {bit} flipped and it still opened"
            );
            let mut sink = Vec::new();
            assert!(envelope::open_stream(&ring, &mut &e[..], &mut sink).is_err());
        }
    }
    // A real 1 MiB chunk: a bit in the header, the nonce, each tag, each body.
    let big = envelope::seal(ring.newest(), &generated(CHUNK as usize + 100));
    let second = V2_HEADER_LEN + TAG_LEN + CHUNK as usize;
    for i in [
        12,
        20,
        V2_HEADER_LEN,
        V2_HEADER_LEN + TAG_LEN + 1000,
        second - 1,
        second + 3,
        big.len() - 1,
    ] {
        let mut e = big.clone();
        e[i] ^= 0x40;
        assert!(envelope::open(&ring, &e).is_err(), "byte {i}");
    }
}

#[test]
fn the_epoch_and_the_chunk_size_are_authenticated() {
    let ring = ring();
    let env = envelope::seal(ring.get(1).unwrap(), b"old content");
    let mut e = env.clone();
    e[12] = 2; // claim epoch 2, which the ring holds
    assert_eq!(refused(&ring, &e), OpenError::Corrupt);
    let mut e = env.clone();
    e[13..17].copy_from_slice(&(CHUNK / 2).to_be_bytes());
    assert_eq!(refused(&ring, &e), OpenError::Corrupt);
    let removed = ring.truncated(1);
    let new = envelope::seal(ring.get(2).unwrap(), b"new");
    assert_eq!(refused(&removed, &new), OpenError::UnknownEpoch(2));
}

#[test]
fn the_same_aligned_block_in_two_files_seals_differently() {
    let ring = ring();
    let key = ring.newest();
    let mib = CHUNK as usize;
    let shared = generated(mib);
    let mut a = shared.clone();
    a.extend_from_slice(b"version a");
    let mut b = shared.clone();
    b.extend_from_slice(b"version b");
    let (ea, eb) = (envelope::seal(key, &a), envelope::seal(key, &b));
    let first = V2_HEADER_LEN..V2_HEADER_LEN + TAG_LEN + mib;
    assert_ne!(
        ea[first.clone()],
        eb[first],
        "an equal first MiB must not show: the file nonce is in every block's AD"
    );
    // Equal files are still equal envelopes (git needs it; v1 showed the same).
    assert_eq!(envelope::seal(key, &a), ea);
    // An equal block twice in ONE file: the index tells them apart.
    let mut twice = shared.clone();
    twice.extend_from_slice(&shared);
    let e = envelope::seal(key, &twice);
    let (b0, b1) = (
        V2_HEADER_LEN..V2_HEADER_LEN + TAG_LEN + mib,
        V2_HEADER_LEN + TAG_LEN + mib..e.len(),
    );
    assert_ne!(e[b0], e[b1]);
}

#[test]
fn the_format_check_knows_v2_and_refuses_impossible_shapes() {
    let ring = ring();
    let env = small(&ring, 40); // blocks of 16, 16, 8
    assert!(envelope::is_envelope(&env));
    assert_eq!(envelope::plaintext_len(&env, env.len() as u64), Some(40));
    for cut in 0..env.len() {
        let c = &env[..cut];
        let body = cut.saturating_sub(V2_HEADER_LEN);
        let r = body % (TAG_LEN + 16);
        // Whole blocks, or the one empty block of an empty file.
        let whole_blocks =
            cut >= V2_HEADER_LEN + TAG_LEN && (r == 0 || r > TAG_LEN || body == TAG_LEN);
        assert_eq!(envelope::is_envelope(c), whole_blocks, "cut at {cut}");
        assert!(envelope::open(&ring, c).is_err(), "cut at {cut}");
    }
    for bad_chunk in [0u32, MAX_CHUNK + 1, u32::MAX] {
        let mut e = env.clone();
        e[13..17].copy_from_slice(&bad_chunk.to_be_bytes());
        assert!(!envelope::is_envelope(&e), "chunk {bad_chunk}");
        assert_eq!(refused(&ring, &e), OpenError::Corrupt, "chunk {bad_chunk}");
    }
    let mut e = env.clone();
    e[8] = 3;
    assert!(!envelope::is_envelope(&e));
    assert_eq!(refused(&ring, &e), OpenError::UnsupportedVersion(3));
}