Skip to main content

mkit_rpc/
framing.rs

1// Length-prefixed protobuf framing for signer + SSH protocols.
2
3use buffa::{DecodeOptions, Message};
4
5use crate::MAX_FRAME_BYTES;
6
7/// Recursion limit applied when decoding frame bodies. The deepest
8/// message in signer.proto / ssh.proto nests four levels (frame →
9/// oneof body → response → repeated entry); 16 leaves generous
10/// headroom for schema evolution while staying far below buffa's
11/// default of 100.
12pub const FRAME_RECURSION_LIMIT: u32 = 16;
13
14/// Decode options for a single frame body: recursion capped at
15/// [`FRAME_RECURSION_LIMIT`] and size capped at [`MAX_FRAME_BYTES`].
16///
17/// [`read_frame`] already bounds the input buffer to
18/// [`MAX_FRAME_BYTES`] before decoding; stating the cap here as well
19/// keeps the bound attached to the decode itself, so paths that
20/// receive frame bodies through other channels (e.g. the encrypted
21/// transport, where the cipher layer does the framing) enforce the
22/// same limits.
23#[must_use]
24pub fn frame_decode_options() -> DecodeOptions {
25    DecodeOptions::new()
26        .with_recursion_limit(FRAME_RECURSION_LIMIT)
27        .with_max_message_size(MAX_FRAME_BYTES as usize)
28}
29
30/// Errors emitted by the framing layer. Wire-protocol errors (a frame
31/// longer than [`MAX_FRAME_BYTES`], a truncated read) are distinct
32/// from decode errors so callers can decide whether to close the
33/// connection or just surface a parse failure.
34#[derive(Debug, thiserror::Error)]
35pub enum FrameError {
36    /// The 4-byte length prefix could not be read in full.
37    #[error("frame length prefix truncated")]
38    LengthTruncated,
39
40    /// The advertised length exceeded [`MAX_FRAME_BYTES`]. Receivers
41    /// MUST close the connection rather than continue reading.
42    #[error("frame length {0} exceeds MAX_FRAME_BYTES")]
43    LengthTooLarge(u32),
44
45    /// The frame body could not be read in full (peer closed, IO
46    /// error, etc.).
47    #[error("frame body truncated: expected {expected} bytes, got {actual}")]
48    BodyTruncated { expected: u32, actual: usize },
49
50    /// The frame body did not decode as the expected message type.
51    #[error("frame decode failed")]
52    DecodeFailed,
53
54    /// Underlying IO error.
55    #[error("io: {0}")]
56    Io(#[from] std::io::Error),
57}
58
59/// Writes a single framed protobuf message to `w`. The encoded length
60/// is prepended as a little-endian u32; if it exceeds
61/// [`MAX_FRAME_BYTES`] the call returns [`FrameError::LengthTooLarge`]
62/// without writing anything.
63pub fn write_frame<W, M>(w: &mut W, msg: &M) -> Result<(), FrameError>
64where
65    W: std::io::Write,
66    M: Message,
67{
68    let body = msg.encode_to_vec();
69    let len: u32 = body
70        .len()
71        .try_into()
72        .map_err(|_| FrameError::LengthTooLarge(u32::MAX))?;
73    if len > MAX_FRAME_BYTES {
74        return Err(FrameError::LengthTooLarge(len));
75    }
76    w.write_all(&len.to_le_bytes())?;
77    w.write_all(&body)?;
78    w.flush()?;
79    Ok(())
80}
81
82/// Reads a single framed protobuf message from `r`. Enforces the
83/// [`MAX_FRAME_BYTES`] cap; receivers MUST close the connection on
84/// any [`FrameError::LengthTooLarge`].
85pub fn read_frame<R, M>(r: &mut R) -> Result<M, FrameError>
86where
87    R: std::io::Read,
88    M: Message + Default,
89{
90    let mut len_buf = [0u8; 4];
91    r.read_exact(&mut len_buf).map_err(|e| {
92        if e.kind() == std::io::ErrorKind::UnexpectedEof {
93            FrameError::LengthTruncated
94        } else {
95            FrameError::Io(e)
96        }
97    })?;
98    let len = u32::from_le_bytes(len_buf);
99    if len > MAX_FRAME_BYTES {
100        return Err(FrameError::LengthTooLarge(len));
101    }
102
103    // Read the body with a manual fill loop (rather than `read_exact`) so
104    // a short read reports the TRUE number of bytes received in
105    // `BodyTruncated.actual` instead of a hardcoded 0.
106    let mut body = vec![0u8; len as usize];
107    let mut filled = 0usize;
108    while filled < body.len() {
109        match r.read(&mut body[filled..]) {
110            Ok(0) => {
111                return Err(FrameError::BodyTruncated {
112                    expected: len,
113                    actual: filled,
114                });
115            }
116            Ok(n) => filled += n,
117            Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
118            Err(e) => return Err(FrameError::Io(e)),
119        }
120    }
121
122    frame_decode_options()
123        .decode_from_slice(&body)
124        .map_err(|_| FrameError::DecodeFailed)
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130    use crate::mkit::rpc::v1::signer::{SignerFrame, signer_frame};
131    use crate::mkit::rpc::v1::{Error, ErrorCode};
132    use std::io::Cursor;
133
134    fn err_frame(code: ErrorCode, msg: &str) -> SignerFrame {
135        SignerFrame {
136            body: Some(signer_frame::Body::Error(Box::new(
137                Error::default()
138                    .with_code(code)
139                    .with_message(msg)
140                    .with_details(Vec::new()),
141            ))),
142            ..Default::default()
143        }
144    }
145
146    #[test]
147    fn roundtrip_signer_error_frame() {
148        let in_msg = err_frame(ErrorCode::UserDeclined, "user said no");
149        let mut buf = Vec::new();
150        write_frame(&mut buf, &in_msg).expect("write");
151
152        // Frame layout: 4-byte LE length + protobuf body.
153        let advertised = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]);
154        assert_eq!(advertised as usize, buf.len() - 4);
155
156        let mut cur = Cursor::new(buf);
157        let out: SignerFrame = read_frame(&mut cur).expect("read");
158        assert_eq!(in_msg, out);
159    }
160
161    #[test]
162    fn read_rejects_oversized_frame() {
163        // Hand-craft a length prefix > MAX_FRAME_BYTES with no body.
164        let mut buf = Vec::with_capacity(4);
165        buf.extend_from_slice(&(MAX_FRAME_BYTES + 1).to_le_bytes());
166        let mut cur = Cursor::new(buf);
167        match read_frame::<_, SignerFrame>(&mut cur) {
168            Err(FrameError::LengthTooLarge(n)) => assert_eq!(n, MAX_FRAME_BYTES + 1),
169            other => panic!("expected LengthTooLarge, got {other:?}"),
170        }
171    }
172
173    #[test]
174    fn read_rejects_truncated_length_prefix() {
175        let buf = vec![0x01, 0x00];
176        let mut cur = Cursor::new(buf);
177        match read_frame::<_, SignerFrame>(&mut cur) {
178            Err(FrameError::LengthTruncated) => {}
179            other => panic!("expected LengthTruncated, got {other:?}"),
180        }
181    }
182
183    #[test]
184    fn decode_options_reject_oversized_body_even_without_framing() {
185        use crate::mkit::rpc::v1::signer::SignRequest;
186
187        // A frame whose encoding exceeds MAX_FRAME_BYTES. read_frame
188        // never sees one (the length prefix is checked first), but
189        // decode paths that receive bodies through other channels —
190        // e.g. the encrypted transport, where the cipher layer does
191        // the framing — rely on frame_decode_options for the bound.
192        // The bare decoder accepts it; the capped decoder must not.
193        let frame = SignerFrame {
194            body: Some(signer_frame::Body::SignRequest(Box::new(
195                SignRequest::default().with_payload(vec![0u8; MAX_FRAME_BYTES as usize + 1]),
196            ))),
197            ..Default::default()
198        };
199        let bytes = frame.encode_to_vec();
200        assert!(SignerFrame::decode_from_slice(&bytes).is_ok());
201        assert!(
202            frame_decode_options()
203                .decode_from_slice::<SignerFrame>(&bytes)
204                .is_err(),
205            "decode cap must reject a body over MAX_FRAME_BYTES"
206        );
207    }
208
209    #[test]
210    fn body_truncated_reports_true_actual_count() {
211        // Advertise a 10-byte body but supply only 3 bytes. The error
212        // must report the actual count (3), not a hardcoded 0.
213        let mut buf = Vec::new();
214        buf.extend_from_slice(&10u32.to_le_bytes());
215        buf.extend_from_slice(&[0xAA, 0xBB, 0xCC]); // 3 body bytes only
216        let mut cur = Cursor::new(buf);
217        match read_frame::<_, SignerFrame>(&mut cur) {
218            Err(FrameError::BodyTruncated { expected, actual }) => {
219                assert_eq!(expected, 10);
220                assert_eq!(actual, 3, "actual byte count must reflect bytes read");
221            }
222            other => panic!("expected BodyTruncated, got {other:?}"),
223        }
224    }
225}
226
227/// Kani proof harnesses (`cargo kani -p mkit-rpc --harness rpc_`) for the
228/// framing layer the `rpc_decode` fuzz target's frames travel through.
229/// Decoding any buffa-generated message (`SignerFrame`, `SshFrame`, even
230/// the one-field `PinPrompt`) is out of reach: buffa's recursive
231/// `UnknownFields` drop glue exhausts CBMC's memory or the 15-min budget
232/// at a single symbolic byte, so that property stays with the fuzz target
233/// and the body is decoded as the opaque `Opaque` message below.
234#[cfg(kani)]
235mod kani_proofs {
236    use super::*;
237    use buffa::bytes::Buf;
238    use buffa::encoding::Tag;
239    use buffa::{DecodeContext, DecodeError, DefaultInstance, EncodeSink, SizeCache};
240
241    /// A message that accepts any body by consuming it whole: the framing
242    /// logic under test is message-independent.
243    #[derive(Clone, Debug, Default, PartialEq)]
244    struct Opaque;
245
246    impl DefaultInstance for Opaque {
247        fn default_instance() -> &'static Self {
248            &Opaque
249        }
250    }
251
252    impl Message for Opaque {
253        fn compute_size(&self, _cache: &mut SizeCache) -> u32 {
254            0
255        }
256        fn write_to(&self, _cache: &mut SizeCache, _buf: &mut impl EncodeSink) {}
257        fn merge(
258            &mut self,
259            buf: &mut impl Buf,
260            _ctx: DecodeContext<'_>,
261        ) -> Result<(), DecodeError> {
262            buf.advance(buf.remaining());
263            Ok(())
264        }
265        fn merge_field(
266            &mut self,
267            _tag: Tag,
268            _buf: &mut impl Buf,
269            _ctx: DecodeContext<'_>,
270        ) -> Result<(), DecodeError> {
271            Ok(())
272        }
273        fn clear(&mut self) {}
274    }
275
276    /// Calls `$f::<N>()` for each listed literal `N` (concrete lengths
277    /// let CBMC constant-fold slice lengths).
278    macro_rules! each_len {
279        ($f:ident; $($n:literal)*) => { $( $f::<$n>(); )* };
280    }
281
282    fn read_at<const N: usize>() {
283        let buf: [u8; N] = kani::any();
284        let b: &[u8] = &buf;
285        let prefix: Option<u32> = b
286            .get(..4)
287            .map(|p| u32::from_le_bytes(p.try_into().expect("4")));
288        let mut r = std::io::Cursor::new(b);
289        let got = read_frame::<_, Opaque>(&mut r);
290        // Accepted iff a full prefix, an in-cap length and a full body.
291        let expect_ok = prefix.is_some_and(|l| l <= MAX_FRAME_BYTES && l as usize <= N - 4);
292        assert_eq!(got.is_ok(), expect_ok);
293        match got {
294            Err(FrameError::LengthTruncated) => {
295                assert!(N < 4);
296            }
297            Err(FrameError::LengthTooLarge(l)) => {
298                assert!(l > MAX_FRAME_BYTES);
299            }
300            Err(FrameError::BodyTruncated { expected, actual }) => {
301                assert!(actual < expected as usize && actual == N - 4);
302            }
303            Err(FrameError::DecodeFailed | FrameError::Io(_)) => {
304                panic!("an opaque body over an in-memory reader cannot fail to decode");
305            }
306            Ok(_) => {}
307        }
308    }
309
310    /// `read_frame` over every <= 6-byte stream (4-byte length prefix +
311    /// <= 2 body bytes): never panics, rejects an over-cap length prefix
312    /// before allocating, and reports a short body with the true received
313    /// count; a complete body is handed to the decoder (`Ok`).
314    #[kani::proof]
315    #[kani::unwind(5)]
316    fn rpc_read_frame_no_panic() {
317        each_len!(read_at; 0 1 2 3 4 5 6);
318    }
319
320    /// Canary: the checker must falsify "an over-cap length prefix is
321    /// accepted", showing the `LengthTooLarge` arm asserted above is
322    /// reachable (the prefix is fully symbolic, body empty).
323    #[kani::proof]
324    #[kani::unwind(5)]
325    #[kani::should_panic]
326    fn rpc_canary_over_cap_length_accepted() {
327        let buf: [u8; 4] = kani::any();
328        let mut r = std::io::Cursor::new(&buf[..]);
329        assert!(!matches!(
330            read_frame::<_, Opaque>(&mut r),
331            Err(FrameError::LengthTooLarge(_))
332        ));
333    }
334}