majordomus_cli/mesh/link.rs
1//! The link protocol: what two runtimes say to become linked, and what they exchange once
2//! they are. Discovery ([`super::protocol`]) proves that a key exists somewhere; a link
3//! proves that the key is answering *now*, serves the *same repository*, speaks a
4//! *compatible protocol*, and is *trusted* — and only then does anything cooperative
5//! travel.
6//!
7//! Two exchanges, both request–answer, both signed end to end:
8//!
9//! 1. **Hello → Welcome.** The dialer presents its [`RuntimeCard`] (key, runtime,
10//! instance, repository, version, features, endpoints), a fresh nonce and its supported
11//! protocol range, signed. The answerer verifies the signature, freshness and nonce
12//! (a replayed hello is refused), negotiates the protocol, refuses another repository,
13//! itself, or an untrusted key — each with a typed [`RefusalCode`] — and otherwise
14//! answers with its own card, a link id, its marks, and the dialer's nonce, signed. The
15//! dialer verifies all of it and applies the same checks the other way. Mutual:
16//! neither side is linked on the other's word alone.
17//! 2. **Sync ↔ Sync.** The dialer sends its marks and the events the answerer lacks,
18//! under the link id with a rising counter, signed; the answerer ingests, answers with
19//! its marks and the events the dialer lacks, signed. One round is one heartbeat and
20//! one full replication step in both directions, so a link works even when only one
21//! side can reach the other.
22//!
23//! Every message is a [`Signed`] body: canonical JSON under a per-message domain
24//! separator, so a signature over one message kind can never be replayed as another.
25//! Discovery never grants any of this: a refused hello is recorded, visible, and links
26//! nothing (`project.mesh-cooperation-is-authenticated`).
27//!
28//! ```
29//! use majordomus_cli::mesh::identity::NodeIdentity;
30//! use majordomus_cli::mesh::link::{negotiate, sign, verify_signed, Domain};
31//!
32//! let node = NodeIdentity::ephemeral().unwrap();
33//! let signed = sign(&node, Domain::Hello, serde_json::json!({"hello": 1}));
34//! assert!(verify_signed(&node.public.public_key, Domain::Hello, &signed));
35//! assert!(!verify_signed(&node.public.public_key, Domain::Welcome, &signed), "domains do not cross");
36//! assert_eq!(negotiate(1, 3), Some(1));
37//! assert_eq!(negotiate(2, 3), None);
38//! ```
39
40use std::time::Duration;
41
42use schemars::JsonSchema;
43use serde::{Deserialize, Serialize};
44use serde_json::Value;
45
46use super::identity::{node_id_of_key, verify, NodeIdentity};
47use super::journal::{canonical_json, IngestReport, Marks, MeshEvent, StreamId};
48
49/// The oldest link protocol version this executable speaks.
50pub const LINK_PROTOCOL_MIN: u32 = 1;
51
52/// The newest link protocol version this executable speaks.
53pub const LINK_PROTOCOL_MAX: u32 = 1;
54
55/// The route a hello is posted to.
56pub const HELLO_PATH: &str = "/api/v1/mesh/link/hello";
57
58/// The route a sync round is posted to.
59pub const SYNC_PATH: &str = "/api/v1/mesh/link/sync";
60
61/// What this executable's links can carry. A peer lacking a feature is told so with
62/// [`RefusalCode::FeatureUnsupported`] when an operation needs it of that peer.
63pub const FEATURES: &[&str] = &["sessions", "claims", "handovers", "reviews"];
64
65/// The largest link message, serialized, in bytes: under the HTTP server's one-megabyte
66/// body bound, so the protocol refuses before the transport has to.
67pub const MAX_LINK_MESSAGE: usize = 900 * 1024;
68
69/// The bytes of events one sync message carries at most; the rest follow next round. Never
70/// more than [`MAX_LINK_MESSAGE`] leaves once the rest of the message is serialized: the
71/// marks of a journal at its stream bounds weigh hundreds of kilobytes (I2139).
72pub const SYNC_EVENT_BUDGET: usize = 600 * 1024;
73
74/// How far a hello's or welcome's timestamp may sit from the reader's clock.
75pub const MAX_LINK_SKEW_SECONDS: u64 = super::protocol::MAX_CLOCK_SKEW_SECONDS;
76
77/// One request's bound.
78pub const REQUEST_TIMEOUT: Duration = Duration::from_secs(5);
79
80/// The message kinds, each its own signing domain. The domain is mixed into the bytes a
81/// signature covers, so a signature made over one kind of message cannot be presented as
82/// another: a captured welcome cannot be replayed as a sync answer even though both are
83/// signed by the same key.
84///
85/// ```
86/// use majordomus_cli::mesh::identity::NodeIdentity;
87/// use majordomus_cli::mesh::link::{sign, verify_signed, Domain};
88///
89/// let node = NodeIdentity::ephemeral().unwrap();
90/// let hello = sign(&node, Domain::Hello, serde_json::json!({ "proto_min": 1 }));
91/// assert!(verify_signed(&node.public.public_key, Domain::Hello, &hello));
92/// for other in [Domain::Welcome, Domain::SyncRequest, Domain::SyncAnswer] {
93/// assert!(!verify_signed(&node.public.public_key, other, &hello), "domains do not cross");
94/// }
95/// ```
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97pub enum Domain {
98 /// A dialer's hello.
99 Hello,
100 /// An answerer's welcome.
101 Welcome,
102 /// A dialer's sync round.
103 SyncRequest,
104 /// An answerer's sync answer.
105 SyncAnswer,
106}
107
108impl Domain {
109 fn prefix(self) -> &'static [u8] {
110 match self {
111 Domain::Hello => b"majordomus-mesh-link-hello/v1\n",
112 Domain::Welcome => b"majordomus-mesh-link-welcome/v1\n",
113 Domain::SyncRequest => b"majordomus-mesh-link-sync-request/v1\n",
114 Domain::SyncAnswer => b"majordomus-mesh-link-sync-answer/v1\n",
115 }
116 }
117}
118
119/// A signed message: a JSON body and the Ed25519 signature over its canonical bytes under
120/// the message's domain. The body stays JSON so that fields a newer peer adds survive
121/// verification here unchanged.
122///
123/// Keeping the body as JSON rather than as the bytes that were signed is what makes the
124/// protocol extensible: a field a newer peer added is carried through verification
125/// untouched, because the signature is checked over the canonical form of whatever is
126/// there rather than over a shape this version knows.
127///
128/// ```
129/// use majordomus_cli::mesh::identity::NodeIdentity;
130/// use majordomus_cli::mesh::link::{sign, verify_signed, Domain, Signed};
131///
132/// let node = NodeIdentity::ephemeral().unwrap();
133/// let signed: Signed = sign(&node, Domain::SyncRequest, serde_json::json!({ "b": 2, "a": 1 }));
134/// assert_eq!(signed.sig.len(), 128, "hex Ed25519");
135///
136/// // key order is not content, so a round trip through JSON still verifies
137/// let wire = serde_json::to_string(&signed).unwrap();
138/// let back: Signed = serde_json::from_str(&wire).unwrap();
139/// assert!(verify_signed(&node.public.public_key, Domain::SyncRequest, &back));
140/// ```
141#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
142pub struct Signed {
143 /// The message.
144 pub body: Value,
145 /// Hex Ed25519 over the domain and the canonical JSON of `body`.
146 pub sig: String,
147}
148
149/// Sign `body` as a message of `domain`. The signature is taken over the domain separator
150/// followed by the canonical JSON of the body, so two peers that serialize the same facts
151/// in different key orders still agree about what was signed, and nothing signed for one
152/// exchange verifies as part of another.
153///
154/// ```
155/// use majordomus_cli::mesh::identity::NodeIdentity;
156/// use majordomus_cli::mesh::link::{sign, verify_signed, Domain};
157///
158/// let node = NodeIdentity::ephemeral().unwrap();
159/// let signed = sign(&node, Domain::Welcome, serde_json::json!({ "link": "abc" }));
160/// assert!(verify_signed(&node.public.public_key, Domain::Welcome, &signed));
161///
162/// // the body travels as given; the signature is what is derived
163/// assert_eq!(signed.body["link"], "abc");
164/// ```
165pub fn sign(identity: &NodeIdentity, domain: Domain, body: Value) -> Signed {
166 let mut bytes = domain.prefix().to_vec();
167 bytes.extend(canonical_json(&body));
168 Signed {
169 sig: identity.sign(&bytes),
170 body,
171 }
172}
173
174/// Whether `signed` is a message of `domain` signed by `public_key`. Three things have to
175/// hold at once — the right key, the right domain, and an untouched body — and this
176/// answers about all three with one boolean, because a link has no use for the difference:
177/// any of them failing means the message does not come from the peer it claims to.
178///
179/// ```
180/// use majordomus_cli::mesh::identity::NodeIdentity;
181/// use majordomus_cli::mesh::link::{sign, verify_signed, Domain};
182///
183/// let node = NodeIdentity::ephemeral().unwrap();
184/// let impostor = NodeIdentity::ephemeral().unwrap();
185/// let mut signed = sign(&node, Domain::Hello, serde_json::json!({ "a": 1, "b": 2 }));
186/// assert!(verify_signed(&node.public.public_key, Domain::Hello, &signed));
187///
188/// // another key did not sign it, and a reordered body is the same body
189/// assert!(!verify_signed(&impostor.public.public_key, Domain::Hello, &signed));
190/// signed.body = serde_json::json!({ "b": 2, "a": 1 });
191/// assert!(verify_signed(&node.public.public_key, Domain::Hello, &signed));
192///
193/// // an edited one is not
194/// signed.body = serde_json::json!({ "b": 3, "a": 1 });
195/// assert!(!verify_signed(&node.public.public_key, Domain::Hello, &signed));
196/// ```
197pub fn verify_signed(public_key: &str, domain: Domain, signed: &Signed) -> bool {
198 let mut bytes = domain.prefix().to_vec();
199 bytes.extend(canonical_json(&signed.body));
200 verify(public_key, &bytes, &signed.sig)
201}
202
203/// The version both sides speak: the highest in the intersection of their ranges, or
204/// `None` when the ranges do not meet.
205///
206/// Taking the highest common version is what lets two executables of different ages link
207/// without either being told about the other: each states the range it speaks and the
208/// newest both understand wins. An inverted range is no range, so a peer that states
209/// nonsense is refused rather than accommodated.
210///
211/// ```
212/// use majordomus_cli::mesh::link::{negotiate, LINK_PROTOCOL_MAX, LINK_PROTOCOL_MIN};
213///
214/// assert_eq!(negotiate(LINK_PROTOCOL_MIN, LINK_PROTOCOL_MAX), Some(LINK_PROTOCOL_MAX));
215/// assert_eq!(negotiate(0, 99), Some(LINK_PROTOCOL_MAX), "the newest both speak");
216/// assert_eq!(negotiate(LINK_PROTOCOL_MAX + 1, LINK_PROTOCOL_MAX + 3), None, "no overlap");
217/// assert_eq!(negotiate(3, 2), None, "an inverted range is no range");
218/// ```
219pub fn negotiate(theirs_min: u32, theirs_max: u32) -> Option<u32> {
220 let low = theirs_min.max(LINK_PROTOCOL_MIN);
221 let high = theirs_max.min(LINK_PROTOCOL_MAX);
222 (theirs_min <= theirs_max && low <= high).then_some(high)
223}
224
225/// What a runtime says about itself on a link: public facts only. The card carries no
226/// claim a peer has to take on faith — the node id is derived from the key rather than
227/// stated, so a card cannot name a node it cannot sign for, and everything else is either
228/// checkable (the repository, the protocol) or advisory (the name, the endpoints).
229///
230/// ```
231/// use majordomus_cli::mesh::identity::NodeIdentity;
232/// use majordomus_cli::mesh::link::{RuntimeCard, FEATURES};
233///
234/// let node = NodeIdentity::ephemeral().unwrap();
235/// let card = RuntimeCard {
236/// pk: node.public.public_key.clone(),
237/// runtime: "00000000000000aa".into(),
238/// instance: node.public.instance_id.as_str().into(),
239/// repo: "r".repeat(32),
240/// name: "laptop".into(),
241/// version: "0.7.0".into(),
242/// features: FEATURES.iter().map(|f| f.to_string()).collect(),
243/// endpoints: vec!["10.0.0.2:8742".into()],
244/// };
245/// assert!(card.check().is_ok());
246/// assert_eq!(card.runtime_key().unwrap(), format!("{}-00000000000000aa", node.public.node_id));
247/// ```
248#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
249pub struct RuntimeCard {
250 /// The node's Ed25519 public key, hex.
251 pub pk: String,
252 /// The runtime slot, 16 hex.
253 pub runtime: String,
254 /// This run of the runtime, 16 hex.
255 pub instance: String,
256 /// The mesh repository id.
257 pub repo: String,
258 /// The node's display name.
259 #[serde(default)]
260 pub name: String,
261 /// The executable version.
262 #[serde(default)]
263 pub version: String,
264 /// The link features this runtime carries.
265 #[serde(default)]
266 pub features: Vec<String>,
267 /// Where this runtime answers, `host:port`.
268 #[serde(default)]
269 pub endpoints: Vec<String>,
270}
271
272impl RuntimeCard {
273 /// The stream this card's runtime writes, when the card is well-formed. The node half
274 /// is derived from the key the card carries rather than read from a field, which is
275 /// what makes a stream id unforgeable: to write under a stream a runtime must hold the
276 /// key that stream is derived from. `None` is the answer for a card whose key, runtime
277 /// or instance is not of the right shape — there is no stream for such a card to write.
278 ///
279 /// ```
280 /// # use majordomus_cli::mesh::identity::NodeIdentity;
281 /// # use majordomus_cli::mesh::link::RuntimeCard;
282 /// # let node = NodeIdentity::ephemeral().unwrap();
283 /// # let card = RuntimeCard {
284 /// # pk: node.public.public_key.clone(),
285 /// # runtime: "00000000000000aa".into(),
286 /// # instance: node.public.instance_id.as_str().into(),
287 /// # repo: "r".repeat(32),
288 /// # name: String::new(),
289 /// # version: String::new(),
290 /// # features: vec![],
291 /// # endpoints: vec![],
292 /// # };
293 /// let stream = card.stream().unwrap();
294 /// assert!(stream.as_str().starts_with(node.public.node_id.as_str()), "derived from the key");
295 ///
296 /// // a runtime slot that is not 16 hex is not a runtime slot
297 /// let malformed = RuntimeCard { runtime: "zz".into(), ..card };
298 /// assert!(malformed.stream().is_none());
299 /// ```
300 pub fn stream(&self) -> Option<StreamId> {
301 let node = node_id_of_key(&self.pk)?;
302 StreamId::new(node.as_str(), &self.runtime, &self.instance)
303 }
304
305 /// `<node>-<runtime>`, when the card is well-formed: the key the peer table is kept
306 /// under. It deliberately drops the instance the stream carries, so that a runtime
307 /// which restarts is recognised as the same peer rather than accumulating an entry per
308 /// run — the instance is how a restart is noticed, not who the peer is.
309 ///
310 /// ```
311 /// # use majordomus_cli::mesh::identity::NodeIdentity;
312 /// # use majordomus_cli::mesh::link::RuntimeCard;
313 /// # let node = NodeIdentity::ephemeral().unwrap();
314 /// # let card = RuntimeCard {
315 /// # pk: node.public.public_key.clone(),
316 /// # runtime: "00000000000000aa".into(),
317 /// # instance: node.public.instance_id.as_str().into(),
318 /// # repo: "r".repeat(32),
319 /// # name: String::new(),
320 /// # version: String::new(),
321 /// # features: vec![],
322 /// # endpoints: vec![],
323 /// # };
324 /// let restarted = RuntimeCard { instance: "0123456789abcdef".into(), ..card.clone() };
325 /// assert_eq!(card.runtime_key(), restarted.runtime_key(), "a restart is the same peer");
326 /// assert_ne!(card.stream(), restarted.stream(), "and a different run of it");
327 /// ```
328 pub fn runtime_key(&self) -> Option<String> {
329 self.stream().map(|s| s.runtime_key())
330 }
331
332 /// Whether the card's runtime carries `feature`. Features are how two executables of
333 /// different ages tell each other what they can do after the protocol version has been
334 /// agreed: an operation that needs something the peer does not carry is refused with
335 /// [`RefusalCode::FeatureUnsupported`], naming the feature, instead of failing
336 /// somewhere further in.
337 ///
338 /// ```
339 /// # use majordomus_cli::mesh::identity::NodeIdentity;
340 /// # use majordomus_cli::mesh::link::{RuntimeCard, FEATURES};
341 /// # let node = NodeIdentity::ephemeral().unwrap();
342 /// # let card = RuntimeCard {
343 /// # pk: node.public.public_key.clone(),
344 /// # runtime: "00000000000000aa".into(),
345 /// # instance: node.public.instance_id.as_str().into(),
346 /// # repo: "r".repeat(32),
347 /// # name: String::new(),
348 /// # version: String::new(),
349 /// # features: FEATURES.iter().map(|f| f.to_string()).collect(),
350 /// # endpoints: vec![],
351 /// # };
352 /// assert!(card.supports("reviews"));
353 /// assert!(!card.supports("teleport"), "an unknown word is not a capability");
354 /// ```
355 pub fn supports(&self, feature: &str) -> bool {
356 self.features.iter().any(|f| f == feature)
357 }
358
359 /// Refuse an out-of-bounds card: a key that does not parse, ids of the wrong shape,
360 /// oversized text.
361 ///
362 /// Every field of a card was written by somebody else, so each is bounded before any
363 /// of it is stored or shown. The bounds are small on purpose — a name, a version, a
364 /// handful of features and endpoints — because nothing legitimate needs more, and a
365 /// card is the first thing a stranger gets to put into this process.
366 ///
367 /// ```
368 /// # use majordomus_cli::mesh::identity::NodeIdentity;
369 /// # use majordomus_cli::mesh::link::RuntimeCard;
370 /// # let node = NodeIdentity::ephemeral().unwrap();
371 /// # let card = RuntimeCard {
372 /// # pk: node.public.public_key.clone(),
373 /// # runtime: "00000000000000aa".into(),
374 /// # instance: node.public.instance_id.as_str().into(),
375 /// # repo: "r".repeat(32),
376 /// # name: String::new(),
377 /// # version: String::new(),
378 /// # features: vec![],
379 /// # endpoints: vec!["10.0.0.2:8742".into()],
380 /// # };
381 /// assert!(card.check().is_ok());
382 /// assert!(RuntimeCard { repo: String::new(), ..card.clone() }.check().is_err());
383 /// assert!(RuntimeCard { name: "n".repeat(500), ..card.clone() }.check().is_err());
384 /// assert!(
385 /// RuntimeCard { endpoints: vec!["no-port".into()], ..card }.check().is_err(),
386 /// "an endpoint without a port is not somewhere to dial",
387 /// );
388 /// ```
389 pub fn check(&self) -> Result<(), String> {
390 if self.stream().is_none() {
391 return Err("the key, runtime or instance is not well-formed".into());
392 }
393 if self.repo.is_empty() || self.repo.len() > 64 {
394 return Err("the repository id is empty or oversized".into());
395 }
396 if self.name.len() > 64
397 || self.version.len() > 32
398 || self.features.len() > 16
399 || self.features.iter().any(|f| f.len() > 32)
400 || self.endpoints.len() > super::protocol::MAX_ENDPOINTS
401 || self
402 .endpoints
403 .iter()
404 .any(|e| e.len() > 64 || !e.contains(':'))
405 {
406 return Err("a field of the card is out of bounds".into());
407 }
408 Ok(())
409 }
410}
411
412/// A dialer's hello: everything the answerer needs to decide whether to link, in one
413/// signed message. The nonce makes the exchange unrepeatable — the welcome must echo it,
414/// and a nonce is never accepted twice — and the timestamp makes it unstorable, since a
415/// hello captured today cannot be presented tomorrow. The protocol range is stated rather
416/// than assumed, so two executables of different ages find their common version here and
417/// nowhere else.
418///
419/// ```
420/// use majordomus_cli::mesh::identity::NodeIdentity;
421/// use majordomus_cli::mesh::link::{fresh_token, sign, verify_signed, Domain, Hello,
422/// RuntimeCard, LINK_PROTOCOL_MAX, LINK_PROTOCOL_MIN};
423///
424/// let node = NodeIdentity::ephemeral().unwrap();
425/// let hello = Hello {
426/// proto_min: LINK_PROTOCOL_MIN,
427/// proto_max: LINK_PROTOCOL_MAX,
428/// card: RuntimeCard {
429/// pk: node.public.public_key.clone(),
430/// runtime: "00000000000000aa".into(),
431/// instance: node.public.instance_id.as_str().into(),
432/// repo: "r".repeat(32),
433/// name: String::new(),
434/// version: String::new(),
435/// features: vec![],
436/// endpoints: vec![],
437/// },
438/// nonce: fresh_token(),
439/// ts: 1_700_000_000,
440/// to: None,
441/// };
442/// assert_eq!(hello.nonce.len(), 32, "32 hex of fresh entropy, echoed by the welcome");
443///
444/// let signed = sign(&node, Domain::Hello, serde_json::to_value(&hello).unwrap());
445/// assert!(verify_signed(&node.public.public_key, Domain::Hello, &signed));
446/// ```
447#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
448pub struct Hello {
449 /// The oldest link protocol the dialer speaks.
450 pub proto_min: u32,
451 /// The newest link protocol the dialer speaks.
452 pub proto_max: u32,
453 /// The dialer.
454 pub card: RuntimeCard,
455 /// 32 hex of fresh entropy: the welcome must echo it, and it is never accepted twice.
456 pub nonce: String,
457 /// The dialer's clock, seconds since the Unix epoch.
458 pub ts: u64,
459 /// The runtime key of the runtime this hello is addressed to, when the dialer knows it:
460 /// inside the signed bytes, so a hello captured on its way to one runtime is refused as
461 /// `misaddressed` by every other. Absent from a dialer that does not know whom it dials
462 /// (a seed, a first contact) and from releases before it existed; such a hello is still
463 /// accepted, and only an addressed one carries the protection.
464 #[serde(default, skip_serializing_if = "Option::is_none")]
465 pub to: Option<String>,
466}
467
468/// An answerer's welcome: the mirror of the hello, plus the two things that make the link
469/// usable. The link id is what the dialer syncs under afterwards, and the marks let the
470/// very first sync carry exactly the events the answerer lacks instead of a round spent
471/// discovering that. The echoed nonce is what binds this welcome to that hello: without
472/// it, a welcome kept from an earlier exchange would answer a hello it never saw.
473///
474/// ```
475/// use std::collections::BTreeMap;
476/// use majordomus_cli::mesh::identity::NodeIdentity;
477/// use majordomus_cli::mesh::link::{fresh_token, sign, verify_signed, Domain, RuntimeCard,
478/// Welcome, LINK_PROTOCOL_MAX};
479///
480/// let node = NodeIdentity::ephemeral().unwrap();
481/// let nonce = fresh_token();
482/// let welcome = Welcome {
483/// proto: LINK_PROTOCOL_MAX,
484/// link: fresh_token(),
485/// card: RuntimeCard {
486/// pk: node.public.public_key.clone(),
487/// runtime: "00000000000000aa".into(),
488/// instance: node.public.instance_id.as_str().into(),
489/// repo: "r".repeat(32),
490/// name: String::new(),
491/// version: String::new(),
492/// features: vec![],
493/// endpoints: vec![],
494/// },
495/// nonce: nonce.clone(),
496/// ts: 1_700_000_000,
497/// marks: BTreeMap::new(),
498/// heartbeat_seconds: 5,
499/// expiry_seconds: 30,
500/// };
501/// assert_eq!(welcome.nonce, nonce, "this welcome answers that hello and no other");
502/// assert!(welcome.marks.is_empty(), "a runtime that has written nothing has nothing to mark");
503///
504/// let signed = sign(&node, Domain::Welcome, serde_json::to_value(&welcome).unwrap());
505/// assert!(verify_signed(&node.public.public_key, Domain::Welcome, &signed));
506/// ```
507#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
508pub struct Welcome {
509 /// The negotiated protocol version.
510 pub proto: u32,
511 /// The link id the dialer syncs under.
512 pub link: String,
513 /// The answerer.
514 pub card: RuntimeCard,
515 /// The hello's nonce, echoed: this welcome answers that hello and no other.
516 pub nonce: String,
517 /// The answerer's clock.
518 pub ts: u64,
519 /// The answerer's marks, so the first sync carries exactly what it lacks.
520 pub marks: Marks,
521 /// The answerer's heartbeat, informational.
522 pub heartbeat_seconds: u64,
523 /// The answerer's expiry, informational.
524 pub expiry_seconds: u64,
525}
526
527/// A dialer's sync round: what this runtime holds, and what it believes the answerer
528/// lacks. Sending the events rather than asking for them is what makes one heartbeat a
529/// full replication step in both directions, so replication keeps working when only one
530/// side can open a connection. The counter refuses a replayed round and the instance
531/// refuses a stale one: a restarted dialer carries a new instance and must say hello
532/// again rather than resume a link the answerer no longer has.
533///
534/// ```
535/// use std::collections::BTreeMap;
536/// use majordomus_cli::mesh::identity::NodeIdentity;
537/// use majordomus_cli::mesh::link::{fresh_token, sign, verify_signed, Domain, SyncRequest};
538///
539/// let node = NodeIdentity::ephemeral().unwrap();
540/// let link = fresh_token();
541/// let round = SyncRequest {
542/// link: link.clone(),
543/// counter: 1,
544/// instance: node.public.instance_id.as_str().into(),
545/// marks: BTreeMap::new(),
546/// events: vec![],
547/// };
548/// let next = SyncRequest { counter: round.counter + 1, ..round.clone() };
549/// assert!(next.counter > round.counter, "a round at or below the last accepted is a replay");
550/// assert_eq!(next.link, link, "every round of one link travels under its id");
551///
552/// let signed = sign(&node, Domain::SyncRequest, serde_json::to_value(&next).unwrap());
553/// assert!(verify_signed(&node.public.public_key, Domain::SyncRequest, &signed));
554/// ```
555#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
556pub struct SyncRequest {
557 /// The link id the welcome gave.
558 pub link: String,
559 /// Rising per link: a request at or below the last accepted counter is a replay.
560 pub counter: u64,
561 /// The dialer's instance: a different one is a restarted runtime that must say hello.
562 pub instance: String,
563 /// The dialer's marks.
564 pub marks: Marks,
565 /// The events the answerer lacks, by the answerer's last marks.
566 pub events: Vec<MeshEvent>,
567}
568
569/// An answerer's sync answer: the other half of the round. Its marks are taken *after*
570/// ingesting the request's events, so the dialer learns in one exchange both what arrived
571/// and what it is still missing. The report travels with it because a silently dropped
572/// event is the failure mode this protocol most needs to make visible: a peer that refuses
573/// what it is sent says so, with reasons and numbers, rather than staying quietly behind.
574///
575/// ```
576/// use std::collections::BTreeMap;
577/// use majordomus_cli::mesh::identity::NodeIdentity;
578/// use majordomus_cli::mesh::journal::IngestReport;
579/// use majordomus_cli::mesh::link::{fresh_token, sign, verify_signed, Domain, SyncAnswer};
580///
581/// let node = NodeIdentity::ephemeral().unwrap();
582/// let answer = SyncAnswer {
583/// link: fresh_token(),
584/// counter: 7,
585/// marks: BTreeMap::new(),
586/// events: vec![],
587/// report: IngestReport::default(),
588/// };
589/// assert_eq!(answer.counter, 7, "the request's counter, echoed");
590/// assert_eq!(answer.report.rejected_total(), 0, "nothing was refused this round");
591///
592/// let signed = sign(&node, Domain::SyncAnswer, serde_json::to_value(&answer).unwrap());
593/// assert!(verify_signed(&node.public.public_key, Domain::SyncAnswer, &signed));
594/// ```
595#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
596pub struct SyncAnswer {
597 /// The link id.
598 pub link: String,
599 /// The request's counter, echoed.
600 pub counter: u64,
601 /// The answerer's marks, after ingesting the request's events.
602 pub marks: Marks,
603 /// The events the dialer lacks, by the request's marks.
604 pub events: Vec<MeshEvent>,
605 /// What ingesting the request's events did.
606 pub report: IngestReport,
607}
608
609/// Why a link message was turned down. Every refusal is typed, counted and shown: a mesh
610/// that refuses a peer says which rule did, instead of dropping a socket.
611///
612/// The codes are the diagnosis a person needs when two runtimes will not link, and they
613/// are deliberately distinct where the remedies differ: `Untrusted` is a key to add,
614/// `RepositoryMismatch` is two different repositories, `SelfLink` is a configuration that
615/// dials its own endpoint, and `UnknownLink` is not a failure at all but an instruction to
616/// say hello again.
617///
618/// ```
619/// use majordomus_cli::mesh::link::RefusalCode;
620///
621/// // the wire word is the code's identity across surfaces, and it is stable
622/// assert_eq!(RefusalCode::RepositoryMismatch.as_str(), "repository_mismatch");
623/// assert_eq!(
624/// serde_json::to_value(RefusalCode::Untrusted).unwrap(),
625/// serde_json::json!("untrusted"),
626/// );
627/// assert_ne!(RefusalCode::Untrusted, RefusalCode::RepositoryMismatch);
628/// ```
629#[derive(
630 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
631)]
632#[serde(rename_all = "snake_case")]
633pub enum RefusalCode {
634 /// Not the shape of the message.
635 Malformed,
636 /// Over [`MAX_LINK_MESSAGE`].
637 Oversized,
638 /// The protocol ranges do not meet.
639 ProtocolUnsupported,
640 /// The signature does not verify.
641 Signature,
642 /// The timestamp is outside the skew window.
643 Stale,
644 /// A nonce or a counter already seen.
645 Replay,
646 /// The peer serves another repository: the isolation rule.
647 RepositoryMismatch,
648 /// The peer's key is not trusted by this runtime's policy: discovery is not trust.
649 Untrusted,
650 /// The peer is this very runtime.
651 SelfLink,
652 /// This runtime's cooperation is not active.
653 NotActive,
654 /// The link id is unknown here: the answerer restarted or expired the link; say hello.
655 UnknownLink,
656 /// A bound of this runtime is full.
657 Capacity,
658 /// The peer lacks a feature the operation needs.
659 FeatureUnsupported,
660 /// A hello addressed to another runtime: captured on its way there and replayed here.
661 Misaddressed,
662}
663
664impl RefusalCode {
665 /// The code's wire word: the snake-case spelling every surface uses, so a refusal
666 /// reads the same in a log line, a JSON answer and a peer's counters. It is written
667 /// out here rather than derived from the variant name, because the word is protocol
668 /// and renaming a variant must not silently change what another runtime reads.
669 ///
670 /// ```
671 /// use majordomus_cli::mesh::link::RefusalCode;
672 ///
673 /// assert_eq!(RefusalCode::ProtocolUnsupported.as_str(), "protocol_unsupported");
674 /// assert_eq!(RefusalCode::SelfLink.as_str(), "self_link");
675 /// assert_eq!(
676 /// serde_json::to_value(RefusalCode::SelfLink).unwrap(),
677 /// serde_json::json!(RefusalCode::SelfLink.as_str()),
678 /// "one spelling, whichever surface prints it",
679 /// );
680 /// ```
681 pub fn as_str(self) -> &'static str {
682 match self {
683 RefusalCode::Malformed => "malformed",
684 RefusalCode::Oversized => "oversized",
685 RefusalCode::ProtocolUnsupported => "protocol_unsupported",
686 RefusalCode::Signature => "signature",
687 RefusalCode::Stale => "stale",
688 RefusalCode::Replay => "replay",
689 RefusalCode::RepositoryMismatch => "repository_mismatch",
690 RefusalCode::Untrusted => "untrusted",
691 RefusalCode::SelfLink => "self_link",
692 RefusalCode::NotActive => "not_active",
693 RefusalCode::UnknownLink => "unknown_link",
694 RefusalCode::Capacity => "capacity",
695 RefusalCode::FeatureUnsupported => "feature_unsupported",
696 RefusalCode::Misaddressed => "misaddressed",
697 }
698 }
699}
700
701/// A refusal: the code, the reason in words, and the protocol range of the refuser so a
702/// version mismatch is diagnosable from either end.
703///
704/// Carrying the refuser's protocol range in every refusal, not only in the one about
705/// protocols, is what makes a version problem visible from the side that has the log: the
706/// dialer sees what the answerer speaks without anyone having to read the other machine.
707///
708/// ```
709/// use majordomus_cli::mesh::link::{LinkRefusal, RefusalCode, LINK_PROTOCOL_MAX};
710///
711/// let refusal = LinkRefusal::new(RefusalCode::Untrusted, "the key is not in the allowlist");
712/// assert_eq!(refusal.code, RefusalCode::Untrusted);
713/// assert_eq!(refusal.proto_max, LINK_PROTOCOL_MAX);
714/// assert_eq!(refusal.to_string(), "untrusted: the key is not in the allowlist");
715/// ```
716#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
717pub struct LinkRefusal {
718 /// The rule that refused.
719 pub code: RefusalCode,
720 /// Why, in words.
721 pub detail: String,
722 /// The refuser's oldest protocol.
723 pub proto_min: u32,
724 /// The refuser's newest protocol.
725 pub proto_max: u32,
726}
727
728impl LinkRefusal {
729 /// A refusal with this executable's protocol range. Every refusal is built here rather
730 /// than by hand, so that no call site can forget the range and leave the other end
731 /// guessing whether it was turned down over a version or over a rule.
732 ///
733 /// ```
734 /// use majordomus_cli::mesh::link::{LinkRefusal, RefusalCode, LINK_PROTOCOL_MAX,
735 /// LINK_PROTOCOL_MIN};
736 ///
737 /// let refusal = LinkRefusal::new(RefusalCode::Capacity, "256 peers already linked");
738 /// assert_eq!((refusal.proto_min, refusal.proto_max), (LINK_PROTOCOL_MIN, LINK_PROTOCOL_MAX));
739 /// assert_eq!(refusal.detail, "256 peers already linked");
740 /// ```
741 pub fn new(code: RefusalCode, detail: impl Into<String>) -> Self {
742 LinkRefusal {
743 code,
744 detail: detail.into(),
745 proto_min: LINK_PROTOCOL_MIN,
746 proto_max: LINK_PROTOCOL_MAX,
747 }
748 }
749}
750
751impl std::fmt::Display for LinkRefusal {
752 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
753 write!(f, "{}: {}", self.code.as_str(), self.detail)
754 }
755}
756
757/// What a hello or a sync answers over the wire: a signed message or a refusal. Both are
758/// successful replies — the transport carries them the same way and neither is an error —
759/// because a refusal is a decision this protocol wants recorded and shown, and an answer
760/// that arrived as an HTTP failure would be indistinguishable from a peer that is down.
761///
762/// ```
763/// use majordomus_cli::mesh::identity::NodeIdentity;
764/// use majordomus_cli::mesh::link::{sign, Domain, LinkReply, RefusalCode};
765///
766/// let node = NodeIdentity::ephemeral().unwrap();
767/// let accepted = LinkReply::accepted(sign(&node, Domain::Welcome, serde_json::json!({})));
768/// assert!(accepted.signed.is_some() && accepted.refusal.is_none());
769///
770/// let refused = LinkReply::refused(RefusalCode::SelfLink, "this runtime dialled itself");
771/// assert!(refused.signed.is_none());
772/// assert_eq!(refused.refusal.unwrap().code, RefusalCode::SelfLink);
773/// ```
774#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
775pub struct LinkReply {
776 /// The signed welcome or sync answer, when accepted.
777 #[serde(default, skip_serializing_if = "Option::is_none")]
778 pub signed: Option<Signed>,
779 /// The refusal, when not.
780 #[serde(default, skip_serializing_if = "Option::is_none")]
781 pub refusal: Option<LinkRefusal>,
782}
783
784impl LinkReply {
785 /// An accepted reply: the signed welcome or sync answer, and no refusal. The two
786 /// halves are built through these constructors rather than by filling the struct in,
787 /// so a reply can never be both at once or neither, which is the one shape a reader
788 /// would have no sensible way to act on.
789 ///
790 /// ```
791 /// use majordomus_cli::mesh::identity::NodeIdentity;
792 /// use majordomus_cli::mesh::link::{sign, Domain, LinkReply};
793 ///
794 /// let node = NodeIdentity::ephemeral().unwrap();
795 /// let reply = LinkReply::accepted(sign(&node, Domain::SyncAnswer, serde_json::json!({})));
796 /// assert!(reply.refusal.is_none(), "an acceptance refuses nothing");
797 /// assert!(reply.signed.unwrap().sig.len() == 128);
798 /// ```
799 pub fn accepted(signed: Signed) -> Self {
800 LinkReply {
801 signed: Some(signed),
802 refusal: None,
803 }
804 }
805
806 /// A refused reply: the typed code that turned the message down, the reason in words,
807 /// and nothing signed. The detail is for the person reading a log and the code is for
808 /// the peer deciding what to do next — retry, say hello again, or stop dialling.
809 ///
810 /// ```
811 /// use majordomus_cli::mesh::link::{LinkReply, RefusalCode};
812 ///
813 /// let reply = LinkReply::refused(RefusalCode::UnknownLink, "say hello again");
814 /// assert!(reply.signed.is_none(), "a refusal signs nothing");
815 /// let refusal = reply.refusal.unwrap();
816 /// assert_eq!(refusal.code, RefusalCode::UnknownLink);
817 /// assert_eq!(refusal.detail, "say hello again");
818 /// ```
819 pub fn refused(code: RefusalCode, detail: impl Into<String>) -> Self {
820 LinkReply {
821 signed: None,
822 refusal: Some(LinkRefusal::new(code, detail)),
823 }
824 }
825}
826
827/// How a link message reaches a peer. The domain above never names a socket: the HTTP
828/// adapter is the one the shared server answers, and a test can put two runtimes in one
829/// process behind another adapter without changing a line of protocol.
830///
831/// One method is the whole contract, which is what keeps the protocol testable: the
832/// authenticity of a link comes from the signatures inside the messages, never from the
833/// channel, so an implementation owes nothing but delivery.
834///
835/// ```
836/// use majordomus_cli::mesh::link::{LinkReply, LinkTransport, RefusalCode, Signed, HELLO_PATH};
837///
838/// // an adapter that reaches nobody is still a transport; it refuses nothing, it fails
839/// struct Unplugged;
840/// impl LinkTransport for Unplugged {
841/// fn post(&self, endpoint: &str, _path: &str, _message: &Signed) -> Result<LinkReply, String> {
842/// Err(format!("{endpoint}: no network here"))
843/// }
844/// }
845///
846/// let signed = Signed { body: serde_json::json!({}), sig: String::new() };
847/// let error = Unplugged.post("10.0.0.2:8742", HELLO_PATH, &signed).unwrap_err();
848/// assert!(error.contains("10.0.0.2:8742"), "a failure names where it was going");
849///
850/// // a refusal, by contrast, is a reply that arrived
851/// let refusal = LinkReply::refused(RefusalCode::Untrusted, "not in the allowlist");
852/// assert!(refusal.refusal.is_some());
853/// ```
854pub trait LinkTransport: Send + Sync {
855 /// Post `message` to `path` at `endpoint` (`host:port`) and return the peer's reply.
856 /// `Err` is a transport failure — unreachable, timed out, not a reply — never a
857 /// refusal; a refusal is an `Ok` reply that says so.
858 ///
859 /// The distinction is what the caller acts on: a transport failure is a peer to back
860 /// off from and try again later, while a refusal is a decision that will be made the
861 /// same way next time and is recorded instead of retried.
862 ///
863 /// ```
864 /// use majordomus_cli::mesh::link::{HttpTransport, LinkTransport, Signed, HELLO_PATH};
865 ///
866 /// let signed = Signed { body: serde_json::json!({}), sig: String::new() };
867 /// // port 9 discards: unreachable is a transport failure, not a refusal
868 /// let error = HttpTransport.post("127.0.0.1:9", HELLO_PATH, &signed).unwrap_err();
869 /// assert!(error.contains("127.0.0.1:9"), "{error}");
870 /// ```
871 fn post(&self, endpoint: &str, path: &str, message: &Signed) -> Result<LinkReply, String>;
872}
873
874/// The transport the shared server answers: plain HTTP/1.1 through the crate's one
875/// minimal client (ADR 0032: no HTTP client dependency, no TLS). A private network or an
876/// overlay provides confidentiality; the protocol provides authenticity.
877///
878/// It refuses an oversized message before opening a socket, so the bound the protocol
879/// states is enforced by the sender too and a peer is never asked to read something it
880/// would have to refuse. An endpoint may be written with or without a scheme, because a
881/// person writing a seed should not have to know which.
882///
883/// ```
884/// use majordomus_cli::mesh::link::{HttpTransport, LinkTransport, Signed, MAX_LINK_MESSAGE,
885/// SYNC_PATH};
886///
887/// let oversized = Signed { body: serde_json::json!("x".repeat(MAX_LINK_MESSAGE)), sig: String::new() };
888/// let error = HttpTransport.post("127.0.0.1:9", SYNC_PATH, &oversized).unwrap_err();
889/// assert!(error.contains("exceeds"), "refused before the socket: {error}");
890/// ```
891pub struct HttpTransport;
892
893impl LinkTransport for HttpTransport {
894 fn post(&self, endpoint: &str, path: &str, message: &Signed) -> Result<LinkReply, String> {
895 let body = serde_json::to_string(message).map_err(|e| e.to_string())?;
896 if body.len() > MAX_LINK_MESSAGE {
897 return Err(format!(
898 "a link message of {} bytes exceeds {MAX_LINK_MESSAGE}",
899 body.len()
900 ));
901 }
902 let base = if endpoint.starts_with("http://") {
903 endpoint.to_string()
904 } else {
905 format!("http://{endpoint}")
906 };
907 let reply = crate::mcp::bridge::request(
908 &base,
909 "POST",
910 path,
911 &[("Content-Type", "application/json")],
912 Some(&body),
913 REQUEST_TIMEOUT,
914 )
915 .map_err(|e| format!("{base}{path}: {e}"))?;
916 if reply.status != 200 {
917 return Err(format!("{base}{path}: status {}", reply.status));
918 }
919 serde_json::from_str(&reply.body)
920 .map_err(|e| format!("{base}{path}: not a link reply: {e}"))
921 }
922}
923
924/// 32 hex characters of OS entropy: nonces and link ids. Both uses need unpredictability
925/// rather than uniqueness alone — a guessable nonce would let a captured hello be answered
926/// in advance, and a guessable link id would let a stranger sync under somebody else's
927/// link. When the OS has no entropy to give, a clock-derived value keeps the link working
928/// and the replay caches still refuse exact repeats.
929///
930/// ```
931/// use majordomus_cli::mesh::link::fresh_token;
932///
933/// let (a, b) = (fresh_token(), fresh_token());
934/// assert_eq!(a.len(), 32);
935/// assert!(a.bytes().all(|c| c.is_ascii_hexdigit()));
936/// assert_ne!(a, b, "a token is fresh, not a counter");
937/// ```
938pub fn fresh_token() -> String {
939 let mut bytes = [0u8; 16];
940 if getrandom::getrandom(&mut bytes).is_err() {
941 // Without entropy a nonce is guessable; a clock-derived fallback keeps the link
942 // working and the replay cache still refuses exact repeats.
943 let nanos = std::time::SystemTime::now()
944 .duration_since(std::time::UNIX_EPOCH)
945 .map(|d| d.as_nanos())
946 .unwrap_or(0);
947 bytes = nanos.to_le_bytes();
948 }
949 bytes.iter().map(|b| format!("{b:02x}")).collect()
950}
951
952#[cfg(test)]
953mod tests {
954 use super::*;
955
956 #[test]
957 fn negotiation_takes_the_highest_common_version_or_refuses() {
958 assert_eq!(negotiate(1, 1), Some(1));
959 assert_eq!(negotiate(0, 9), Some(LINK_PROTOCOL_MAX));
960 assert_eq!(
961 negotiate(LINK_PROTOCOL_MAX + 1, LINK_PROTOCOL_MAX + 3),
962 None
963 );
964 assert_eq!(negotiate(3, 2), None, "an inverted range is no range");
965 }
966
967 #[test]
968 fn a_signature_covers_the_body_and_its_domain() {
969 let node = NodeIdentity::ephemeral().unwrap();
970 let other = NodeIdentity::ephemeral().unwrap();
971 let mut signed = sign(
972 &node,
973 Domain::SyncRequest,
974 serde_json::json!({"b": 2, "a": 1}),
975 );
976 assert!(verify_signed(
977 &node.public.public_key,
978 Domain::SyncRequest,
979 &signed
980 ));
981 assert!(!verify_signed(
982 &other.public.public_key,
983 Domain::SyncRequest,
984 &signed
985 ));
986 assert!(!verify_signed(
987 &node.public.public_key,
988 Domain::SyncAnswer,
989 &signed
990 ));
991 // Key order is not content: a reordered body still verifies.
992 signed.body = serde_json::json!({"a": 1, "b": 2});
993 assert!(verify_signed(
994 &node.public.public_key,
995 Domain::SyncRequest,
996 &signed
997 ));
998 signed.body = serde_json::json!({"a": 1, "b": 3});
999 assert!(!verify_signed(
1000 &node.public.public_key,
1001 Domain::SyncRequest,
1002 &signed
1003 ));
1004 }
1005
1006 #[test]
1007 fn a_card_is_bounded_and_names_its_runtime() {
1008 let node = NodeIdentity::ephemeral().unwrap();
1009 let card = RuntimeCard {
1010 pk: node.public.public_key.clone(),
1011 runtime: "00000000000000aa".into(),
1012 instance: node.public.instance_id.as_str().into(),
1013 repo: "r".repeat(32),
1014 name: "n".into(),
1015 version: "0.7.0".into(),
1016 features: FEATURES.iter().map(|f| f.to_string()).collect(),
1017 endpoints: vec!["127.0.0.1:1".into()],
1018 };
1019 assert!(card.check().is_ok());
1020 assert_eq!(
1021 card.runtime_key().unwrap(),
1022 format!("{}-00000000000000aa", node.public.node_id)
1023 );
1024 assert!(card.supports("claims") && !card.supports("teleport"));
1025 let broken = RuntimeCard {
1026 runtime: "zz".into(),
1027 ..card.clone()
1028 };
1029 assert!(broken.check().is_err());
1030 let spoofed = RuntimeCard {
1031 pk: "00".repeat(32),
1032 ..card
1033 };
1034 assert_ne!(spoofed.runtime_key(), broken.runtime_key());
1035 }
1036
1037 #[test]
1038 fn tokens_are_fresh_hex() {
1039 let (a, b) = (fresh_token(), fresh_token());
1040 assert_eq!(a.len(), 32);
1041 assert_ne!(a, b);
1042 }
1043
1044 #[test]
1045 fn an_unreachable_endpoint_is_a_transport_error_not_a_refusal() {
1046 let node = NodeIdentity::ephemeral().unwrap();
1047 let signed = sign(&node, Domain::Hello, serde_json::json!({}));
1048 let error = HttpTransport
1049 .post("127.0.0.1:9", HELLO_PATH, &signed)
1050 .unwrap_err();
1051 assert!(error.contains("127.0.0.1:9"), "{error}");
1052 }
1053}