Skip to main content

crypt_io/stream/
frame.rs

1//! Wire format for streamed AEAD.
2//!
3//! # Header (24 bytes)
4//!
5//! ```text
6//!   offset | size | field
7//!   -------+------+--------------------------------------------------
8//!    0..8  |  8   | magic = b"\x89CRYPTIO"
9//!    8     |  1   | version = 0x01
10//!    9     |  1   | algorithm (0x00 ChaCha20-Poly1305, 0x01 AES-256-GCM)
11//!    10    |  1   | chunk_size_log2 (default 16 = 64 KiB chunks)
12//!    11..16|  5   | reserved (all zero)
13//!    16..23|  7   | nonce_prefix (random per stream)
14//!    23    |  1   | reserved (zero)
15//! ```
16//!
17//! The full 24-byte header is fed as Additional Authenticated Data (AAD)
18//! to every chunk. Tampering with the algorithm byte, chunk-size byte,
19//! or nonce prefix produces an authentication failure on the first
20//! chunk.
21//!
22//! # Per-chunk nonce (12 bytes) — STREAM construction
23//!
24//! ```text
25//!   offset | size | field
26//!   -------+------+--------------------------------------------------
27//!    0..7  |  7   | nonce_prefix (copied from header)
28//!    7..11 |  4   | counter (u32 big-endian, starts at 0)
29//!    11    |  1   | last_flag (0x00 for non-final, 0x01 for final)
30//! ```
31//!
32//! The `last_flag` bit is what defeats truncation attacks. A non-final
33//! chunk and the final chunk use different nonces — even if an attacker
34//! reads ahead and tries to verify a non-final chunk as final (or vice
35//! versa), the GHASH/Poly1305 tag will not match.
36//!
37//! # Stream body
38//!
39//! ```text
40//!   [header (24 B)]
41//!   [chunk_0 (chunk_size + 16 B)]   ── non-final, last_flag = 0
42//!   [chunk_1 (chunk_size + 16 B)]   ── non-final, last_flag = 0
43//!   ...
44//!   [chunk_N-1 (chunk_size + 16 B)] ── non-final, last_flag = 0
45//!   [chunk_N (< chunk_size + 16 B)] ── final, last_flag = 1
46//! ```
47//!
48//! The final chunk is **always** strictly smaller than `chunk_size + 16`
49//! bytes. If the encryptor's internal buffer happens to hold exactly
50//! `chunk_size` bytes when `finalize` is called, it emits the buffered
51//! data as a non-final chunk and then a zero-byte final chunk (16 bytes
52//! total — just the tag). This makes EOF detection unambiguous: short
53//! read → final chunk; full read → non-final.
54
55use crate::aead::Algorithm;
56use crate::error::{Error, Result};
57
58/// Magic prefix identifying a `crypt-io` stream. 8 bytes. The high bit
59/// in the first byte (0x89) helps detect binary-as-text mis-handling.
60pub const MAGIC: &[u8; 8] = b"\x89CRYPTIO";
61
62/// Header size in bytes.
63pub const HEADER_LEN: usize = 24;
64
65/// Per-chunk nonce size in bytes (matches both shipped AEADs).
66pub const NONCE_LEN: usize = 12;
67
68/// Length of the random nonce prefix carried in the header.
69pub const NONCE_PREFIX_LEN: usize = 7;
70
71/// AEAD tag size (matches both shipped AEADs).
72pub const TAG_LEN: usize = 16;
73
74/// Format version.
75pub const VERSION: u8 = 0x01;
76
77/// Default chunk-size log2 — 16 means 64 KiB chunks.
78pub const DEFAULT_CHUNK_SIZE_LOG2: u8 = 16;
79
80/// Minimum chunk-size log2 — 10 (1 KiB). Below this the per-chunk
81/// AEAD overhead dominates.
82pub const MIN_CHUNK_SIZE_LOG2: u8 = 10;
83
84/// Maximum chunk-size log2 — 24 (16 MiB). Above this the buffering
85/// memory cost gets uncomfortable for streaming workflows.
86pub const MAX_CHUNK_SIZE_LOG2: u8 = 24;
87
88pub(super) const ALG_CHACHA20_POLY1305: u8 = 0x00;
89pub(super) const ALG_AES_256_GCM: u8 = 0x01;
90
91/// Encode `algorithm` as the on-the-wire byte.
92pub(super) fn encode_algorithm(algorithm: Algorithm) -> u8 {
93    match algorithm {
94        Algorithm::ChaCha20Poly1305 => ALG_CHACHA20_POLY1305,
95        Algorithm::Aes256Gcm => ALG_AES_256_GCM,
96    }
97}
98
99/// Decode the algorithm byte from the wire.
100pub(super) fn decode_algorithm(byte: u8) -> Result<Algorithm> {
101    match byte {
102        ALG_CHACHA20_POLY1305 => Ok(Algorithm::ChaCha20Poly1305),
103        ALG_AES_256_GCM => Ok(Algorithm::Aes256Gcm),
104        _ => Err(Error::InvalidCiphertext(alloc::format!(
105            "unknown algorithm byte: 0x{byte:02x}"
106        ))),
107    }
108}
109
110/// Build a header for a fresh stream. `nonce_prefix` must be 7 random bytes.
111#[must_use]
112pub(super) fn build_header(
113    algorithm: Algorithm,
114    chunk_size_log2: u8,
115    nonce_prefix: &[u8; NONCE_PREFIX_LEN],
116) -> [u8; HEADER_LEN] {
117    let mut h = [0u8; HEADER_LEN];
118    h[0..8].copy_from_slice(MAGIC);
119    h[8] = VERSION;
120    h[9] = encode_algorithm(algorithm);
121    h[10] = chunk_size_log2;
122    // bytes 11..16: reserved zero (already)
123    h[16..23].copy_from_slice(nonce_prefix);
124    // byte 23: reserved zero (already)
125    h
126}
127
128/// Parsed view of a header.
129#[derive(Debug, Clone, Copy)]
130pub(super) struct ParsedHeader {
131    pub algorithm: Algorithm,
132    pub chunk_size_log2: u8,
133    pub nonce_prefix: [u8; NONCE_PREFIX_LEN],
134    /// Original 24 header bytes — used as AAD for every chunk.
135    pub raw: [u8; HEADER_LEN],
136}
137
138/// Parse and validate a 24-byte header.
139pub(super) fn parse_header(bytes: &[u8]) -> Result<ParsedHeader> {
140    if bytes.len() < HEADER_LEN {
141        return Err(Error::InvalidCiphertext(alloc::format!(
142            "stream header too short ({} bytes, need {HEADER_LEN})",
143            bytes.len()
144        )));
145    }
146    let raw_slice = &bytes[..HEADER_LEN];
147
148    if &raw_slice[0..8] != MAGIC {
149        return Err(Error::InvalidCiphertext(alloc::string::String::from(
150            "stream magic mismatch (not a crypt-io stream)",
151        )));
152    }
153    if raw_slice[8] != VERSION {
154        return Err(Error::InvalidCiphertext(alloc::format!(
155            "unsupported stream version: 0x{:02x} (this build understands 0x{VERSION:02x})",
156            raw_slice[8],
157        )));
158    }
159    let algorithm = decode_algorithm(raw_slice[9])?;
160    let chunk_size_log2 = raw_slice[10];
161    if !(MIN_CHUNK_SIZE_LOG2..=MAX_CHUNK_SIZE_LOG2).contains(&chunk_size_log2) {
162        return Err(Error::InvalidCiphertext(alloc::format!(
163            "chunk_size_log2 out of range: {chunk_size_log2}"
164        )));
165    }
166    let mut nonce_prefix = [0u8; NONCE_PREFIX_LEN];
167    nonce_prefix.copy_from_slice(&raw_slice[16..23]);
168
169    let mut raw = [0u8; HEADER_LEN];
170    raw.copy_from_slice(raw_slice);
171
172    Ok(ParsedHeader {
173        algorithm,
174        chunk_size_log2,
175        nonce_prefix,
176        raw,
177    })
178}
179
180/// Build the 12-byte per-chunk nonce from the prefix, counter, and
181/// last-chunk flag.
182#[must_use]
183pub(super) fn build_nonce(
184    nonce_prefix: &[u8; NONCE_PREFIX_LEN],
185    counter: u32,
186    is_final: bool,
187) -> [u8; NONCE_LEN] {
188    let mut n = [0u8; NONCE_LEN];
189    n[0..7].copy_from_slice(nonce_prefix);
190    n[7..11].copy_from_slice(&counter.to_be_bytes());
191    n[11] = u8::from(is_final);
192    n
193}
194
195/// Compute the chunk size in bytes from `chunk_size_log2`.
196#[must_use]
197pub(super) fn chunk_size_from_log2(log2: u8) -> usize {
198    1usize << log2
199}
200
201#[cfg(test)]
202#[allow(clippy::unwrap_used, clippy::expect_used)]
203mod tests {
204    use super::*;
205
206    #[test]
207    fn header_round_trip_chacha() {
208        let prefix = [0xaau8; NONCE_PREFIX_LEN];
209        let h = build_header(Algorithm::ChaCha20Poly1305, 16, &prefix);
210        let p = parse_header(&h).unwrap();
211        assert_eq!(p.algorithm, Algorithm::ChaCha20Poly1305);
212        assert_eq!(p.chunk_size_log2, 16);
213        assert_eq!(p.nonce_prefix, prefix);
214        assert_eq!(p.raw, h);
215    }
216
217    #[test]
218    fn header_round_trip_aes() {
219        let prefix = [0xbbu8; NONCE_PREFIX_LEN];
220        let h = build_header(Algorithm::Aes256Gcm, 12, &prefix);
221        let p = parse_header(&h).unwrap();
222        assert_eq!(p.algorithm, Algorithm::Aes256Gcm);
223        assert_eq!(p.chunk_size_log2, 12);
224        assert_eq!(p.nonce_prefix, prefix);
225    }
226
227    #[test]
228    fn header_rejects_wrong_magic() {
229        let mut h = build_header(Algorithm::ChaCha20Poly1305, 16, &[0u8; 7]);
230        h[0] = b'X';
231        let err = parse_header(&h).unwrap_err();
232        assert!(matches!(err, Error::InvalidCiphertext(_)));
233    }
234
235    #[test]
236    fn header_rejects_unknown_version() {
237        let mut h = build_header(Algorithm::ChaCha20Poly1305, 16, &[0u8; 7]);
238        h[8] = 0xff;
239        let err = parse_header(&h).unwrap_err();
240        assert!(matches!(err, Error::InvalidCiphertext(_)));
241    }
242
243    #[test]
244    fn header_rejects_unknown_algorithm() {
245        let mut h = build_header(Algorithm::ChaCha20Poly1305, 16, &[0u8; 7]);
246        h[9] = 0x42;
247        let err = parse_header(&h).unwrap_err();
248        assert!(matches!(err, Error::InvalidCiphertext(_)));
249    }
250
251    #[test]
252    fn header_rejects_out_of_range_chunk_size_log2() {
253        for bad in [0u8, 9, 25, 64, 255] {
254            let mut h = build_header(Algorithm::ChaCha20Poly1305, 16, &[0u8; 7]);
255            h[10] = bad;
256            let err = parse_header(&h).unwrap_err();
257            assert!(matches!(err, Error::InvalidCiphertext(_)), "bad={bad}");
258        }
259    }
260
261    #[test]
262    fn header_rejects_too_short() {
263        let err = parse_header(&[0u8; HEADER_LEN - 1]).unwrap_err();
264        assert!(matches!(err, Error::InvalidCiphertext(_)));
265    }
266
267    #[test]
268    fn nonce_distinct_per_counter_and_flag() {
269        let prefix = [0xccu8; NONCE_PREFIX_LEN];
270        let n0 = build_nonce(&prefix, 0, false);
271        let n1 = build_nonce(&prefix, 1, false);
272        let n0_final = build_nonce(&prefix, 0, true);
273        assert_ne!(n0, n1);
274        assert_ne!(n0, n0_final);
275        assert_ne!(n1, n0_final);
276        // Prefix preserved
277        assert_eq!(&n0[..7], &prefix);
278        assert_eq!(n0[7..11], 0u32.to_be_bytes());
279        assert_eq!(n0[11], 0);
280        assert_eq!(n0_final[11], 1);
281    }
282
283    #[test]
284    fn chunk_size_from_log2_matches_pow2() {
285        assert_eq!(chunk_size_from_log2(10), 1024);
286        assert_eq!(chunk_size_from_log2(16), 65_536);
287        assert_eq!(chunk_size_from_log2(20), 1_048_576);
288    }
289}