Skip to main content

majordomus_cli/mesh/
protocol.rs

1//! The discovery wire protocol: one envelope, every transport. A multicast datagram, a
2//! broadcast datagram and a rendezvous candidate all carry the same signed advertisement,
3//! so there is one serializer, one parser and one verification path — a second protocol
4//! per transport is exactly the five-truths defect ADR 0050 refuses.
5//!
6//! An advertisement says "a Majordomus node exists, here is its public key, here is where
7//! it answers, and the key signs all of it". It grants nothing: trust is evaluated above
8//! this module, and authorization does not exist at this layer at all. It never carries a
9//! secret, a repository's content, or an environment.
10//!
11//! The parser is written for hostile input: bounded before it is read, refused before it
12//! is trusted, and no input of any shape panics — a property test holds that.
13//!
14//! ```
15//! use majordomus_cli::mesh::identity::NodeIdentity;
16//! use majordomus_cli::mesh::protocol::{advertise, encode, parse, Refusal, MAX_DATAGRAM};
17//!
18//! let node = NodeIdentity::ephemeral().unwrap();
19//! let envelope = advertise(&node, 1, &["127.0.0.1:8741".into()], &[], &[], "docs");
20//! let bytes = encode(&envelope).unwrap();
21//! assert!(bytes.len() <= MAX_DATAGRAM);
22//! assert!(parse(&bytes).is_ok());
23//! assert_eq!(parse(&vec![0u8; MAX_DATAGRAM + 1]), Err(Refusal::Oversized));
24//! ```
25
26use schemars::JsonSchema;
27use serde::{Deserialize, Serialize};
28
29use super::identity::{node_id_of_key, InstanceId, NodeId, NodeIdentity};
30use super::MeshError;
31
32/// The protocol version this executable writes. Version 2 added the runtime slot (`rt`),
33/// so that two servers of one machine — two worktrees — are two runtimes rather than one
34/// node restarting forever. A version-1 reader refuses a version-2 datagram as `version`,
35/// which is counted and explicit; a version-2 reader reads both.
36pub const PROTOCOL_VERSION: u32 = 2;
37
38/// The oldest protocol version this executable reads. A version-1 advertisement has no
39/// runtime slot and is recorded as the node's unnamed runtime.
40pub const MIN_PROTOCOL_VERSION: u32 = 1;
41
42/// The largest datagram the protocol sends or reads, in bytes. A packet above this is
43/// refused before parsing: the bound is the first defense, not the parser.
44pub const MAX_DATAGRAM: usize = 1200;
45
46/// How far an advertisement's timestamp may sit from the reader's clock, in seconds,
47/// before the packet is stale (or from the future) and ignored.
48pub const MAX_CLOCK_SKEW_SECONDS: u64 = 300;
49
50/// The most endpoints one advertisement may carry.
51pub const MAX_ENDPOINTS: usize = 4;
52
53/// The advertised body: everything the signature covers. Field names are one or two
54/// characters because this travels in a single UDP datagram; the types above the wire
55/// spell things out.
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
57pub struct Advertisement {
58    /// Protocol version.
59    pub v: u32,
60    /// The Ed25519 public key, hex. The node id is derived from it, never carried:
61    /// a field that could disagree with the key is a field somebody will forge.
62    pub pk: String,
63    /// This run of the node.
64    pub inst: InstanceId,
65    /// A human name for the node. Presentation only.
66    #[serde(default)]
67    pub name: String,
68    /// Monotonic per instance: a replayed datagram carries a counter the reader has
69    /// already seen and is dropped.
70    pub seq: u64,
71    /// Seconds since the Unix epoch at signing time.
72    pub ts: u64,
73    /// Where this node answers HTTP, as `host:port` authorities, at most
74    /// [`MAX_ENDPOINTS`]. Advisory: an endpoint is where to *try*, identity is the key.
75    #[serde(default)]
76    pub ep: Vec<String>,
77    /// The transports the node serves: `http`, `mcp`, `ws`. Sanitized capability
78    /// awareness, never an authorization.
79    #[serde(default)]
80    pub caps: Vec<String>,
81    /// The repositories this node serves, as mesh repository ids (32 hex, see
82    /// [`super::repository`]): derived from the repository's root commits or its declared
83    /// identity, never from a path, so that one repository cloned on two machines is
84    /// recognised as one. A digest discloses nothing; a reader that serves the same
85    /// repository recognises it, anyone else learns only "some repo".
86    #[serde(default)]
87    pub repos: Vec<String>,
88    /// The executable version, informational.
89    #[serde(default)]
90    pub ver: String,
91    /// The runtime slot (16 hex): which server of this node — one per checkout. Empty in
92    /// version 1. Omitted from the signed bytes when empty, so a version-1 signature
93    /// verifies unchanged.
94    #[serde(default, skip_serializing_if = "String::is_empty")]
95    pub rt: String,
96}
97
98/// The wire envelope: the advertisement plus its signature.
99#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
100pub struct Envelope {
101    /// The signed body.
102    #[serde(flatten)]
103    pub adv: Advertisement,
104    /// Ed25519 over [`Advertisement::signing_bytes`], hex.
105    pub sig: String,
106}
107
108impl Advertisement {
109    /// The bytes the signature covers: the canonical JSON of the advertisement alone.
110    /// Field order is the struct's, which serde keeps stable, so signer and verifier
111    /// serialize identically without a second canonicalization scheme.
112    pub fn signing_bytes(&self) -> Vec<u8> {
113        serde_json::to_vec(self).unwrap_or_default()
114    }
115
116    /// The node id this advertisement proves, when its key parses.
117    pub fn node_id(&self) -> Option<NodeId> {
118        node_id_of_key(&self.pk)
119    }
120}
121
122/// Build and sign this node's advertisement, for the node's unnamed runtime.
123#[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/// Build and sign the advertisement of one runtime of this node.
136///
137/// ```
138/// use majordomus_cli::mesh::identity::NodeIdentity;
139/// use majordomus_cli::mesh::protocol::{advertise_as, encode, parse};
140/// let node = NodeIdentity::ephemeral().unwrap();
141/// let envelope = advertise_as(&node, "00000000000000aa", 1, &[], &[], &[], "docs");
142/// assert_eq!(parse(&encode(&envelope).unwrap()).unwrap().adv.rt, "00000000000000aa");
143/// ```
144#[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
171/// Encode an envelope for the wire. An envelope that does not fit [`MAX_DATAGRAM`] is an
172/// error at the sender — the receiver's bound must never be the first place it is felt.
173pub 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/// Why a packet was refused. Every reason is a counter somewhere: refusals are the
185/// diagnosis of a hostile network, not silence.
186#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
187#[serde(rename_all = "snake_case")]
188pub enum Refusal {
189    /// Longer than [`MAX_DATAGRAM`].
190    Oversized,
191    /// Not the JSON shape of an envelope.
192    Malformed,
193    /// A protocol version this executable does not read.
194    Version,
195    /// More endpoints than [`MAX_ENDPOINTS`], or an endpoint that is not `host:port`,
196    /// or an oversized field.
197    Bounds,
198    /// The timestamp sits outside [`MAX_CLOCK_SKEW_SECONDS`].
199    Stale,
200    /// The signature does not verify against the carried key.
201    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
218/// Parse and verify one datagram. `Ok` means: well-formed, in bounds, current, and the
219/// signature proves the key. It does NOT mean trusted — trust policy runs above.
220///
221/// ```
222/// use majordomus_cli::mesh::protocol::{parse, Refusal};
223/// assert_eq!(parse(b"not an envelope"), Err(Refusal::Malformed));
224/// ```
225pub fn parse(bytes: &[u8]) -> Result<Envelope, Refusal> {
226    parse_at(bytes, now())
227}
228
229/// [`parse`] against an explicit clock, so staleness is a tested property rather than a
230/// property of the test machine's clock.
231pub 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
268/// `host:port` with a numeric port and a non-empty host, nothing more clever. The
269/// endpoint is advisory; this bound only keeps garbage out of listings.
270fn 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
280/// Seconds since the Unix epoch, the clock advertisements are stamped and judged with;
281/// zero when the system clock sits before the epoch, which only ever refuses packets.
282pub 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        // A node id is a digest of the key: carrying somebody else's id is impossible
332        // because the id is never carried at all.
333        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        // The deterministic edge of the fuzz property; the proptest below widens it.
387        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}