Skip to content

Commit 9607f5b

Browse files
committed
♻️ Replace GCM header offset arithmetic with StreamHeader newtype
CipherContext was an untagged union whose iv/key meaning depended on the mode field, with the 43-byte stream header layout duplicated across the write-side builder, its re-parser, and the read side. StreamHeader now owns the layout (to_bytes/try_from_bytes, including the segment size range check), and CipherPayload makes the block/GCM distinction a tagged enum, removing gcm_stream_params and its panicking slice expects.
1 parent 954aec0 commit 9607f5b

6 files changed

Lines changed: 199 additions & 134 deletions

File tree

lib/src/archive/write.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -379,7 +379,7 @@ impl<W: Write> Archive<W> {
379379
(ChunkType::SHED, header.to_bytes()).write_chunk_in(&mut self.inner)?;
380380
if let Some(WriteCipher { context: c, .. }) = &context.cipher {
381381
(ChunkType::PHSF, c.phsf.as_bytes()).write_chunk_in(&mut self.inner)?;
382-
(ChunkType::SDAT, c.iv.as_slice()).write_chunk_in(&mut self.inner)?;
382+
(ChunkType::SDAT, c.prefix_bytes().as_slice()).write_chunk_in(&mut self.inner)?;
383383
}
384384
self.inner.flush()?;
385385
let max_chunk_size = self.max_chunk_size;
@@ -593,7 +593,7 @@ where
593593
let context = get_writer_context(option, &header.to_bytes(), StreamKind::PerEntry)?;
594594
if let Some(WriteCipher { context: c, .. }) = &context.cipher {
595595
(ChunkType::PHSF, c.phsf.as_bytes()).write_chunk_in(inner)?;
596-
(ChunkType::FDAT, &c.iv[..]).write_chunk_in(inner)?;
596+
(ChunkType::FDAT, c.prefix_bytes().as_slice()).write_chunk_in(inner)?;
597597
}
598598
let inner = {
599599
let writer = ChunkStreamWriter::new(ChunkType::FDAT, inner, max_chunk_size);

lib/src/cipher.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ mod stream;
77

88
use crate::io::TryIntoInner;
99
pub(crate) use aead::{
10-
DEFAULT_SEGMENT_SIZE, MAX_SEGMENT_SIZE, STREAM_HEADER_LEN, StreamKind, derive_stream_key,
10+
DEFAULT_SEGMENT_SIZE, STREAM_HEADER_LEN, StreamHeader, StreamKind, derive_stream_key,
1111
};
1212
#[cfg(test)]
1313
pub(crate) use aead::{GCM_TAG_LEN, segment_nonce};

lib/src/cipher/aead.rs

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
//! `PHSF` bytes; each segment's 96-bit nonce is derived from the stream's nonce
77
//! prefix, a segment counter, and a final-segment marker.
88
9+
use crate::error::AeadError;
910
use hkdf::Hkdf;
1011
use sha2::{Digest, Sha256};
1112

@@ -15,6 +16,36 @@ pub(crate) const MAX_SEGMENT_SIZE: u32 = 67_108_864; // 64 MiB
1516
pub(crate) const DEFAULT_SEGMENT_SIZE: u32 = 1_048_576; // 1 MiB
1617
const DOMAIN_TAG: &[u8; 13] = b"PNA-STREAM-v1";
1718

19+
/// On-wire GCM stream header: `salt(32) || nonce_prefix(7) || segment_size(u32 BE)`.
20+
#[derive(Debug)]
21+
pub(crate) struct StreamHeader {
22+
pub(crate) salt: [u8; 32],
23+
pub(crate) nonce_prefix: [u8; 7],
24+
pub(crate) segment_size: u32,
25+
}
26+
27+
impl StreamHeader {
28+
pub(crate) fn to_bytes(&self) -> [u8; STREAM_HEADER_LEN] {
29+
let mut bytes = [0u8; STREAM_HEADER_LEN];
30+
bytes[..32].copy_from_slice(&self.salt);
31+
bytes[32..39].copy_from_slice(&self.nonce_prefix);
32+
bytes[39..43].copy_from_slice(&self.segment_size.to_be_bytes());
33+
bytes
34+
}
35+
36+
pub(crate) fn try_from_bytes(bytes: &[u8; STREAM_HEADER_LEN]) -> Result<Self, AeadError> {
37+
let segment_size = u32::from_be_bytes(bytes[39..43].try_into().unwrap());
38+
if segment_size == 0 || segment_size > MAX_SEGMENT_SIZE {
39+
return Err(AeadError::Malformed("segment size out of range"));
40+
}
41+
Ok(Self {
42+
salt: bytes[..32].try_into().unwrap(),
43+
nonce_prefix: bytes[32..39].try_into().unwrap(),
44+
segment_size,
45+
})
46+
}
47+
}
48+
1849
#[derive(Copy, Clone, Debug)]
1950
pub(crate) enum StreamKind {
2051
PerEntry,
@@ -94,6 +125,67 @@ mod tests {
94125
0xc5, 0xbf, 0x34, 0x00, 0x72, 0x08, 0xd5, 0xb8, 0x87, 0x18, 0x58, 0x65,
95126
];
96127

128+
#[test]
129+
fn stream_header_roundtrips_through_bytes() {
130+
let header = StreamHeader {
131+
salt: [0xA5; 32],
132+
nonce_prefix: [0x5A; 7],
133+
segment_size: 0x01020304,
134+
};
135+
let bytes = header.to_bytes();
136+
assert_eq!(bytes[..32], [0xA5; 32]);
137+
assert_eq!(bytes[32..39], [0x5A; 7]);
138+
assert_eq!(bytes[39..43], [0x01, 0x02, 0x03, 0x04]);
139+
let parsed = StreamHeader::try_from_bytes(&bytes).unwrap();
140+
assert_eq!(parsed.salt, header.salt);
141+
assert_eq!(parsed.nonce_prefix, header.nonce_prefix);
142+
assert_eq!(parsed.segment_size, header.segment_size);
143+
}
144+
145+
#[test]
146+
fn stream_header_rejects_zero_segment_size() {
147+
let bytes = StreamHeader {
148+
salt: [0; 32],
149+
nonce_prefix: [0; 7],
150+
segment_size: 0,
151+
}
152+
.to_bytes();
153+
assert!(matches!(
154+
StreamHeader::try_from_bytes(&bytes),
155+
Err(AeadError::Malformed(_))
156+
));
157+
}
158+
159+
#[test]
160+
fn stream_header_rejects_oversized_segment_size() {
161+
let bytes = StreamHeader {
162+
salt: [0; 32],
163+
nonce_prefix: [0; 7],
164+
segment_size: MAX_SEGMENT_SIZE + 1,
165+
}
166+
.to_bytes();
167+
assert!(matches!(
168+
StreamHeader::try_from_bytes(&bytes),
169+
Err(AeadError::Malformed(_))
170+
));
171+
}
172+
173+
#[test]
174+
fn stream_header_accepts_boundary_segment_sizes() {
175+
for segment_size in [1, MAX_SEGMENT_SIZE] {
176+
let bytes = StreamHeader {
177+
salt: [0; 32],
178+
nonce_prefix: [0; 7],
179+
segment_size,
180+
}
181+
.to_bytes();
182+
assert_eq!(
183+
StreamHeader::try_from_bytes(&bytes).unwrap().segment_size,
184+
segment_size
185+
);
186+
}
187+
}
188+
97189
#[test]
98190
fn hkdf_sha256_rfc5869_test_case_1() {
99191
let ikm = [0x0bu8; 22];

lib/src/entry/builder.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,7 @@ impl EntryBuilder {
216216
let writer = get_writer(FlattenWriter::new(), &context)?;
217217
let (iv, phsf) = match context.cipher {
218218
None => (None, None),
219-
Some(WriteCipher { context: c, .. }) => (Some(c.iv), Some(c.phsf)),
219+
Some(WriteCipher { context: c, .. }) => (Some(c.prefix_bytes()), Some(c.phsf)),
220220
};
221221
Ok(Self {
222222
data: Some(writer),
@@ -234,7 +234,7 @@ impl EntryBuilder {
234234
writer.write_all(source.as_bytes())?;
235235
let (iv, phsf) = match context.cipher {
236236
None => (None, None),
237-
Some(WriteCipher { context: c, .. }) => (Some(c.iv), Some(c.phsf)),
237+
Some(WriteCipher { context: c, .. }) => (Some(c.prefix_bytes()), Some(c.phsf)),
238238
};
239239
Ok(Self {
240240
data: Some(writer),
@@ -595,7 +595,7 @@ impl SolidEntryBuilder {
595595
let writer = get_writer(FlattenWriter::new(), &context)?;
596596
let (iv, phsf) = match context.cipher {
597597
None => (None, None),
598-
Some(WriteCipher { context: c, .. }) => (Some(c.iv), Some(c.phsf)),
598+
Some(WriteCipher { context: c, .. }) => (Some(c.prefix_bytes()), Some(c.phsf)),
599599
};
600600
Ok(Self {
601601
header,

lib/src/entry/read.rs

Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ use crate::{
44
CipherMode, Compression, Encryption,
55
cipher::{
66
Ctr128BEReader, DecryptCbcAes256Reader, DecryptCbcCamellia256Reader,
7-
DecryptGcmAes256Reader, DecryptGcmCamellia256Reader, DecryptReader, MAX_SEGMENT_SIZE,
8-
STREAM_HEADER_LEN, StreamKind, derive_stream_key,
7+
DecryptGcmAes256Reader, DecryptGcmCamellia256Reader, DecryptReader, STREAM_HEADER_LEN,
8+
StreamHeader, StreamKind, derive_stream_key,
99
},
1010
compress::DecompressReader,
1111
error::AeadError,
@@ -86,21 +86,17 @@ pub(crate) fn decrypt_reader<R: Read>(
8686
e
8787
}
8888
})?;
89-
let stream_salt: [u8; 32] = header[..32].try_into().unwrap();
90-
let nonce_prefix: [u8; 7] = header[32..39].try_into().unwrap();
91-
let segment_size = u32::from_be_bytes(header[39..43].try_into().unwrap());
92-
// Checked before the password KDF runs, so a malformed stream
93-
// fails without paying the (deliberately expensive) Argon2id cost.
94-
if segment_size == 0 || segment_size > MAX_SEGMENT_SIZE {
95-
return Err(AeadError::Malformed("segment size out of range").into());
96-
}
89+
// Parsed (and the segment size range-checked) before the
90+
// password KDF runs, so a malformed stream fails without
91+
// paying the (deliberately expensive) Argon2id cost.
92+
let header = StreamHeader::try_from_bytes(&header)?;
9793
let k_master = derive_key(s, password)?;
9894
if k_master.as_bytes().len() != 32 {
9995
return Err(AeadError::Malformed("K_master is not 32 bytes").into());
10096
}
10197
let k_stream = derive_stream_key(
10298
k_master.as_bytes(),
103-
&stream_salt,
99+
&header.salt,
104100
kind,
105101
header_chunk_data,
106102
s.as_bytes(),
@@ -109,14 +105,14 @@ pub(crate) fn decrypt_reader<R: Read>(
109105
Encryption::Aes => DecryptReader::GcmAes(DecryptGcmAes256Reader::new(
110106
reader,
111107
&k_stream,
112-
nonce_prefix,
113-
segment_size,
108+
header.nonce_prefix,
109+
header.segment_size,
114110
)),
115111
_ => DecryptReader::GcmCamellia(DecryptGcmCamellia256Reader::new(
116112
reader,
117113
&k_stream,
118-
nonce_prefix,
119-
segment_size,
114+
header.nonce_prefix,
115+
header.segment_size,
120116
)),
121117
}
122118
}

0 commit comments

Comments
 (0)