Skip to main content

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}