Skip to main content

rag_rat_sync/
wire.rs

1//! The sync session wire protocol (phase D, #406).
2//!
3//! One symmetric protocol for device↔device and device↔server: both peers send [`Frame::Hello`]
4//! naming the account and every account-log entry hash they already hold, then each streams the
5//! entries the other lacks as [`Frame::Entries`] pages, ending with [`Frame::Done`]. The receiver
6//! feeds every entry through the op-log ingest seam, which re-verifies signature, canonicity, and
7//! chain continuity from scratch — the sender is never trusted, so a malformed or forged frame is
8//! rejected exactly as a malformed local write would be.
9//!
10//! Frames are canonical CBOR arrays tagged by a leading discriminant, encoded by hand with
11//! `minicbor::Encoder` to match the op-log's envelope style (no derive). The byte layout is a
12//! frozen wire — a golden-vector test pins it.
13
14use minicbor::{Decoder, Encoder};
15
16/// The ALPN this protocol speaks. Versioned: a breaking wire change bumps the suffix so an old peer
17/// declines the handshake instead of misreading frames. Bumped to `/2` for the #881 auth phase (the
18/// `Auth` frame exchanged before any inventory) — a `/1` peer, which would stream its inventory
19/// without authenticating, must not interoperate.
20pub const SYNC_ALPN: &[u8] = b"rag-rat/sync/2";
21
22/// Domain tag committed into every frame's leading array element, so a frame from another protocol
23/// (or a truncated one) cannot be mistaken for a valid frame.
24const FRAME_DOMAIN: &str = "rag-rat/sync-frame/1";
25
26/// The maximum entries a single [`Frame::Entries`] page carries — a hard cap so one frame can never
27/// force an unbounded allocation on the receiver (#406: bounded frames, no amplification).
28pub const MAX_ENTRIES_PER_PAGE: usize = 256;
29
30/// The maximum entry hashes a single [`Frame::Hello`] inventory carries. Bounds the hello frame so
31/// a peer cannot force an unbounded allocation before any authentication. A sender with more
32/// entries than this advertises a bounded subset — still correct (the peer's extra sends are
33/// idempotently re-ingested), just less efficient; see `session::bounded_inventory`. For the
34/// accounts D targets the cap is never reached.
35pub const MAX_HELLO_HASHES: usize = 65_536;
36
37/// The maximum bytes an [`Frame::Auth`] binding carries. A node binding is a small fixed shape
38/// (~224 bytes: domain + three 32-byte keys + a timestamp + a 64-byte signature); this is a
39/// decode-time sanity bound on that field. The actual PRE-ALLOCATION bound for an unauthenticated
40/// peer is the auth phase's frame-level cap, enforced from the length prefix before the body is
41/// allocated (`auth::MAX_AUTH_FRAME_BYTES` via `codec::read_frame_within`) — not this, which is
42/// checked only after the frame is read.
43pub const MAX_AUTH_BINDING_BYTES: usize = 512;
44
45type Hash = [u8; 32];
46
47/// One protocol frame. The discriminant is the second array element (after the domain tag).
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub enum Frame {
50    /// Authorizes the sender to the peer BEFORE any inventory is revealed (#881): names the account
51    /// and carries the sender's signed transport-node ↔ account-device binding. The peer verifies
52    /// it against its roster and the connection's authenticated remote node id under its own
53    /// admission policy, and reveals nothing (not even account confirmation) until it passes.
54    Auth { account_id: Hash, binding: Vec<u8> },
55    /// Opens the data phase: the account being synced and every account-log entry hash the sender
56    /// holds. The peer replies with the entries in ITS store that are not in this set.
57    Hello { account_id: Hash, have: Vec<Hash> },
58    /// A page of `signed_bytes` the peer lacked. `more` is true when further pages follow.
59    Entries { entries: Vec<Vec<u8>>, more: bool },
60    /// The sender has streamed every entry the peer lacked. A session ends when both directions are
61    /// `Done`.
62    Done,
63}
64
65mod tag {
66    pub const HELLO: u8 = 0;
67    pub const ENTRIES: u8 = 1;
68    pub const DONE: u8 = 2;
69    pub const AUTH: u8 = 3;
70}
71
72/// A frame that failed to decode. Kept distinct from an I/O error so the session can treat a
73/// protocol violation (drop the peer) differently from a transport hiccup.
74#[derive(Debug)]
75pub enum WireError {
76    /// The bytes are not a well-formed frame of this protocol.
77    Malformed(String),
78    /// A field exceeded its hard cap — a bounded-frame violation, treated as hostile.
79    OverCap(String),
80}
81
82impl std::fmt::Display for WireError {
83    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84        match self {
85            WireError::Malformed(m) => write!(f, "malformed sync frame: {m}"),
86            WireError::OverCap(m) => write!(f, "sync frame over cap: {m}"),
87        }
88    }
89}
90
91impl std::error::Error for WireError {}
92
93impl Frame {
94    /// Encode to canonical CBOR. Infallible for the in-memory shapes the session builds (the caps
95    /// are enforced by the builders), so encoding errors are a programmer bug, not a wire
96    /// condition.
97    pub fn encode(&self) -> Vec<u8> {
98        let mut buf = Vec::new();
99        let mut enc = Encoder::new(&mut buf);
100        // `[domain, tag, ..variant fields]`.
101        match self {
102            Frame::Auth { account_id, binding } => {
103                enc.array(4).expect(INFALLIBLE);
104                enc.str(FRAME_DOMAIN).expect(INFALLIBLE);
105                enc.u8(tag::AUTH).expect(INFALLIBLE);
106                enc.bytes(account_id).expect(INFALLIBLE);
107                enc.bytes(binding).expect(INFALLIBLE);
108            },
109            Frame::Hello { account_id, have } => {
110                enc.array(3).expect(INFALLIBLE);
111                enc.str(FRAME_DOMAIN).expect(INFALLIBLE);
112                enc.u8(tag::HELLO).expect(INFALLIBLE);
113                enc.array(2).expect(INFALLIBLE);
114                enc.bytes(account_id).expect(INFALLIBLE);
115                enc.array(have.len() as u64).expect(INFALLIBLE);
116                for h in have {
117                    enc.bytes(h).expect(INFALLIBLE);
118                }
119            },
120            Frame::Entries { entries, more } => {
121                enc.array(4).expect(INFALLIBLE);
122                enc.str(FRAME_DOMAIN).expect(INFALLIBLE);
123                enc.u8(tag::ENTRIES).expect(INFALLIBLE);
124                enc.array(entries.len() as u64).expect(INFALLIBLE);
125                for e in entries {
126                    enc.bytes(e).expect(INFALLIBLE);
127                }
128                enc.bool(*more).expect(INFALLIBLE);
129            },
130            Frame::Done => {
131                enc.array(2).expect(INFALLIBLE);
132                enc.str(FRAME_DOMAIN).expect(INFALLIBLE);
133                enc.u8(tag::DONE).expect(INFALLIBLE);
134            },
135        }
136        buf
137    }
138
139    /// Decode a frame, enforcing the domain tag and every hard cap. A caller treats `Err` as a
140    /// protocol violation and drops the peer.
141    pub fn decode(bytes: &[u8]) -> Result<Frame, WireError> {
142        let mut dec = Decoder::new(bytes);
143        let outer = expect_len(dec.array().map_err(m)?, "frame")?;
144        let domain = dec.str().map_err(m)?;
145        if domain != FRAME_DOMAIN {
146            return Err(WireError::Malformed(format!("unknown frame domain {domain:?}")));
147        }
148        let tag = dec.u8().map_err(m)?;
149        // Pin the outer arity per variant: the wire is frozen, so a `Done` with extra fields or a
150        // `Hello` of the wrong length is a malformed frame, not a tolerated superset.
151        let expected_outer = match tag {
152            tag::HELLO => 3,
153            tag::ENTRIES => 4,
154            tag::DONE => 2,
155            tag::AUTH => 4,
156            other => return Err(WireError::Malformed(format!("unknown frame tag {other}"))),
157        };
158        if outer != expected_outer {
159            return Err(WireError::Malformed(format!(
160                "frame tag {tag} arity {outer}, expected {expected_outer}"
161            )));
162        }
163        let frame = Self::decode_body(tag, &mut dec)?;
164        // A canonical frame consumes ALL its bytes: trailing CBOR after a valid frame is malformed,
165        // never silently ignored.
166        if dec.position() != bytes.len() {
167            return Err(WireError::Malformed("trailing bytes after frame".into()));
168        }
169        Ok(frame)
170    }
171
172    fn decode_body(tag: u8, dec: &mut Decoder<'_>) -> Result<Frame, WireError> {
173        match tag {
174            tag::AUTH => {
175                let account_id = fixed_hash(dec.bytes().map_err(m)?)?;
176                let binding = dec.bytes().map_err(m)?;
177                if binding.len() > MAX_AUTH_BINDING_BYTES {
178                    return Err(WireError::OverCap(format!(
179                        "auth binding {} > {MAX_AUTH_BINDING_BYTES}",
180                        binding.len()
181                    )));
182                }
183                Ok(Frame::Auth { account_id, binding: binding.to_vec() })
184            },
185            tag::HELLO => {
186                let inner = dec.array().map_err(m)?;
187                if inner != Some(2) {
188                    return Err(WireError::Malformed("hello payload arity".into()));
189                }
190                let account_id = fixed_hash(dec.bytes().map_err(m)?)?;
191                let n = expect_len(dec.array().map_err(m)?, "hello.have")?;
192                if n > MAX_HELLO_HASHES as u64 {
193                    return Err(WireError::OverCap(format!("hello.have {n} > {MAX_HELLO_HASHES}")));
194                }
195                let mut have = Vec::with_capacity(n as usize);
196                for _ in 0..n {
197                    have.push(fixed_hash(dec.bytes().map_err(m)?)?);
198                }
199                Ok(Frame::Hello { account_id, have })
200            },
201            tag::ENTRIES => {
202                let n = expect_len(dec.array().map_err(m)?, "entries")?;
203                if n > MAX_ENTRIES_PER_PAGE as u64 {
204                    return Err(WireError::OverCap(format!(
205                        "entries page {n} > {MAX_ENTRIES_PER_PAGE}"
206                    )));
207                }
208                let mut entries = Vec::with_capacity(n as usize);
209                for _ in 0..n {
210                    entries.push(dec.bytes().map_err(m)?.to_vec());
211                }
212                let more = dec.bool().map_err(m)?;
213                Ok(Frame::Entries { entries, more })
214            },
215            tag::DONE => Ok(Frame::Done),
216            other => Err(WireError::Malformed(format!("unknown frame tag {other}"))),
217        }
218    }
219}
220
221const INFALLIBLE: &str = "encoding into an owned Vec cannot fail";
222
223fn m(e: minicbor::decode::Error) -> WireError {
224    WireError::Malformed(e.to_string())
225}
226
227fn expect_len(len: Option<u64>, field: &str) -> Result<u64, WireError> {
228    len.ok_or_else(|| WireError::Malformed(format!("indefinite-length {field} array")))
229}
230
231fn fixed_hash(bytes: &[u8]) -> Result<Hash, WireError> {
232    Hash::try_from(bytes)
233        .map_err(|_| WireError::Malformed(format!("expected 32-byte hash, got {}", bytes.len())))
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239
240    fn roundtrip(frame: &Frame) {
241        let bytes = frame.encode();
242        assert_eq!(&Frame::decode(&bytes).unwrap(), frame, "encode∘decode is identity");
243    }
244
245    #[test]
246    fn every_frame_roundtrips() {
247        roundtrip(&Frame::Auth { account_id: [0xbb; 32], binding: vec![1, 2, 3, 4] });
248        roundtrip(&Frame::Auth { account_id: [0; 32], binding: vec![] });
249        roundtrip(&Frame::Hello { account_id: [0xaa; 32], have: vec![[1; 32], [2; 32]] });
250        roundtrip(&Frame::Hello { account_id: [0; 32], have: vec![] });
251        roundtrip(&Frame::Entries { entries: vec![vec![1, 2, 3], vec![]], more: true });
252        roundtrip(&Frame::Entries { entries: vec![], more: false });
253        roundtrip(&Frame::Done);
254    }
255
256    #[test]
257    fn an_over_cap_auth_binding_is_rejected() {
258        // An Auth frame whose binding exceeds the cap is hostile — bounds the first-frame
259        // allocation an unauthenticated peer can force.
260        let frame =
261            Frame::Auth { account_id: [3; 32], binding: vec![0u8; MAX_AUTH_BINDING_BYTES + 1] };
262        assert!(matches!(Frame::decode(&frame.encode()), Err(WireError::OverCap(_))));
263    }
264
265    #[test]
266    fn a_foreign_domain_is_rejected() {
267        // A CBOR array that is well-formed but not our protocol.
268        let mut buf = Vec::new();
269        let mut enc = Encoder::new(&mut buf);
270        enc.array(2).unwrap();
271        enc.str("some/other-protocol/1").unwrap();
272        enc.u8(0).unwrap();
273        assert!(matches!(Frame::decode(&buf), Err(WireError::Malformed(_))));
274    }
275
276    #[test]
277    fn an_over_cap_entries_page_is_rejected_as_hostile() {
278        // Hand-build an Entries frame claiming more elements than the cap allows, without
279        // allocating them — the decoder must reject on the declared length alone.
280        let mut buf = Vec::new();
281        let mut enc = Encoder::new(&mut buf);
282        enc.array(4).unwrap();
283        enc.str(FRAME_DOMAIN).unwrap();
284        enc.u8(tag::ENTRIES).unwrap();
285        enc.array((MAX_ENTRIES_PER_PAGE + 1) as u64).unwrap();
286        // no elements written; the length prefix alone must trip the cap
287        assert!(matches!(Frame::decode(&buf), Err(WireError::OverCap(_))));
288    }
289
290    #[test]
291    fn a_truncated_frame_is_malformed_not_a_panic() {
292        let full = Frame::Hello { account_id: [7; 32], have: vec![[9; 32]] }.encode();
293        for cut in 0..full.len() {
294            // Every prefix either decodes (unlikely) or errors — never panics.
295            let _ = Frame::decode(&full[..cut]);
296        }
297    }
298
299    #[test]
300    fn a_wrong_outer_arity_is_rejected() {
301        // A Done tag inside a 3-element outer array (Hello's arity) is malformed, not tolerated.
302        let mut buf = Vec::new();
303        let mut enc = Encoder::new(&mut buf);
304        enc.array(3).unwrap();
305        enc.str(FRAME_DOMAIN).unwrap();
306        enc.u8(tag::DONE).unwrap();
307        enc.u8(0).unwrap(); // an extra field a lax decoder might ignore
308        assert!(matches!(Frame::decode(&buf), Err(WireError::Malformed(_))));
309    }
310
311    #[test]
312    fn trailing_bytes_after_a_valid_frame_are_rejected() {
313        let mut buf = Frame::Done.encode();
314        buf.push(0xff); // one byte of garbage after an otherwise-valid frame
315        assert!(matches!(Frame::decode(&buf), Err(WireError::Malformed(_))));
316    }
317
318    /// The wire is frozen: pin the exact bytes of a representative frame so an accidental layout
319    /// change (field order, tag value, domain string) breaks the build rather than a live peer.
320    #[test]
321    fn done_frame_bytes_are_frozen() {
322        let bytes = Frame::Done.encode();
323        assert_eq!(
324            bytes,
325            // array(2), text "rag-rat/sync-frame/1", u8 2
326            [
327                0x82, 0x74, b'r', b'a', b'g', b'-', b'r', b'a', b't', b'/', b's', b'y', b'n', b'c',
328                b'-', b'f', b'r', b'a', b'm', b'e', b'/', b'1', 0x02,
329            ],
330        );
331    }
332}