#[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()
}
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()
}
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)
);
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"
);
}
}
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);
}
}
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();
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"
);
}
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);
let mut empty = env.clone();
empty.extend_from_slice(&[0u8; TAG_LEN]);
assert!(!envelope::is_envelope(&empty));
assert_eq!(refused(&ring, &empty), OpenError::Corrupt);
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());
}
}
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; 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"
);
assert_eq!(envelope::seal(key, &a), ea);
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); 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);
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));
}