From 822d884e21d8f9806caa75ffdcf6e5bae4754c99 Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Thu, 23 Jul 2026 00:45:08 -0400 Subject: [PATCH] =?UTF-8?q?fix(gossip):=20chunk=20the=20digest=20+=20fail-?= =?UTF-8?q?soft=20obfuscate=20=E2=80=94=20no=20panic=20at=20large=20mesh?= =?UTF-8?q?=20scale=20(#44)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bin/yip-rendezvous/src/conn.rs | 3 +- bin/yip-rendezvous/src/main.rs | 20 ++-- bin/yip-rendezvous/tests/smoke.rs | 7 +- bin/yipd/src/membership.rs | 146 +++++++++++++++++++++++++++--- bin/yipd/src/peer_manager.rs | 70 ++++++++++---- bin/yipd/src/relay_client.rs | 9 +- crates/yip-obf/src/lib.rs | 44 +++++++-- 7 files changed, 245 insertions(+), 54 deletions(-) diff --git a/bin/yip-rendezvous/src/conn.rs b/bin/yip-rendezvous/src/conn.rs index cfe1c1d..dff3613 100644 --- a/bin/yip-rendezvous/src/conn.rs +++ b/bin/yip-rendezvous/src/conn.rs @@ -216,7 +216,8 @@ mod tests { fn framed_register(obf_key: &[u8; 16], node: yip_rendezvous::NodeId, counter: u64) -> Vec { let mut plain = Vec::new(); encode(&Message::Register { node, counter }, &mut plain); - let env = yip_obf::obfuscate(obf_key, yip_obf::RDV_TYPE, &plain, 0); + let env = yip_obf::obfuscate(obf_key, yip_obf::RDV_TYPE, &plain, 0) + .expect("small test body fits u16"); let mut framed = Vec::new(); framed.extend_from_slice(&u16::try_from(env.len()).unwrap().to_be_bytes()); framed.extend_from_slice(&env); diff --git a/bin/yip-rendezvous/src/main.rs b/bin/yip-rendezvous/src/main.rs index 23be5df..659e025 100644 --- a/bin/yip-rendezvous/src/main.rs +++ b/bin/yip-rendezvous/src/main.rs @@ -47,7 +47,8 @@ fn wrap_reply(obf_key: Option<&[u8; 16]>, reply: &Message) -> Vec { let mut out = Vec::new(); encode(reply, &mut out); match obf_key { - Some(key) => yip_obf::obfuscate(key, yip_obf::RDV_TYPE, &out, random_pad(OBF_PAD_MAX)), + Some(key) => yip_obf::obfuscate(key, yip_obf::RDV_TYPE, &out, random_pad(OBF_PAD_MAX)) + .expect("rendezvous reply body is a small fixed-shape Message, always fits u16"), None => out, } } @@ -62,7 +63,8 @@ pub(crate) const TLS_FRAME_CAP: usize = 2048; pub(crate) fn frame_obf(obf_key: &[u8; 16], msg: &Message) -> Vec { let mut plain = Vec::new(); encode(msg, &mut plain); - let env = yip_obf::obfuscate(obf_key, yip_obf::RDV_TYPE, &plain, random_pad(OBF_PAD_MAX)); + let env = yip_obf::obfuscate(obf_key, yip_obf::RDV_TYPE, &plain, random_pad(OBF_PAD_MAX)) + .expect("rendezvous message body is a small fixed-shape Message, always fits u16"); let mut out = Vec::with_capacity(2 + env.len()); let len = u16::try_from(env.len()).unwrap_or(u16::MAX); out.extend_from_slice(&len.to_be_bytes()); @@ -674,7 +676,8 @@ mod obf_tests { let mut plain = Vec::new(); encode(&msg, &mut plain); - let wrapped = yip_obf::obfuscate(&key, yip_obf::RDV_TYPE, &plain, 12); + let wrapped = yip_obf::obfuscate(&key, yip_obf::RDV_TYPE, &plain, 12) + .expect("small test body fits u16"); let (ptype, body) = yip_obf::deobfuscate(&key, &wrapped).expect("round-trips"); assert_eq!(ptype, yip_obf::RDV_TYPE); assert_eq!( @@ -700,7 +703,8 @@ mod obf_tests { let mut plain = Vec::new(); encode(&msg, &mut plain); - let wrapped = yip_obf::obfuscate(&key, yip_obf::RDV_TYPE, &plain, 0); + let wrapped = yip_obf::obfuscate(&key, yip_obf::RDV_TYPE, &plain, 0) + .expect("small test body fits u16"); let (ptype, body) = yip_obf::deobfuscate(&key, &wrapped).expect("round-trips"); assert_eq!(ptype, yip_obf::RDV_TYPE); match yip_rendezvous::decode(&body) { @@ -729,7 +733,8 @@ mod obf_tests { let mut plain = Vec::new(); encode(&msg, &mut plain); - let wrapped = yip_obf::obfuscate(&k1, yip_obf::RDV_TYPE, &plain, 8); + let wrapped = yip_obf::obfuscate(&k1, yip_obf::RDV_TYPE, &plain, 8) + .expect("small test body fits u16"); match yip_obf::deobfuscate(&k2, &wrapped) { None => {} Some((ptype, body)) => { @@ -757,7 +762,10 @@ mod obf_tests { let n = 512usize; let dgs: Vec> = (0..n) - .map(|_| yip_obf::obfuscate(&key, yip_obf::RDV_TYPE, &plain, 4)) + .map(|_| { + yip_obf::obfuscate(&key, yip_obf::RDV_TYPE, &plain, 4) + .expect("small test body fits u16") + }) .collect(); let len = dgs[0].len(); for pos in 0..len { diff --git a/bin/yip-rendezvous/tests/smoke.rs b/bin/yip-rendezvous/tests/smoke.rs index d5f08b7..a12e4e2 100644 --- a/bin/yip-rendezvous/tests/smoke.rs +++ b/bin/yip-rendezvous/tests/smoke.rs @@ -50,7 +50,9 @@ fn wait_until_listening(listen: &str, key: Option<&[u8; 16]>) { &mut plain, ); let on_wire = match key { - Some(k) => yip_obf::obfuscate(k, yip_obf::RDV_TYPE, &plain, 6), + Some(k) => { + yip_obf::obfuscate(k, yip_obf::RDV_TYPE, &plain, 6).expect("small test body fits u16") + } None => plain.clone(), }; let mut rx = [0u8; 2048]; @@ -194,7 +196,8 @@ fn register_lookup_relay_over_udp_with_obf_psk() { let send_wrapped = |sock: &UdpSocket, msg: &Message| { let mut plain = Vec::new(); encode(msg, &mut plain); - let wrapped = yip_obf::obfuscate(&key, yip_obf::RDV_TYPE, &plain, 6); + let wrapped = yip_obf::obfuscate(&key, yip_obf::RDV_TYPE, &plain, 6) + .expect("small test body fits u16"); sock.send(&wrapped).unwrap(); }; let recv_wrapped = |sock: &UdpSocket, rx: &mut [u8]| -> Message { diff --git a/bin/yipd/src/membership.rs b/bin/yipd/src/membership.rs index febe5fc..df23329 100644 --- a/bin/yipd/src/membership.rs +++ b/bin/yipd/src/membership.rs @@ -61,6 +61,22 @@ const GOSSIP_INTERVAL_MS: u64 = 5_000; /// messages instead of emitting one oversized one. const MAX_GOSSIP_RECORDS_PER_REPLY: usize = 32; +/// Max `(node_id, seq)` entries carried in a single `GossipMsg::Digest` +/// emitted by `tick_digest` (#44). Each entry encodes as `NodeId(16) + +/// seq(8) = 24` bytes. A directory larger than this cap is split across +/// multiple `Digest` messages (mirrors `MAX_GOSSIP_RECORDS_PER_REPLY` for +/// `Records`), instead of one unboundedly large digest — which, at ≥~2731 +/// entries, would encode past 65535 bytes and make `yip_obf::obfuscate`'s +/// `u16` body-length cast fail (fail-soft as of #44, but still unshippable), +/// and which in any case cannot converge once it exceeds the network MTU. +/// +/// Sized so one whole obfuscated digest datagram stays comfortably under +/// `peer_manager::OBF_MTU_BUDGET` (1200 bytes): +/// `PacketType`(1) + `Digest` tag(1) + count(2) + 40 entries × 24 +/// + obf envelope (nonce 8 + type 1 + body_len 2 = 11) + IP/UDP (~28) +/// = 1 + 1 + 2 + 960 + 11 + 28 = 1003, well under 1200. +const MAX_GOSSIP_DIGEST_ENTRIES: usize = 40; + /// The `seq` a node's own record starts at. Zero is fine: `ingest_record`'s /// seq-supersession means any received record for our own `node_id` with a /// higher seq would be a stale/duplicate broadcast of ourselves, which is @@ -275,16 +291,23 @@ impl Membership { } /// A debounced `Digest` of the whole local directory, to send to gossip - /// partners. `now_ms` is MONOTONIC milliseconds — used purely to space - /// digests at least `GOSSIP_INTERVAL_MS` (jittered ±25% per fire when - /// `obf_on`, see `digest_ms`) apart; it is never compared against a + /// partners — split into one or more `GossipMsg::Digest` messages of at + /// most `MAX_GOSSIP_DIGEST_ENTRIES` entries each (#44), so a large + /// directory neither exceeds the MTU nor risks the obf envelope's `u16` + /// body-length cap. `now_ms` is MONOTONIC milliseconds — used purely to + /// space digests at least `GOSSIP_INTERVAL_MS` (jittered ±25% per fire + /// when `obf_on`, see `digest_ms`) apart; it is never compared against a /// cert's validity window. `obf_on` is the caller's /// `PeerManager::obf_key.is_some()`; when false `digest_ms` stays exactly /// `GOSSIP_INTERVAL_MS` forever (byte-identical obf-off timing). - pub fn tick_digest(&mut self, now_ms: u64, obf_on: bool) -> Option { + /// + /// Returns an empty `Vec` both when debounced (too soon since the last + /// fire) and when the directory is empty — never a `Vec` containing an + /// empty `Digest`. + pub fn tick_digest(&mut self, now_ms: u64, obf_on: bool) -> Vec { if let Some(last) = self.last_digest_ms { if now_ms.saturating_sub(last) < self.digest_ms { - return None; + return Vec::new(); } } self.last_digest_ms = Some(now_ms); @@ -298,7 +321,10 @@ impl Membership { .iter() .map(|(nid, r)| (*nid, r.seq)) .collect(); - Some(GossipMsg::Digest(entries)) + entries + .chunks(MAX_GOSSIP_DIGEST_ENTRIES) + .map(|chunk| GossipMsg::Digest(chunk.to_vec())) + .collect() } /// The signed bootstrap root set (pubkey + reachable address), for @@ -643,13 +669,20 @@ mod tests { let secs = 500u64; // Round 1: both emit a digest of what they know (just themselves). - let da = a - .tick_digest(now_ms, false) - .expect("first digest always fires"); + // Each directory holds only its own record (well under + // MAX_GOSSIP_DIGEST_ENTRIES), so `tick_digest` fires exactly one + // `Digest`. + let da = { + let mut da = a.tick_digest(now_ms, false); + assert_eq!(da.len(), 1, "first digest always fires, as one chunk"); + da.remove(0) + }; now_ms += GOSSIP_INTERVAL_MS; - let db = b - .tick_digest(now_ms, false) - .expect("first digest always fires"); + let db = { + let mut db = b.tick_digest(now_ms, false); + assert_eq!(db.len(), 1, "first digest always fires, as one chunk"); + db.remove(0) + }; // Each peer reacts to the other's digest with a PullRequest for // what it's missing. @@ -893,4 +926,93 @@ mod tests { assert!(got_ids.contains(nid), "missing requested record {nid:?}"); } } + + // (j) #44 fix: a directory holding more than `MAX_GOSSIP_DIGEST_ENTRIES` + // records must not produce one unboundedly large `Digest` (which, once + // obfuscated, panics `yip_obf::obfuscate`'s `u16` length cast, and which + // in any case can't converge because it exceeds the MTU). `tick_digest` + // must instead split into multiple capped `Digest` messages whose union + // still covers every directory node_id exactly once. + #[test] + fn tick_digest_chunks_large_directory() { + let ca = ca_key(1); + let net = [7u8; 16]; + let (mut m, own_nid) = fresh_membership(&ca, net, 10); + + let member_sign_key = SigningKey::from_bytes(&[201u8; 32]); + let member_sign_pub = member_sign_key.verifying_key().to_bytes(); + + // Own record is already in the directory; mint enough additional + // records to push the total strictly past the per-chunk cap. + let extra = MAX_GOSSIP_DIGEST_ENTRIES * 2 + 5; + let mut expected_ids = std::collections::HashSet::new(); + expected_ids.insert(own_nid); + for i in 0..extra { + let mut member_pk = [0u8; 32]; + let idx = u16::try_from(i).expect("test count fits u16"); + member_pk[0..2].copy_from_slice(&(100 + idx).to_be_bytes()); + let cert = make_cert(&ca, member_pk, member_sign_pub, net, 0, 1_000_000); + let endpoints = vec![format!("192.0.2.1:{}", 10_000 + i).parse().unwrap()]; + let rec = build_signed_record(cert, endpoints, 1, &member_sign_key.to_bytes()); + expected_ids.insert(rec.node_id); + assert!(m.ingest_record(rec, 500)); + } + + // Past the debounce, so the digest fires. + let digests = m.tick_digest(1_000, false); + assert!( + digests.len() > 1, + "a directory larger than one chunk must split across multiple Digest messages" + ); + + let mut got_ids = std::collections::HashSet::new(); + for msg in &digests { + match msg { + GossipMsg::Digest(entries) => { + assert!( + entries.len() <= MAX_GOSSIP_DIGEST_ENTRIES, + "each Digest message must respect the per-chunk cap" + ); + for (nid, _seq) in entries { + got_ids.insert(*nid); + } + } + other => panic!("expected Digest, got {other:?}"), + } + } + assert_eq!( + got_ids, expected_ids, + "the union of all Digest chunks must cover every directory node_id exactly once" + ); + } + + // (k) a directory under the cap still yields exactly ONE digest, and an + // empty/too-soon debounce yields an empty Vec (not a lone empty Digest). + #[test] + fn tick_digest_single_chunk_under_cap_and_debounced_empty() { + let ca = ca_key(1); + let net = [7u8; 16]; + let (mut m, _own_nid) = fresh_membership(&ca, net, 10); + + // Own record only: well under the cap. + let digests = m.tick_digest(1_000, false); + assert_eq!( + digests.len(), + 1, + "a directory under the cap yields exactly one Digest" + ); + match &digests[0] { + GossipMsg::Digest(entries) => assert_eq!(entries.len(), 1), + other => panic!("expected Digest, got {other:?}"), + } + + // Immediately again (before GOSSIP_INTERVAL_MS elapses): debounced, + // so no digest at all (empty Vec, not a Vec containing an empty + // Digest). + let debounced = m.tick_digest(1_001, false); + assert!( + debounced.is_empty(), + "a too-soon re-tick must yield an empty Vec" + ); + } } diff --git a/bin/yipd/src/peer_manager.rs b/bin/yipd/src/peer_manager.rs index 37ed59f..bd902ec 100644 --- a/bin/yipd/src/peer_manager.rs +++ b/bin/yipd/src/peer_manager.rs @@ -2332,13 +2332,17 @@ impl PeerManager { .iter() .any(|p| matches!(p.state, PeerState::Established(_))); - // Debounced digest (spacing handled inside `tick_digest`). + // Debounced digest (spacing handled inside `tick_digest`), chunked + // (#44) into one or more `GossipMsg::Digest` messages when the + // directory exceeds `MAX_GOSSIP_DIGEST_ENTRIES` — send each chunk to + // every gossip target exactly as a single digest was sent before. let obf_on = self.obf_key.is_some(); - if let Some(digest) = self + let digests: Vec = self .membership .as_mut() - .and_then(|m| m.tick_digest(now_ms, obf_on)) - { + .map(|m| m.tick_digest(now_ms, obf_on)) + .unwrap_or_default(); + for digest in digests { let mut bytes = Vec::new(); bytes.push(PacketType::Gossip as u8); digest.encode(&mut bytes); @@ -2493,27 +2497,46 @@ impl PeerManager { /// dedicated `yip_obf::RDV_TYPE` and the network `obf_key` (the server is /// never an `Established` peer, so it has no session key). Only called on /// the obfuscation-enabled path. - fn obf_egress(&self, dgs: &mut [EgressDatagram]) { + /// + /// Takes `&mut Vec` (not `&mut [_]`) so a datagram whose body can't fit + /// the obf envelope's `u16` length field (#44 fail-soft: `obfuscate` + /// returns `None` instead of panicking) can be dropped from `dgs` + /// outright — unshippable, not merely emptied — rather than left behind + /// as a stray zero-length datagram. `tick_gossip`'s digest chunking + /// already keeps every gossip datagram well under this cap, so in + /// practice this is defense-in-depth, not the expected path. + fn obf_egress(&self, dgs: &mut Vec) { let server = self.rendezvous.as_ref().map(|r| r.server_addr()); - for d in dgs.iter_mut() { + dgs.retain_mut(|d| { if d.bytes.is_empty() { - continue; + return true; } if Some(d.dst) == server { let Some(key) = self.obf_key else { - continue; + return true; }; let pad = random_pad(obf_pad_max(yip_obf::RDV_TYPE, d.bytes.len() + 1)); - d.bytes = yip_obf::obfuscate(&key, yip_obf::RDV_TYPE, &d.bytes, pad); - continue; + return match yip_obf::obfuscate(&key, yip_obf::RDV_TYPE, &d.bytes, pad) { + Some(wrapped) => { + d.bytes = wrapped; + true + } + None => false, + }; } let ptype = d.bytes[0]; let Some(key) = self.obf_key_for_egress(d.dst, ptype) else { - continue; + return true; }; let pad = random_pad(obf_pad_max(ptype, d.bytes.len())); - d.bytes = yip_obf::obfuscate(&key, ptype, &d.bytes[1..], pad); - } + match yip_obf::obfuscate(&key, ptype, &d.bytes[1..], pad) { + Some(wrapped) => { + d.bytes = wrapped; + true + } + None => false, + } + }); } /// Wrap `udp` egress and re-materialize a `DispatchOut` from the owned @@ -6972,7 +6995,8 @@ mod tests { let mut recovered: Option> = None; for dg in &dgs { assert_eq!(dg.bytes[0], PacketType::Data as u8); - let wrapped = yip_obf::obfuscate(&sess, PacketType::Data as u8, &dg.bytes[1..], 0); + let wrapped = yip_obf::obfuscate(&sess, PacketType::Data as u8, &dg.bytes[1..], 0) + .expect("small test body fits u16"); // The wire datagram carries no plaintext PacketType prefix. assert_ne!( wrapped[0], @@ -7026,7 +7050,8 @@ mod tests { PacketType::HandshakeInit as u8, &init_pkt[1..], 32, - ); + ) + .expect("small test body fits u16"); // Arrives from a fresh source address (unknown / not Established). Step // (a) finds no session key; step (b) unmasks the handshake via obf_psk. @@ -7142,7 +7167,8 @@ mod tests { // `obf_egress` on the real egress path); reproduce that one wrap by // hand here to get wire-format bytes for `on_udp`'s ingress test. let plain = pm.build_junk(); - let junk = yip_obf::obfuscate(&sess, yip_obf::JUNK_TYPE, &plain[1..], 0); + let junk = yip_obf::obfuscate(&sess, yip_obf::JUNK_TYPE, &plain[1..], 0) + .expect("small test body fits u16"); let before_tag = pm.by_tag.get(&TAG).copied(); let out = pm.on_udp(peer_ep, &junk, 0); @@ -7182,7 +7208,8 @@ mod tests { // Same rationale as the session-keyed test above: `build_junk()` is // plaintext, so wrap it once by hand to get wire-format bytes. let plain = pm.build_junk(); - let junk = yip_obf::obfuscate(&obf_key, yip_obf::JUNK_TYPE, &plain[1..], 0); + let junk = yip_obf::obfuscate(&obf_key, yip_obf::JUNK_TYPE, &plain[1..], 0) + .expect("small test body fits u16"); let src: SocketAddr = "203.0.113.55:5555".parse().unwrap(); assert!(matches!(pm.on_udp(src, &junk, 0), DispatchOut::None)); assert!(matches!(pm.peers[0].state, PeerState::Idle)); @@ -7535,7 +7562,8 @@ mod tests { // Wrong key: dropped, no candidate learned (rendezvous-only peer // starts in Punching with no candidate address set). let wrong_key = yip_obf::derive_key(&[0x67u8; 32]); - let wrapped_wrong = yip_obf::obfuscate(&wrong_key, yip_obf::RDV_TYPE, &plain, 0); + let wrapped_wrong = yip_obf::obfuscate(&wrong_key, yip_obf::RDV_TYPE, &plain, 0) + .expect("small test body fits u16"); assert!(matches!( pm.on_udp(mock_server(), &wrapped_wrong, 0), DispatchOut::None @@ -7543,7 +7571,8 @@ mod tests { assert_eq!(pm.peers[0].path.candidate(), None); // Right key, wrong ptype: dropped, no candidate learned. - let wrapped_wrong_type = yip_obf::obfuscate(&obf_key, PacketType::Data as u8, &plain, 0); + let wrapped_wrong_type = yip_obf::obfuscate(&obf_key, PacketType::Data as u8, &plain, 0) + .expect("small test body fits u16"); assert!(matches!( pm.on_udp(mock_server(), &wrapped_wrong_type, 0), DispatchOut::None @@ -7551,7 +7580,8 @@ mod tests { assert_eq!(pm.peers[0].path.candidate(), None); // Right key, right type: recovers the PeerInfo and sets the candidate. - let wrapped = yip_obf::obfuscate(&obf_key, yip_obf::RDV_TYPE, &plain, 5); + let wrapped = yip_obf::obfuscate(&obf_key, yip_obf::RDV_TYPE, &plain, 5) + .expect("small test body fits u16"); assert!(matches!( pm.on_udp(mock_server(), &wrapped, 0), DispatchOut::None diff --git a/bin/yipd/src/relay_client.rs b/bin/yipd/src/relay_client.rs index ddc41b3..ed69e37 100644 --- a/bin/yipd/src/relay_client.rs +++ b/bin/yipd/src/relay_client.rs @@ -104,7 +104,8 @@ impl Counter { pub(crate) fn build_register(obf_key: &[u8; 16], node: NodeId, counter: u64) -> Vec { let mut plain = Vec::new(); encode(&Message::Register { node, counter }, &mut plain); - let env = yip_obf::obfuscate(obf_key, yip_obf::RDV_TYPE, &plain, 0); + let env = yip_obf::obfuscate(obf_key, yip_obf::RDV_TYPE, &plain, 0) + .expect("Register body is a small fixed-shape RDV message, always fits u16"); let mut out = Vec::new(); crate::tls::frame_datagram(&env, &mut out).expect("register envelope within frame cap"); out @@ -1017,7 +1018,8 @@ mod tests { }, &mut plain, ); - let env = yip_obf::obfuscate(&obf_key, yip_obf::RDV_TYPE, &plain, 0); + let env = yip_obf::obfuscate(&obf_key, yip_obf::RDV_TYPE, &plain, 0) + .expect("small fixed-shape test body fits u16"); let mut wire = Vec::new(); frame_datagram(&env, &mut wire).expect("frame relay-deliver envelope"); blocking_write_all_tls(&mut stream, &wire); @@ -1134,7 +1136,8 @@ mod tests { let mut body = vec![0u8; len]; read_exact_ssl(&mut tls, &mut body); // Send an inbound obf'd datagram, framed, and hold the conn open briefly. - let deliver = yip_obf::obfuscate(&server_obf, yip_obf::RDV_TYPE, b"inbound-proof", 0); + let deliver = yip_obf::obfuscate(&server_obf, yip_obf::RDV_TYPE, b"inbound-proof", 0) + .expect("small fixed-shape test body fits u16"); let mut framed = Vec::new(); crate::tls::frame_datagram(&deliver, &mut framed).expect("frame"); blocking_write_all_tls(&mut tls, &framed); diff --git a/crates/yip-obf/src/lib.rs b/crates/yip-obf/src/lib.rs index e15110a..864d591 100644 --- a/crates/yip-obf/src/lib.rs +++ b/crates/yip-obf/src/lib.rs @@ -110,9 +110,15 @@ fn random_nonce() -> [u8; NONCE_LEN] { } /// Wrap `(ptype, body)` with `pad_len` random trailing padding bytes. -pub fn obfuscate(key: &[u8; 16], ptype: u8, body: &[u8], pad_len: usize) -> Vec { +/// +/// Returns `None` — instead of panicking — when `body.len()` exceeds +/// `u16::MAX` and so cannot fit the envelope's `body_len` field (#44: a +/// wire-facing daemon function must never panic on an oversized body, e.g. +/// an unbounded gossip digest; the caller is expected to bound/chunk its +/// input, but this is the fail-soft backstop). +pub fn obfuscate(key: &[u8; 16], ptype: u8, body: &[u8], pad_len: usize) -> Option> { let nonce = random_nonce(); - let body_len = u16::try_from(body.len()).expect("body fits u16"); + let body_len = u16::try_from(body.len()).ok()?; // plaintext region: type(1) ‖ body_len(2) ‖ body ‖ padding let mut region = Vec::with_capacity(3 + body.len() + pad_len); region.push(ptype); @@ -126,7 +132,7 @@ pub fn obfuscate(key: &[u8; 16], ptype: u8, body: &[u8], pad_len: usize) -> Vec< let mut out = Vec::with_capacity(NONCE_LEN + region.len()); out.extend_from_slice(&nonce); out.extend_from_slice(®ion); - out + Some(out) } /// Recover `(ptype, body)`, or `None` if too short / length-inconsistent. @@ -154,7 +160,7 @@ mod tests { #[test] fn round_trips_type_and_body() { let key = derive_key(b"network-secret"); - let dg = obfuscate(&key, 2, b"hello world payload", 17); + let dg = obfuscate(&key, 2, b"hello world payload", 17).expect("small test body fits u16"); let (ptype, body) = deobfuscate(&key, &dg).expect("round-trips"); assert_eq!(ptype, 2); assert_eq!(body, b"hello world payload"); @@ -164,7 +170,7 @@ mod tests { fn wrong_key_does_not_recover_body() { let k1 = derive_key(b"secret-a"); let k2 = derive_key(b"secret-b"); - let dg = obfuscate(&k1, 2, b"the real body", 8); + let dg = obfuscate(&k1, 2, b"the real body", 8).expect("small test body fits u16"); // Wrong key yields either None (inconsistent length) or a garbage body, // but MUST NOT recover the real (ptype=2, "the real body"). match deobfuscate(&k2, &dg) { @@ -182,7 +188,9 @@ mod tests { let key = derive_key(b"k"); let n = 512usize; let dgs: Vec> = (0..n) - .map(|_| obfuscate(&key, 2, b"same body every time", 4)) + .map(|_| { + obfuscate(&key, 2, b"same body every time", 4).expect("small test body fits u16") + }) .collect(); let len = dgs[0].len(); for pos in 0..len { @@ -200,7 +208,7 @@ mod tests { let key = derive_key(b"k"); assert_eq!(deobfuscate(&key, &[]), None); assert_eq!(deobfuscate(&key, &[0u8; 3]), None); // < MIN_ENVELOPE - let mut dg = obfuscate(&key, 1, b"abc", 5); + let mut dg = obfuscate(&key, 1, b"abc", 5).expect("small test body fits u16"); dg.truncate(dg.len() - 1); // corrupt length consistency // Must not panic; returns None or a shorter/garbage body, never OOB. let _ = deobfuscate(&key, &dg); @@ -209,8 +217,8 @@ mod tests { #[test] fn pad_len_changes_size_but_not_recovered_body() { let key = derive_key(b"k"); - let a = obfuscate(&key, 0, b"x", 0); - let b = obfuscate(&key, 0, b"x", 200); + let a = obfuscate(&key, 0, b"x", 0).expect("small test body fits u16"); + let b = obfuscate(&key, 0, b"x", 200).expect("small test body fits u16"); assert!(b.len() > a.len()); assert_eq!(deobfuscate(&key, &a).unwrap().1, b"x"); assert_eq!(deobfuscate(&key, &b).unwrap().1, b"x"); @@ -249,13 +257,29 @@ mod tests { assert!(a.iter().any(|&x| x != 0), "not all-zero"); } + #[test] + fn obfuscate_is_fail_soft_on_oversized_body() { + // Bug #44: `obfuscate` used to `.expect("body fits u16")` on the + // length cast, so a body > u16::MAX (e.g. an unbounded full-directory + // gossip digest) panicked the daemon. It must instead fail soft. + let key = derive_key(b"k"); + let oversized = vec![0u8; 65536]; // 65536 > u16::MAX (65535) + assert_eq!( + obfuscate(&key, 2, &oversized, 0), + None, + "a body that cannot fit the u16 length field must yield None, not panic" + ); + // A normal small body still round-trips as Some(..). + assert!(obfuscate(&key, 2, b"small body", 0).is_some()); + } + #[test] fn junk_datagram_deobfuscates_to_junk_type() { let key = derive_key(b"net"); let mut r = XorShift64::from_getrandom(); let mut body = [0u8; 128]; r.fill(&mut body); - let dg = obfuscate(&key, JUNK_TYPE, &body, 7); + let dg = obfuscate(&key, JUNK_TYPE, &body, 7).expect("small test body fits u16"); let (pt, _b) = deobfuscate(&key, &dg).expect("round-trips"); assert_eq!(pt, JUNK_TYPE); }