1use schemars::JsonSchema;
27use serde::{Deserialize, Serialize};
28
29use super::identity::{node_id_of_key, InstanceId, NodeId, NodeIdentity};
30use super::MeshError;
31
32pub const PROTOCOL_VERSION: u32 = 2;
37
38pub const MIN_PROTOCOL_VERSION: u32 = 1;
41
42pub const MAX_DATAGRAM: usize = 1200;
45
46pub const MAX_CLOCK_SKEW_SECONDS: u64 = 300;
49
50pub const MAX_ENDPOINTS: usize = 4;
52
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
57pub struct Advertisement {
58 pub v: u32,
60 pub pk: String,
63 pub inst: InstanceId,
65 #[serde(default)]
67 pub name: String,
68 pub seq: u64,
71 pub ts: u64,
73 #[serde(default)]
76 pub ep: Vec<String>,
77 #[serde(default)]
80 pub caps: Vec<String>,
81 #[serde(default)]
87 pub repos: Vec<String>,
88 #[serde(default)]
90 pub ver: String,
91 #[serde(default, skip_serializing_if = "String::is_empty")]
95 pub rt: String,
96}
97
98#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
100pub struct Envelope {
101 #[serde(flatten)]
103 pub adv: Advertisement,
104 pub sig: String,
106}
107
108impl Advertisement {
109 pub fn signing_bytes(&self) -> Vec<u8> {
113 serde_json::to_vec(self).unwrap_or_default()
114 }
115
116 pub fn node_id(&self) -> Option<NodeId> {
118 node_id_of_key(&self.pk)
119 }
120}
121
122#[allow(clippy::too_many_arguments)]
124pub fn advertise(
125 identity: &NodeIdentity,
126 seq: u64,
127 endpoints: &[String],
128 caps: &[String],
129 repos: &[String],
130 version: &str,
131) -> Envelope {
132 advertise_as(identity, "", seq, endpoints, caps, repos, version)
133}
134
135#[allow(clippy::too_many_arguments)]
145pub fn advertise_as(
146 identity: &NodeIdentity,
147 runtime: &str,
148 seq: u64,
149 endpoints: &[String],
150 caps: &[String],
151 repos: &[String],
152 version: &str,
153) -> Envelope {
154 let adv = Advertisement {
155 rt: runtime.into(),
156 v: PROTOCOL_VERSION,
157 pk: identity.public.public_key.clone(),
158 inst: identity.public.instance_id.clone(),
159 name: identity.public.display_name.clone(),
160 seq,
161 ts: now(),
162 ep: endpoints.iter().take(MAX_ENDPOINTS).cloned().collect(),
163 caps: caps.to_vec(),
164 repos: repos.to_vec(),
165 ver: version.into(),
166 };
167 let sig = identity.sign(&adv.signing_bytes());
168 Envelope { adv, sig }
169}
170
171pub fn encode(envelope: &Envelope) -> Result<Vec<u8>, MeshError> {
174 let bytes = serde_json::to_vec(envelope).map_err(|e| MeshError::Protocol(e.to_string()))?;
175 if bytes.len() > MAX_DATAGRAM {
176 return Err(MeshError::Protocol(format!(
177 "an advertisement of {} bytes exceeds the {MAX_DATAGRAM}-byte datagram bound",
178 bytes.len()
179 )));
180 }
181 Ok(bytes)
182}
183
184#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
187#[serde(rename_all = "snake_case")]
188pub enum Refusal {
189 Oversized,
191 Malformed,
193 Version,
195 Bounds,
198 Stale,
200 Signature,
202}
203
204impl std::fmt::Display for Refusal {
205 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
206 let word = match self {
207 Refusal::Oversized => "oversized",
208 Refusal::Malformed => "malformed",
209 Refusal::Version => "version",
210 Refusal::Bounds => "bounds",
211 Refusal::Stale => "stale",
212 Refusal::Signature => "signature",
213 };
214 f.write_str(word)
215 }
216}
217
218pub fn parse(bytes: &[u8]) -> Result<Envelope, Refusal> {
226 parse_at(bytes, now())
227}
228
229pub fn parse_at(bytes: &[u8], clock: u64) -> Result<Envelope, Refusal> {
232 if bytes.len() > MAX_DATAGRAM {
233 return Err(Refusal::Oversized);
234 }
235 let envelope: Envelope = serde_json::from_slice(bytes).map_err(|_| Refusal::Malformed)?;
236 let adv = &envelope.adv;
237 if adv.v < MIN_PROTOCOL_VERSION || adv.v > PROTOCOL_VERSION {
238 return Err(Refusal::Version);
239 }
240 let runtime_ok = adv.rt.is_empty()
241 || (adv.v >= 2
242 && adv.rt.len() == 16
243 && adv
244 .rt
245 .bytes()
246 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)));
247 if !runtime_ok
248 || adv.ep.len() > MAX_ENDPOINTS
249 || adv.ep.iter().any(|e| !plausible_authority(e))
250 || adv.name.len() > 64
251 || adv.caps.len() > 8
252 || adv.caps.iter().any(|c| c.len() > 16)
253 || adv.repos.len() > 16
254 || adv.repos.iter().any(|r| r.len() > 64)
255 || adv.ver.len() > 32
256 {
257 return Err(Refusal::Bounds);
258 }
259 if adv.ts.abs_diff(clock) > MAX_CLOCK_SKEW_SECONDS {
260 return Err(Refusal::Stale);
261 }
262 if !super::identity::verify(&adv.pk, &adv.signing_bytes(), &envelope.sig) {
263 return Err(Refusal::Signature);
264 }
265 Ok(envelope)
266}
267
268fn plausible_authority(text: &str) -> bool {
271 if text.len() > 64 {
272 return false;
273 }
274 let Some((host, port)) = text.rsplit_once(':') else {
275 return false;
276 };
277 !host.is_empty() && port.parse::<u16>().is_ok()
278}
279
280pub fn now() -> u64 {
283 std::time::SystemTime::now()
284 .duration_since(std::time::UNIX_EPOCH)
285 .map(|d| d.as_secs())
286 .unwrap_or(0)
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292
293 fn identity() -> NodeIdentity {
294 let dir = tempfile::tempdir().unwrap();
295 NodeIdentity::load_or_create(&dir.path().join("node.json")).unwrap()
296 }
297
298 fn sample(identity: &NodeIdentity) -> Envelope {
299 advertise(
300 identity,
301 1,
302 &["127.0.0.1:8741".into()],
303 &["http".into(), "mcp".into()],
304 &[],
305 "0.5.0",
306 )
307 }
308
309 #[test]
310 fn a_signed_advertisement_round_trips() {
311 let id = identity();
312 let envelope = sample(&id);
313 let bytes = encode(&envelope).unwrap();
314 assert!(bytes.len() <= MAX_DATAGRAM);
315 let parsed = parse(&bytes).expect("a fresh signed envelope parses");
316 assert_eq!(parsed.adv, envelope.adv);
317 assert_eq!(parsed.adv.node_id(), Some(id.public.node_id.clone()));
318 }
319
320 #[test]
321 fn a_tampered_envelope_is_refused_as_signature() {
322 let id = identity();
323 let mut envelope = sample(&id);
324 envelope.adv.name = "impostor".into();
325 let bytes = serde_json::to_vec(&envelope).unwrap();
326 assert_eq!(parse(&bytes), Err(Refusal::Signature));
327 }
328
329 #[test]
330 fn spoofing_a_key_moves_the_node_id() {
331 let a = identity();
334 let b = identity();
335 let envelope = sample(&a);
336 assert_ne!(envelope.adv.node_id(), Some(b.public.node_id));
337 }
338
339 #[test]
340 fn an_oversized_packet_is_refused_before_parsing() {
341 let bytes = vec![b'x'; MAX_DATAGRAM + 1];
342 assert_eq!(parse(&bytes), Err(Refusal::Oversized));
343 }
344
345 #[test]
346 fn garbage_is_malformed_not_a_panic() {
347 assert_eq!(parse(b"not json"), Err(Refusal::Malformed));
348 assert_eq!(parse(b""), Err(Refusal::Malformed));
349 assert_eq!(parse(b"{}"), Err(Refusal::Malformed));
350 }
351
352 #[test]
353 fn a_wrong_version_is_refused_without_a_signature_check() {
354 let id = identity();
355 let mut envelope = sample(&id);
356 envelope.adv.v = 99;
357 let bytes = serde_json::to_vec(&envelope).unwrap();
358 assert_eq!(parse(&bytes), Err(Refusal::Version));
359 }
360
361 #[test]
362 fn a_stale_timestamp_is_ignored() {
363 let id = identity();
364 let envelope = sample(&id);
365 let bytes = encode(&envelope).unwrap();
366 assert_eq!(
367 parse_at(&bytes, envelope.adv.ts + MAX_CLOCK_SKEW_SECONDS + 1),
368 Err(Refusal::Stale)
369 );
370 assert!(parse_at(&bytes, envelope.adv.ts + 5).is_ok());
371 }
372
373 #[test]
374 fn endpoint_bounds_hold() {
375 let id = identity();
376 let mut envelope = sample(&id);
377 envelope.adv.ep = vec!["no-port".into()];
378 let sig = id.sign(&envelope.adv.signing_bytes());
379 envelope.sig = sig;
380 let bytes = serde_json::to_vec(&envelope).unwrap();
381 assert_eq!(parse(&bytes), Err(Refusal::Bounds));
382 }
383
384 #[test]
385 fn arbitrary_bytes_never_panic() {
386 for bytes in [
388 &[0xffu8, 0xfe, 0x00][..],
389 b"[1,2,3]",
390 b"{\"v\":1}",
391 "🦀🦀🦀".as_bytes(),
392 ] {
393 let _ = parse(bytes);
394 }
395 }
396
397 proptest::proptest! {
398 #[test]
399 fn parse_never_panics_on_any_input(bytes in proptest::collection::vec(proptest::prelude::any::<u8>(), 0..1400)) {
400 let _ = parse(&bytes);
401 }
402 }
403}