Skip to main content

clawft_kernel/
mesh_admit.rs

1//! Mesh peer admission (P3-K1, ADR-103, plan section 1.5).
2//!
3//! After the optional Noise handshake, a peer that wants a verified
4//! identity sends [`AdmitHello`] as its first frame. The hello signs the
5//! *session's* Noise handshake hash, so a captured hello cannot be
6//! replayed on another session, and binds the node id to the Ed25519 key
7//! that the id is derived from ([`node_id_from_pubkey`]).
8//!
9//! This module is policy-free plumbing: [`AdmitHello::verify`] is pure
10//! cryptography; the policy (genesis pin, revocation, verdict, mode) lives
11//! behind [`AdmissionGate`]. [`AllowAll`] is today's behaviour;
12//! [`CryptoGate`] implements `off | observe | enforce`.
13//!
14//! # Trust of scopes
15//!
16//! A peer's `dest_scope` is a *request*; the kernel ignores it and the
17//! [`LocalDelivery`](crate::mesh_delivery::LocalDelivery) implementor must
18//! treat it as untrusted. A peer's `src_scope` is kept only when
19//! admission verified the peer ([`Grant::trust_scope`]); for every other
20//! peer `mesh_serve` strips it before delivery.
21
22use async_trait::async_trait;
23use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
24use serde::{Deserialize, Serialize};
25
26use crate::node_id::node_id_from_pubkey;
27
28pub use crate::mesh_admit_gate::{
29    AdmissionRecord, CryptoGate, OpenVerdicts, Verdict, VerdictRequest, VerdictSource,
30    VERDICT_TTL,
31};
32#[cfg(feature = "exochain")]
33pub use crate::mesh_admit_gate::GateVerdictSource;
34
35/// Discriminator that tells a hello apart from a `MeshIpcEnvelope`.
36pub const HELLO_KIND: &str = "mesh.admit.hello";
37/// Domain-separation prefix of the signed bytes.
38pub const HELLO_DOMAIN: &[u8] = b"weftos/mesh-admit/v1\0";
39/// Allowed clock skew between hello `ts` and local time.
40pub const MAX_SKEW_SECS: u64 = 60;
41/// Capability string marking a leaf-class peer.
42pub const CAP_LEAF: &str = "leaf";
43
44/// Wire form of the first frame (JSON). Binary fields are lowercase hex.
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct AdmitHello {
47    /// Always [`HELLO_KIND`].
48    pub kind: String,
49    /// Protocol version, currently 1.
50    pub v: u32,
51    /// `node_id_from_pubkey(pubkey)`.
52    pub node_id: String,
53    /// Ed25519 public key (64 hex).
54    pub pubkey: String,
55    /// Free-form platform string.
56    #[serde(default)]
57    pub platform: String,
58    /// Capability tags; [`CAP_LEAF`] marks a leaf-class peer.
59    #[serde(default)]
60    pub capabilities: Vec<String>,
61    /// Cluster genesis hash the peer believes in (64 hex).
62    pub genesis_hash: String,
63    /// Unix seconds when signed.
64    pub ts: u64,
65    /// The peer's Noise static public key (64 hex), or empty.
66    #[serde(default)]
67    pub noise_static_pub: String,
68    /// Signature (128 hex) over [`signed_bytes`].
69    pub sig: String,
70}
71
72/// What the channel itself proves: the session hash and the remote static.
73#[derive(Debug, Clone, Copy)]
74pub struct ChannelBinding<'a> {
75    /// Noise handshake hash of this session.
76    pub handshake_hash: &'a [u8],
77    /// Remote Noise static key as seen by the handshake.
78    pub remote_static: Option<&'a [u8]>,
79}
80
81/// A hello whose node id, key, session binding and timestamp checked out.
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct VerifiedHello {
84    /// The verified node id.
85    pub node_id: String,
86    /// The verified Ed25519 key.
87    pub pubkey: [u8; 32],
88    /// Platform string as sent.
89    pub platform: String,
90    /// Capabilities as sent.
91    pub capabilities: Vec<String>,
92    /// Genesis hash as signed (not yet compared to a pin).
93    pub genesis_hash: [u8; 32],
94    /// Signed timestamp.
95    pub ts: u64,
96}
97
98impl VerifiedHello {
99    /// Class implied by the capabilities. **Self-declared**: a node can
100    /// claim `leaf` (taking limits it does not need) or omit it. Under
101    /// `enforce` the verdict source may override it
102    /// ([`Verdict::PermitAs`](crate::mesh_admit::Verdict)); a full fix
103    /// (class bound to an issued credential) is a known open limit
104    /// (ADR-103, Phase 3).
105    pub fn class(&self) -> PeerClass {
106        if self.capabilities.iter().any(|c| c == CAP_LEAF) {
107            PeerClass::Leaf
108        } else {
109            PeerClass::Node
110        }
111    }
112}
113
114/// Why a hello did not verify, or that none was sent.
115#[derive(Debug, Clone, PartialEq, Eq)]
116pub enum HelloFailure {
117    /// First frame was not a hello (legacy / ESP32 peers).
118    Missing,
119    /// Hello present but unparsable or wrong version.
120    Malformed(String),
121    /// `node_id` is not the id derived from `pubkey`.
122    NodeIdMismatch,
123    /// Channel has no handshake hash (plaintext passthrough).
124    NoHandshakeBinding,
125    /// `noise_static_pub` differs from the handshake's remote static.
126    StaticMismatch,
127    /// Signature does not verify over this session's hash.
128    BadSignature,
129    /// `ts` outside the allowed skew.
130    ClockSkew,
131}
132
133impl HelloFailure {
134    /// Stable machine-readable code for records and logs.
135    pub fn code(&self) -> &'static str {
136        match self {
137            Self::Missing => "hello_missing",
138            Self::Malformed(_) => "hello_malformed",
139            Self::NodeIdMismatch => "node_id_mismatch",
140            Self::NoHandshakeBinding => "no_handshake_binding",
141            Self::StaticMismatch => "static_mismatch",
142            Self::BadSignature => "bad_signature",
143            Self::ClockSkew => "clock_skew",
144        }
145    }
146}
147
148/// The exact bytes a hello signs.
149///
150/// Layout: domain || handshake hash || genesis || ts (BE) || node_id ||
151/// platform || capability count (BE u32) || each capability, with every
152/// variable-length string prefixed by its length (BE u32). Covering the
153/// identity, platform and capabilities means none can be altered in
154/// flight; the class a hello implies is therefore signed (but still
155/// self-declared, see [`VerifiedHello::class`]).
156pub fn signed_bytes(
157    handshake_hash: &[u8],
158    genesis: &[u8; 32],
159    ts: u64,
160    node_id: &str,
161    platform: &str,
162    capabilities: &[String],
163) -> Vec<u8> {
164    fn put(b: &mut Vec<u8>, s: &str) {
165        b.extend_from_slice(&(s.len() as u32).to_be_bytes());
166        b.extend_from_slice(s.as_bytes());
167    }
168    let mut b = Vec::with_capacity(HELLO_DOMAIN.len() + handshake_hash.len() + 128);
169    b.extend_from_slice(HELLO_DOMAIN);
170    b.extend_from_slice(handshake_hash);
171    b.extend_from_slice(genesis);
172    b.extend_from_slice(&ts.to_be_bytes());
173    put(&mut b, node_id);
174    put(&mut b, platform);
175    b.extend_from_slice(&(capabilities.len() as u32).to_be_bytes());
176    for c in capabilities {
177        put(&mut b, c);
178    }
179    b
180}
181
182impl AdmitHello {
183    /// Build and sign a hello for the session identified by
184    /// `handshake_hash`. `noise_static_pub` is this side's Noise static
185    /// public key (what the peer sees as its remote static).
186    pub fn sign(
187        key: &SigningKey,
188        handshake_hash: &[u8],
189        noise_static_pub: &[u8],
190        genesis: &[u8; 32],
191        ts: u64,
192        platform: &str,
193        capabilities: Vec<String>,
194    ) -> Self {
195        let pk = key.verifying_key().to_bytes();
196        let node_id = node_id_from_pubkey(&pk);
197        let sig = key.sign(&signed_bytes(
198            handshake_hash, genesis, ts, &node_id, platform, &capabilities,
199        ));
200        Self {
201            kind: HELLO_KIND.to_owned(),
202            v: 1,
203            node_id,
204            pubkey: hex_encode(&pk),
205            platform: platform.to_owned(),
206            capabilities,
207            genesis_hash: hex_encode(genesis),
208            ts,
209            noise_static_pub: hex_encode(noise_static_pub),
210            sig: hex_encode(&sig.to_bytes()),
211        }
212    }
213
214    /// Serialize for the wire.
215    pub fn to_bytes(&self) -> Vec<u8> {
216        serde_json::to_vec(self).unwrap_or_default()
217    }
218
219    /// Classify a first frame: `None` when it is not a hello at all (the
220    /// legacy path), `Some(Err)` when it claims to be one but is broken.
221    pub fn parse_frame(data: &[u8]) -> Option<Result<Self, HelloFailure>> {
222        if data.first() != Some(&b'{') {
223            return None;
224        }
225        let v: serde_json::Value = serde_json::from_slice(data).ok()?;
226        if v.get("kind").and_then(|k| k.as_str()) != Some(HELLO_KIND) {
227            return None;
228        }
229        Some(serde_json::from_value(v).map_err(|e| HelloFailure::Malformed(e.to_string())))
230    }
231
232    /// Verify everything that needs no policy: id/key binding, session
233    /// binding, signature, timestamp. Order follows plan section 1.5.
234    pub fn verify(
235        &self,
236        binding: Option<&ChannelBinding<'_>>,
237        now: u64,
238    ) -> Result<VerifiedHello, HelloFailure> {
239        if self.v != 1 {
240            return Err(HelloFailure::Malformed(format!("unsupported version {}", self.v)));
241        }
242        let pubkey: [u8; 32] = hex_decode_fixed(&self.pubkey)
243            .ok_or_else(|| HelloFailure::Malformed("pubkey".into()))?;
244        let genesis: [u8; 32] = hex_decode_fixed(&self.genesis_hash)
245            .ok_or_else(|| HelloFailure::Malformed("genesis_hash".into()))?;
246        let sig: [u8; 64] =
247            hex_decode_fixed(&self.sig).ok_or_else(|| HelloFailure::Malformed("sig".into()))?;
248        if node_id_from_pubkey(&pubkey) != self.node_id {
249            return Err(HelloFailure::NodeIdMismatch);
250        }
251        let binding = binding.ok_or(HelloFailure::NoHandshakeBinding)?;
252        let claimed_static = hex_decode(&self.noise_static_pub)
253            .ok_or_else(|| HelloFailure::Malformed("noise_static_pub".into()))?;
254        if binding.remote_static != Some(claimed_static.as_slice()) {
255            return Err(HelloFailure::StaticMismatch);
256        }
257        let vk = VerifyingKey::from_bytes(&pubkey)
258            .map_err(|_| HelloFailure::Malformed("pubkey not on curve".into()))?;
259        // Cheap check first: skew before the signature verification.
260        if now.abs_diff(self.ts) > MAX_SKEW_SECS {
261            return Err(HelloFailure::ClockSkew);
262        }
263        vk.verify_strict(
264            &signed_bytes(
265                binding.handshake_hash,
266                &genesis,
267                self.ts,
268                &self.node_id,
269                &self.platform,
270                &self.capabilities,
271            ),
272            &Signature::from_bytes(&sig),
273        )
274        .map_err(|_| HelloFailure::BadSignature)?;
275        Ok(VerifiedHello {
276            node_id: self.node_id.clone(),
277            pubkey,
278            platform: self.platform.clone(),
279            capabilities: self.capabilities.clone(),
280            genesis_hash: genesis,
281            ts: self.ts,
282        })
283    }
284}
285
286/// What this node needs to introduce itself on an outbound connection.
287#[derive(Clone)]
288pub struct DialIdentity {
289    /// This node's Ed25519 key (its node id derives from it).
290    pub key: SigningKey,
291    /// Cluster genesis hash to present.
292    pub genesis: [u8; 32],
293    /// Platform string.
294    pub platform: String,
295    /// Capability tags.
296    pub capabilities: Vec<String>,
297}
298
299impl DialIdentity {
300    /// Hello for a session. `noise_static` is our own Noise static public
301    /// key (see [`noise_static_public`](crate::mesh_noise::noise_static_public)).
302    pub fn hello(&self, handshake_hash: &[u8], noise_static: &[u8], ts: u64) -> AdmitHello {
303        AdmitHello::sign(
304            &self.key,
305            handshake_hash,
306            noise_static,
307            &self.genesis,
308            ts,
309            &self.platform,
310            self.capabilities.clone(),
311        )
312    }
313}
314
315/// How the peer presented itself.
316#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
317pub enum PeerClass {
318    /// Verified full node.
319    Node,
320    /// Verified leaf device (hello carried [`CAP_LEAF`]).
321    Leaf,
322    /// Sent no verifiable hello (old build, ESP32 firmware).
323    Legacy,
324}
325
326impl PeerClass {
327    /// Stable lowercase name (status output, `fleet.snapshot`).
328    pub fn as_str(self) -> &'static str {
329        match self {
330            Self::Node => "node",
331            Self::Leaf => "leaf",
332            Self::Legacy => "legacy",
333        }
334    }
335}
336
337/// Transport protection of the connection.
338#[derive(Debug, Clone, Copy, PartialEq, Eq)]
339pub enum ChannelKind {
340    /// Noise session (has a handshake hash).
341    Noise,
342    /// Plaintext.
343    Passthrough,
344}
345
346/// Facts about the connection handed to the gate.
347#[derive(Debug, Clone, Copy)]
348pub struct AdmitContext {
349    /// Peer class as known so far.
350    pub class: PeerClass,
351    /// Transport protection.
352    pub channel: ChannelKind,
353}
354
355/// What a connection may do after admission.
356#[derive(Debug, Clone, Copy, PartialEq, Eq)]
357pub enum PeerLimits {
358    /// No restriction beyond the kernel's own.
359    None,
360    /// Leaf: publish only under `substrate/<own-id>/`, subscribe freely.
361    Leaf,
362}
363
364/// A refusal reason (also used for would-be refusals under observe).
365#[derive(Debug, Clone, PartialEq, Eq)]
366pub struct Refusal {
367    /// Stable code, e.g. `wrong_genesis`, `revoked`, `hello_missing`.
368    pub code: &'static str,
369    /// Human-readable detail.
370    pub detail: String,
371}
372
373/// Terms of an admission.
374#[derive(Debug, Clone, PartialEq, Eq)]
375pub struct Grant {
376    /// Post-admission limits.
377    pub limits: PeerLimits,
378    /// Class the connection is served as (the gate may override the
379    /// declared class under `enforce`).
380    pub class: PeerClass,
381    /// True only when the hello verified AND the gate actually enforces
382    /// (`CryptoGate` in `enforce`) AND decided Admit. `AllowAll`, `off`
383    /// and `observe` never set it: a verified key there only binds
384    /// `source_node`, it is not membership.
385    pub admitted: bool,
386    /// Keep the peer's `src_scope` (only for admitted `Node` peers).
387    pub trust_scope: bool,
388    /// Set under `observe` when enforcement would have refused.
389    pub observed: Option<Refusal>,
390}
391
392impl Grant {
393    /// An unrestricted, non-admitted grant (no membership claimed).
394    pub fn open(class: PeerClass) -> Self {
395        Self { limits: PeerLimits::None, class, admitted: false, trust_scope: false, observed: None }
396    }
397}
398
399/// Gate decision.
400#[derive(Debug, Clone, PartialEq, Eq)]
401pub enum Admission {
402    /// Serve the peer on the given terms.
403    Admit(Grant),
404    /// Close the connection.
405    Refuse(Refusal),
406}
407
408/// Admission policy for the mesh listener.
409#[async_trait]
410pub trait AdmissionGate: Send + Sync + 'static {
411    /// True when the listener should apply the idle limit and the live
412    /// revocation check (a gate that actually checks peers). The per-IP cap
413    /// and first-frame timeout apply under every gate. `AllowAll` is false so
414    /// quiet legacy leaves are not cut off once connected.
415    fn strict(&self) -> bool {
416        false
417    }
418
419    /// Whether an admitted peer has since been revoked and must be cut off
420    /// (re-checked on every frame). Only gates that refuse revoked peers at
421    /// admission say yes; the default never does.
422    fn is_revoked(&self, _node_id: &str) -> bool {
423        false
424    }
425
426    /// A hello verified; decide.
427    async fn admit(&self, hello: &VerifiedHello, ctx: &AdmitContext) -> Admission;
428
429    /// No hello, or it failed verification; decide.
430    async fn admit_unverified(&self, failure: &HelloFailure, ctx: &AdmitContext) -> Admission;
431}
432
433/// Admit everyone: the pre-K1 behaviour. Verified hellos still bind
434/// `source_node`, but nothing is ever refused or limited.
435#[derive(Debug, Default, Clone, Copy)]
436pub struct AllowAll;
437
438#[async_trait]
439impl AdmissionGate for AllowAll {
440    async fn admit(&self, _: &VerifiedHello, ctx: &AdmitContext) -> Admission {
441        Admission::Admit(Grant::open(ctx.class))
442    }
443    async fn admit_unverified(&self, _: &HelloFailure, _: &AdmitContext) -> Admission {
444        Admission::Admit(Grant::open(PeerClass::Legacy))
445    }
446}
447
448/// Lowercase hex encode.
449pub fn hex_encode(b: &[u8]) -> String {
450    use std::fmt::Write;
451    b.iter().fold(String::with_capacity(b.len() * 2), |mut s, x| {
452        let _ = write!(s, "{x:02x}");
453        s
454    })
455}
456
457/// Decode hex (empty string decodes to empty).
458pub fn hex_decode(s: &str) -> Option<Vec<u8>> {
459    if !s.len().is_multiple_of(2) || !s.is_ascii() {
460        return None;
461    }
462    (0..s.len())
463        .step_by(2)
464        .map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok())
465        .collect()
466}
467
468fn hex_decode_fixed<const N: usize>(s: &str) -> Option<[u8; N]> {
469    hex_decode(s)?.try_into().ok()
470}
471
472#[cfg(test)]
473#[path = "mesh_admit_tests.rs"]
474mod tests;
475
476#[cfg(test)]
477#[path = "mesh_admit_dial_tests.rs"]
478mod dial_tests;
479
480pub use crate::mesh_admit_gate::install_nested_peer_ceiling;