Skip to main content

yip_wire/
lib.rs

1//! Wire framing for the yip data plane: keyed header-protection and
2//! coverage-based authentication. Behavior lands in milestone M2; this
3//! milestone establishes the public surface later crates depend on.
4#![forbid(unsafe_code)]
5
6use siphasher::sip::SipHasher13;
7use std::hash::Hasher;
8use subtle::ConstantTimeEq;
9
10/// A single on-wire frame carrying one FEC symbol.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct Frame {
13    /// Epoch-rotating keyed token selecting the session/decoder.
14    pub conn_tag: u64,
15    /// Which pipelined FEC object this symbol belongs to.
16    pub object_id: u16,
17    /// FEC payload identifier (Reed–Solomon codec tag + symbol index), opaque to the wire layer.
18    pub payload_id: [u8; 4],
19    /// Symbol kind / control bits (source/repair, feedback, ARQ).
20    pub flags: u8,
21    /// The ciphertext symbol payload.
22    pub payload: Vec<u8>,
23}
24
25/// Length of the logical (and protected) frame header in bytes.
26pub const HEADER_LEN: usize = 15;
27/// Length of the trailing coverage-auth tag in bytes.
28pub const TAG_LEN: usize = 8;
29/// Smallest valid frame: header + tag, empty payload.
30pub const MIN_FRAME: usize = HEADER_LEN + TAG_LEN;
31
32/// Serialize the logical header (big-endian, fixed layout).
33fn write_header(frame: &Frame) -> [u8; HEADER_LEN] {
34    let mut out = [0u8; HEADER_LEN];
35    out[0..8].copy_from_slice(&frame.conn_tag.to_be_bytes());
36    out[8..10].copy_from_slice(&frame.object_id.to_be_bytes());
37    out[10..14].copy_from_slice(&frame.payload_id);
38    out[14] = frame.flags;
39    out
40}
41
42/// Parse the logical header fields back out.
43fn read_header(bytes: &[u8; HEADER_LEN]) -> (u64, u16, [u8; 4], u8) {
44    let conn_tag = u64::from_be_bytes(bytes[0..8].try_into().expect("8 bytes"));
45    let object_id = u16::from_be_bytes(bytes[8..10].try_into().expect("2 bytes"));
46    let payload_id: [u8; 4] = bytes[10..14].try_into().expect("4 bytes");
47    let flags = bytes[14];
48    (conn_tag, object_id, payload_id, flags)
49}
50
51/// Compute the 8-byte coverage-auth tag over `header` and `payload` under `auth_key`.
52///
53/// Feeds the slices sequentially into `SipHasher13` without allocating intermediate buffers.
54#[inline]
55fn auth_tag(auth_key: &[u8; 16], header: &[u8; HEADER_LEN], payload: &[u8]) -> [u8; TAG_LEN] {
56    let mut hasher = SipHasher13::new_with_key(auth_key);
57    hasher.write(header);
58    hasher.write(payload);
59    hasher.finish().to_be_bytes()
60}
61
62/// Generate the 15-byte header protection mask as a SipHash-1-3 CTR keystream under `hp_key`,
63/// seeded by `sample` (the 8-byte auth tag).
64///
65/// Computed entirely on the stack into a fixed `[u8; HEADER_LEN]` buffer (zero heap allocation).
66#[inline]
67fn header_mask(hp_key: &[u8; 16], sample: &[u8]) -> [u8; HEADER_LEN] {
68    let mut out = [0u8; HEADER_LEN];
69
70    // Block 0: SipHash13(hp_key, sample ‖ 0_u32) -> 8 bytes
71    let mut h0 = SipHasher13::new_with_key(hp_key);
72    h0.write(sample);
73    h0.write(&0u32.to_be_bytes());
74    out[0..8].copy_from_slice(&h0.finish().to_be_bytes());
75
76    // Block 1: SipHash13(hp_key, sample ‖ 1_u32) -> 8 bytes (truncate to 7 bytes to fill 15)
77    let mut h1 = SipHasher13::new_with_key(hp_key);
78    h1.write(sample);
79    h1.write(&1u32.to_be_bytes());
80    out[8..15].copy_from_slice(&h1.finish().to_be_bytes()[..7]);
81
82    out
83}
84
85/// XOR `mask` into `buf` byte-for-byte (`buf.len()` must be `<= mask.len()`).
86fn xor_in_place(buf: &mut [u8], mask: &[u8]) {
87    for (b, m) in buf.iter_mut().zip(mask.iter()) {
88        *b ^= *m;
89    }
90}
91
92/// Errors from decoding a wire datagram.
93#[derive(Debug, thiserror::Error, PartialEq, Eq)]
94pub enum WireError {
95    /// Coverage-auth tag did not verify.
96    #[error("authentication failed")]
97    AuthFailed,
98    /// Datagram was too short or structurally invalid.
99    #[error("malformed datagram")]
100    Malformed,
101}
102
103/// Wire codec: frames `Frame`s with a SipHash coverage-auth tag and keyed
104/// header protection. Keys are injected (real session keys arrive in M3).
105pub struct Codec {
106    auth_key: [u8; 16],
107    hp_key: [u8; 16],
108}
109
110impl Codec {
111    /// Construct a codec from a 16-byte auth key and a 16-byte header-protection key.
112    pub fn new(auth_key: [u8; 16], hp_key: [u8; 16]) -> Self {
113        Self { auth_key, hp_key }
114    }
115}
116
117/// Encodes [`Frame`]s to datagrams and back. Implemented in M2.
118pub trait WireCodec {
119    /// Serialize and header-protect a frame into a wire datagram.
120    fn frame(&self, frame: &Frame) -> Vec<u8>;
121    /// Authenticate, deprotect, and parse a datagram into a [`Frame`].
122    fn deframe(&self, datagram: &[u8]) -> Result<Frame, WireError>;
123}
124
125impl WireCodec for Codec {
126    fn frame(&self, frame: &Frame) -> Vec<u8> {
127        let header = write_header(frame);
128        let tag = auth_tag(&self.auth_key, &header, &frame.payload);
129        let mask = header_mask(&self.hp_key, &tag);
130
131        let mut out = Vec::with_capacity(HEADER_LEN + frame.payload.len() + TAG_LEN);
132        out.extend_from_slice(&header);
133        xor_in_place(&mut out[..HEADER_LEN], &mask);
134        out.extend_from_slice(&frame.payload);
135        out.extend_from_slice(&tag);
136        out
137    }
138
139    fn deframe(&self, datagram: &[u8]) -> Result<Frame, WireError> {
140        if datagram.len() < MIN_FRAME {
141            return Err(WireError::Malformed);
142        }
143        let tag = &datagram[datagram.len() - TAG_LEN..];
144        // Recover the header by removing the keyed mask (seeded by the tag) entirely on the stack.
145        let mask = header_mask(&self.hp_key, tag);
146        let mut header = [0u8; HEADER_LEN];
147        header.copy_from_slice(&datagram[..HEADER_LEN]);
148        xor_in_place(&mut header, &mask);
149        let payload = &datagram[HEADER_LEN..datagram.len() - TAG_LEN];
150
151        // Recompute the tag over recovered-header‖payload using streaming multi-slice (zero alloc).
152        let expected = auth_tag(&self.auth_key, &header, payload);
153        if expected.ct_eq(tag).unwrap_u8() != 1 {
154            return Err(WireError::AuthFailed);
155        }
156        let (conn_tag, object_id, payload_id, flags) = read_header(&header);
157        Ok(Frame {
158            conn_tag,
159            object_id,
160            payload_id,
161            flags,
162            payload: payload.to_vec(),
163        })
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    #[test]
172    fn auth_tag_is_keyed_and_covers_input() {
173        let k1 = [1u8; 16];
174        let k2 = [2u8; 16];
175        let h1 = [0x11u8; HEADER_LEN];
176        let h2 = [0x22u8; HEADER_LEN];
177        let a = auth_tag(&k1, &h1, b"hello");
178        let b = auth_tag(&k1, &h1, b"hello");
179        let c = auth_tag(&k1, &h1, b"hellp"); // one payload byte different
180        let d = auth_tag(&k2, &h1, b"hello"); // different key
181        let e = auth_tag(&k1, &h2, b"hello"); // different header
182        assert_eq!(a, b, "deterministic for same key+input");
183        assert_ne!(a, c, "changes when payload bytes change");
184        assert_ne!(a, d, "changes when key changes");
185        assert_ne!(a, e, "changes when header bytes change");
186    }
187
188    #[test]
189    fn auth_tag_multi_slice_matches_contiguous() {
190        let key = [0x42u8; 16];
191        let header = [0x33u8; HEADER_LEN];
192        let payload = vec![0x77u8; 1200];
193        let tag = auth_tag(&key, &header, &payload);
194
195        let mut ref_hasher = SipHasher13::new_with_key(&key);
196        let mut contiguous = Vec::with_capacity(HEADER_LEN + payload.len());
197        contiguous.extend_from_slice(&header);
198        contiguous.extend_from_slice(&payload);
199        ref_hasher.write(&contiguous);
200        let expected = ref_hasher.finish().to_be_bytes();
201
202        assert_eq!(
203            tag, expected,
204            "streaming multi-slice matches contiguous hash"
205        );
206    }
207
208    #[test]
209    fn frame_carries_object_id() {
210        let frame = Frame {
211            conn_tag: 7,
212            object_id: 42,
213            payload_id: [0; 4],
214            flags: 0,
215            payload: vec![1, 2, 3],
216        };
217        assert_eq!(frame.object_id, 42);
218    }
219
220    #[test]
221    fn header_mask_masks_reversibly_and_hides_constants() {
222        let hp = [3u8; 16];
223        let sample = [0xAAu8; TAG_LEN];
224        let mut header = [0u8; HEADER_LEN]; // all-zero "constant" header
225        let mask = header_mask(&hp, &sample);
226        assert_eq!(mask.len(), HEADER_LEN);
227        xor_in_place(&mut header, &mask);
228        assert_ne!(
229            header, [0u8; HEADER_LEN],
230            "constant header is hidden after masking"
231        );
232        // XOR again with the same mask restores the original
233        xor_in_place(&mut header, &mask);
234        assert_eq!(header, [0u8; HEADER_LEN], "masking is reversible");
235        // a different sample yields a different stream
236        let mask2 = header_mask(&hp, &[0xBBu8; TAG_LEN]);
237        assert_ne!(mask, mask2);
238    }
239
240    /// `header_mask` must advance its internal block counter so successive blocks
241    /// (Block 0 = bytes 0..8, Block 1 = bytes 8..15) are distinct.
242    #[test]
243    fn header_mask_consecutive_blocks_are_distinct() {
244        let hp = [7u8; 16];
245        let sample = [0x55u8; TAG_LEN];
246        let mask = header_mask(&hp, &sample);
247        assert_eq!(mask.len(), HEADER_LEN);
248        assert_ne!(
249            &mask[..7],
250            &mask[8..15],
251            "block-0 (counter=0) and block-1 (counter=1) must differ"
252        );
253    }
254
255    /// `MIN_FRAME` must equal `HEADER_LEN + TAG_LEN`.  Kills the `+` → `-`
256    /// mutant on line 30: with subtraction MIN_FRAME would be 7 instead of 23,
257    /// letting the short-datagram gate in `deframe` accept datagrams too small
258    /// to contain a real frame.
259    #[test]
260    fn min_frame_equals_header_plus_tag() {
261        assert_eq!(
262            MIN_FRAME,
263            HEADER_LEN + TAG_LEN,
264            "MIN_FRAME must be HEADER_LEN + TAG_LEN"
265        );
266    }
267
268    /// `deframe` must accept a datagram of exactly `MIN_FRAME` bytes (empty
269    /// payload).  Kills the `datagram.len() < MIN_FRAME` → `<= MIN_FRAME`
270    /// mutant (line 130): that mutant rejects exact-MIN_FRAME datagrams as
271    /// Malformed even though they are structurally valid.
272    #[test]
273    fn codec_accepts_exact_min_frame_datagram() {
274        let codec = Codec::new([9u8; 16], [10u8; 16]);
275        let frame = Frame {
276            conn_tag: 0xCAFE_BABE_0000_0001,
277            object_id: 3,
278            payload_id: [0xAB, 0xCD, 0xEF, 0x01],
279            flags: 0x42,
280            payload: vec![], // empty payload → wire length == MIN_FRAME
281        };
282        let wire = codec.frame(&frame);
283        assert_eq!(
284            wire.len(),
285            MIN_FRAME,
286            "empty-payload frame must be exactly MIN_FRAME bytes"
287        );
288        assert_eq!(
289            codec.deframe(&wire).unwrap(),
290            frame,
291            "exact-MIN_FRAME datagram must be accepted"
292        );
293    }
294
295    #[test]
296    fn codec_roundtrips_a_frame() {
297        let codec = Codec::new([4u8; 16], [5u8; 16]);
298        let frame = Frame {
299            conn_tag: 0xDEAD_BEEF_0000_0001,
300            object_id: 7,
301            payload_id: [1, 2, 3, 4],
302            flags: 0b0000_0011,
303            payload: b"the quick brown fox".to_vec(),
304        };
305        let wire = codec.frame(&frame);
306        assert!(wire.len() >= MIN_FRAME);
307        assert_eq!(codec.deframe(&wire).unwrap(), frame);
308    }
309
310    #[test]
311    fn codec_rejects_tampered_frame() {
312        let codec = Codec::new([4u8; 16], [5u8; 16]);
313        let frame = Frame {
314            conn_tag: 1,
315            object_id: 1,
316            payload_id: [0; 4],
317            flags: 0,
318            payload: b"payload".to_vec(),
319        };
320        let mut wire = codec.frame(&frame);
321        let last = wire.len() - 1;
322        wire[last] ^= 0x01; // flip a payload/tag bit
323        assert_eq!(codec.deframe(&wire), Err(WireError::AuthFailed));
324    }
325
326    #[test]
327    fn codec_rejects_short_datagram() {
328        let codec = Codec::new([4u8; 16], [5u8; 16]);
329        assert_eq!(
330            codec.deframe(&[0u8; MIN_FRAME - 1]),
331            Err(WireError::Malformed)
332        );
333    }
334
335    #[test]
336    fn codec_has_no_constant_header_bytes() {
337        // Two frames identical except conn_tag must not share a plaintext-looking
338        // header prefix on the wire (header is protected).
339        let codec = Codec::new([4u8; 16], [5u8; 16]);
340        let base = Frame {
341            conn_tag: 0,
342            object_id: 0,
343            payload_id: [0; 4],
344            flags: 0,
345            payload: vec![],
346        };
347        let wire = codec.frame(&base);
348        // The first 15 wire bytes are the protected all-zero header; they must not be all zero.
349        assert_ne!(&wire[..HEADER_LEN], &[0u8; HEADER_LEN]);
350    }
351
352    #[test]
353    fn header_roundtrips() {
354        let frame = Frame {
355            conn_tag: 0x0102_0304_0506_0708,
356            object_id: 0xABCD,
357            payload_id: [9, 8, 7, 6],
358            flags: 0x5A,
359            payload: vec![],
360        };
361        let bytes = write_header(&frame);
362        assert_eq!(bytes.len(), HEADER_LEN);
363        let (conn_tag, object_id, payload_id, flags) = read_header(&bytes);
364        assert_eq!(conn_tag, frame.conn_tag);
365        assert_eq!(object_id, frame.object_id);
366        assert_eq!(payload_id, frame.payload_id);
367        assert_eq!(flags, frame.flags);
368    }
369}