Proof-of-concept mesh agent library over iroh

Working library with real iroh connections, not an interface sketch:

- persistent device identity in state.sqlite, stable across restarts
- deterministic network space derived from name + secret via HKDF-SHA256,
  with frozen labels and unambiguous length-prefixed encoding
- replaceable discovery returning unverified candidates only; static
  bootstrap, in-memory test backend and a composite
- real iroh connections plus an explicit mutual membership proof:
  HMAC-SHA256 over a role-separated transcript bound to the TLS exporter,
  the network id and both endpoint identities
- small versioned control protocol: handshake, announcement, ping/pong
- multiple networks per agent with enforced isolation
- automatic reconnect with bounded backoff and jitter
- mandatory state vs disposable cache, with a real directory ownership lock
- status snapshots, event stream and honest diagnostics

47 integration and unit tests cover the required scenarios offline on
loopback. Snapshots, revocations and WireGuard are designed for and
documented, not implemented.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
tsunagi
2026-09-21 00:10:07 +01:00
co-authored by Claude Opus 5
commit 7cea9afa37
44 changed files with 12916 additions and 0 deletions
+80
View File
@@ -0,0 +1,80 @@
//! Length-prefixed framing over one QUIC bidirectional stream.
//!
//! A frame is `u32_be(len) || payload`. The announced length is validated
//! against the configured limit **before** a buffer of that size is allocated,
//! so a hostile peer cannot make the agent allocate arbitrary memory with a
//! four byte header.
//!
//! No custom encryption is layered on top: the iroh/QUIC connection already
//! provides confidentiality, integrity and endpoint authentication.
use iroh::endpoint::{RecvStream, SendStream};
use crate::error::ProtocolError;
/// Size of the frame length prefix, in bytes.
pub const LENGTH_PREFIX_LEN: usize = 4;
/// Writes one frame.
pub async fn write_frame(
stream: &mut SendStream,
payload: &[u8],
max_frame_len: usize,
) -> Result<(), ProtocolError> {
if payload.len() > max_frame_len {
return Err(ProtocolError::FrameTooLarge {
announced: payload.len() as u64,
limit: max_frame_len,
});
}
let len = u32::try_from(payload.len()).map_err(|_| ProtocolError::FrameTooLarge {
announced: payload.len() as u64,
limit: max_frame_len,
})?;
stream
.write_all(&len.to_be_bytes())
.await
.map_err(|err| ProtocolError::Stream(err.to_string()))?;
stream
.write_all(payload)
.await
.map_err(|err| ProtocolError::Stream(err.to_string()))?;
Ok(())
}
/// Reads one frame, rejecting oversized headers before allocating.
pub async fn read_frame(
stream: &mut RecvStream,
max_frame_len: usize,
) -> Result<Vec<u8>, ProtocolError> {
let mut header = [0u8; LENGTH_PREFIX_LEN];
match stream.read_exact(&mut header).await {
Ok(()) => {}
Err(err) => return Err(classify_read_error(err)),
}
let announced = u32::from_be_bytes(header) as u64;
if announced > max_frame_len as u64 {
return Err(ProtocolError::FrameTooLarge {
announced,
limit: max_frame_len,
});
}
// Safe: `announced` was just bounded by `max_frame_len`, a usize.
let mut payload = vec![0u8; announced as usize];
if !payload.is_empty() {
match stream.read_exact(&mut payload).await {
Ok(()) => {}
Err(err) => return Err(classify_read_error(err)),
}
}
Ok(payload)
}
fn classify_read_error(err: iroh::endpoint::ReadExactError) -> ProtocolError {
match err {
iroh::endpoint::ReadExactError::FinishedEarly(_) => ProtocolError::StreamClosed,
other => ProtocolError::Stream(other.to_string()),
}
}
+575
View File
@@ -0,0 +1,575 @@
//! Mutual proof that both ends belong to the same network space.
//!
//! # Why a successful iroh connection is not enough
//!
//! iroh authenticates *endpoints*: after the QUIC/TLS handshake each side knows
//! the other's [`EndpointId`], because that id is the public key in the
//! certificate. It says nothing about network membership — anybody can dial us.
//! So on top of the authenticated connection we run an explicit mutual proof of
//! knowledge of the derived network authentication key.
//!
//! # Channel binding is not a proof by itself
//!
//! iroh exposes the TLS exporter (RFC 5705) via
//! [`Connection::export_keying_material`]. That gives both sides the same
//! secret bytes for *this* connection, which is exactly what is needed to stop
//! a proof being replayed on another connection. It proves nothing about the
//! shared network secret on its own, because both ends of any connection can
//! compute it. The proof of membership is the HMAC keyed by `auth_key`; the
//! exporter output is only one of its inputs.
//!
//! # The scheme
//!
//! ```text
//! cb = TLS-Exporter(label = "tsunagi/handshake/v1", context = network_id, 32)
//! LP(x)= u32_be(len(x)) || x
//!
//! transcript(role) = LP("tsunagi-handshake-v1")
//! || LP(role) // "initiator-proof" | "responder-proof"
//! || LP(u16_be(protocol_version))
//! || LP(network_id) // 32 bytes
//! || LP(initiator_endpoint_id) // 32 bytes
//! || LP(responder_endpoint_id) // 32 bytes
//! || LP(cb) // 32 bytes
//! || LP(nonce_initiator) // 16 bytes
//! || LP(nonce_responder) // 16 bytes
//!
//! proof(role) = HMAC-SHA256(auth_key, transcript(role))
//! ```
//!
//! What each input buys:
//!
//! * `auth_key` — membership. Derived from name+secret only, see
//! [`crate::identity`].
//! * `cb` — binding to this connection. A proof captured elsewhere is useless
//! here, because `cb` differs per TLS session.
//! * `network_id` — binding to this network space.
//! * both endpoint ids — binding to these two identities.
//! * distinct `role` labels — no reflection: the responder cannot bounce the
//! initiator's own proof back at it.
//! * both nonces — freshness contributed by each side.
//!
//! # Message order
//!
//! ```text
//! initiator -> responder : Hello { version, network_id, nonce_i }
//! initiator <- responder : HelloAck { version, nonce_r }
//! initiator -> responder : AuthProof{ proof(initiator) }
//! initiator <- responder : AuthProof{ proof(responder) } // only if the first proof verified
//! ```
//!
//! The responder emits nothing derived from `auth_key` until the initiator's
//! proof has verified, so a caller who does not know the secret learns nothing.
//! Until both steps complete, no regular control message is accepted in either
//! direction.
//!
//! [`Connection::export_keying_material`]: iroh::endpoint::Connection::export_keying_material
use hmac::{Hmac, KeyInit, Mac};
use iroh::EndpointId;
use iroh::endpoint::{Connection, RecvStream, SendStream};
use sha2::Sha256;
use zeroize::Zeroizing;
use crate::config::Limits;
use crate::error::ProtocolError;
use crate::identity::{NetworkId, NetworkKeys};
use crate::proto::frame::{read_frame, write_frame};
use crate::proto::message::{AuthProof, Hello, HelloAck, PROTOCOL_VERSION, decode, encode};
/// Frozen domain separator of the handshake transcript.
pub const TRANSCRIPT_DOMAIN: &str = "tsunagi-handshake-v1";
/// TLS exporter label used for channel binding.
pub const EXPORTER_LABEL: &[u8] = b"tsunagi/handshake/v1";
/// Transcript role label of the side that dialled.
pub const ROLE_INITIATOR: &str = "initiator-proof";
/// Transcript role label of the side that accepted.
pub const ROLE_RESPONDER: &str = "responder-proof";
/// Length of the channel binding material, in bytes.
pub const CHANNEL_BINDING_LEN: usize = 32;
/// Which side of the handshake this agent played.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Role {
/// This agent dialled.
Initiator,
/// This agent accepted.
Responder,
}
impl Role {
/// Short label for diagnostics.
pub fn as_str(&self) -> &'static str {
match self {
Role::Initiator => "initiator",
Role::Responder => "responder",
}
}
}
/// Result of a completed handshake.
#[derive(Debug, Clone)]
pub struct HandshakeOutcome {
/// Network both sides proved membership of.
pub network_id: NetworkId,
/// Authenticated endpoint id of the peer, taken from the TLS certificate.
pub peer: EndpointId,
/// Which side this agent played.
pub role: Role,
}
fn push_lp(out: &mut Vec<u8>, bytes: &[u8]) {
let len = u32::try_from(bytes.len()).unwrap_or(u32::MAX);
out.extend_from_slice(&len.to_be_bytes());
out.extend_from_slice(bytes);
}
/// Builds the role-specific transcript. Pure function, unit tested.
#[allow(clippy::too_many_arguments)]
pub fn transcript(
role: &str,
version: u16,
network_id: &[u8; 32],
initiator: &[u8; 32],
responder: &[u8; 32],
channel_binding: &[u8],
nonce_initiator: &[u8; 16],
nonce_responder: &[u8; 16],
) -> Vec<u8> {
let mut out = Vec::with_capacity(256);
push_lp(&mut out, TRANSCRIPT_DOMAIN.as_bytes());
push_lp(&mut out, role.as_bytes());
push_lp(&mut out, &version.to_be_bytes());
push_lp(&mut out, network_id);
push_lp(&mut out, initiator);
push_lp(&mut out, responder);
push_lp(&mut out, channel_binding);
push_lp(&mut out, nonce_initiator);
push_lp(&mut out, nonce_responder);
out
}
/// Computes one proof.
#[allow(clippy::too_many_arguments)]
fn proof(
auth_key: &[u8; 32],
role: &str,
version: u16,
network_id: &[u8; 32],
initiator: &[u8; 32],
responder: &[u8; 32],
channel_binding: &[u8],
nonce_initiator: &[u8; 16],
nonce_responder: &[u8; 16],
) -> [u8; 32] {
let message = transcript(
role,
version,
network_id,
initiator,
responder,
channel_binding,
nonce_initiator,
nonce_responder,
);
let mut mac = match <Hmac<Sha256> as KeyInit>::new_from_slice(auth_key) {
Ok(mac) => mac,
// HMAC-SHA256 accepts keys of any length, so a 32 byte key cannot fail.
Err(_) => unreachable!("HMAC-SHA256 accepts a 32 byte key"),
};
mac.update(&message);
let tag = mac.finalize().into_bytes();
let mut out = [0u8; 32];
out.copy_from_slice(&tag);
out
}
/// Verifies a proof in constant time.
#[allow(clippy::too_many_arguments)]
fn verify(
auth_key: &[u8; 32],
role: &str,
version: u16,
network_id: &[u8; 32],
initiator: &[u8; 32],
responder: &[u8; 32],
channel_binding: &[u8],
nonce_initiator: &[u8; 16],
nonce_responder: &[u8; 16],
candidate: &[u8; 32],
) -> Result<(), ProtocolError> {
let message = transcript(
role,
version,
network_id,
initiator,
responder,
channel_binding,
nonce_initiator,
nonce_responder,
);
let mut mac = match <Hmac<Sha256> as KeyInit>::new_from_slice(auth_key) {
Ok(mac) => mac,
Err(_) => unreachable!("HMAC-SHA256 accepts a 32 byte key"),
};
mac.update(&message);
mac.verify_slice(candidate)
.map_err(|_| ProtocolError::AuthenticationFailed)
}
/// Extracts channel binding material from the connection.
fn channel_binding(
conn: &Connection,
network_id: &[u8; 32],
) -> Result<Zeroizing<[u8; CHANNEL_BINDING_LEN]>, ProtocolError> {
let mut out = Zeroizing::new([0u8; CHANNEL_BINDING_LEN]);
conn.export_keying_material(out.as_mut(), EXPORTER_LABEL, network_id)
.map_err(|err| ProtocolError::NoChannelBinding(format!("{err:?}")))?;
Ok(out)
}
fn fresh_nonce() -> [u8; 16] {
let mut nonce = [0u8; 16];
rand::fill(&mut nonce);
nonce
}
fn check_version(found: u16) -> Result<(), ProtocolError> {
if found != PROTOCOL_VERSION {
return Err(ProtocolError::UnsupportedVersion {
found,
supported: PROTOCOL_VERSION,
});
}
Ok(())
}
/// Runs the initiator side of the handshake.
///
/// Bounded by [`Limits::handshake_timeout`].
pub async fn initiate(
conn: &Connection,
send: &mut SendStream,
recv: &mut RecvStream,
local_id: EndpointId,
keys: &NetworkKeys,
limits: &Limits,
) -> Result<HandshakeOutcome, ProtocolError> {
tokio::time::timeout(
limits.handshake_timeout,
initiate_inner(conn, send, recv, local_id, keys, limits),
)
.await
.unwrap_or(Err(ProtocolError::HandshakeTimeout))
}
async fn initiate_inner(
conn: &Connection,
send: &mut SendStream,
recv: &mut RecvStream,
local_id: EndpointId,
keys: &NetworkKeys,
limits: &Limits,
) -> Result<HandshakeOutcome, ProtocolError> {
let network_id = keys.network_id();
let network_bytes = *network_id.as_bytes();
let peer = conn.remote_id();
let initiator = *local_id.as_bytes();
let responder = *peer.as_bytes();
let cb = channel_binding(conn, &network_bytes)?;
let nonce_i = fresh_nonce();
let hello = Hello {
version: PROTOCOL_VERSION,
network_id: network_bytes,
nonce: nonce_i,
};
write_frame(send, &encode(&hello)?, limits.max_frame_len).await?;
let ack: HelloAck = decode(&read_frame(recv, limits.max_frame_len).await?)?;
check_version(ack.version)?;
let nonce_r = ack.nonce;
let mine = proof(
keys.auth_key(),
ROLE_INITIATOR,
PROTOCOL_VERSION,
&network_bytes,
&initiator,
&responder,
cb.as_ref(),
&nonce_i,
&nonce_r,
);
write_frame(
send,
&encode(&AuthProof { proof: mine })?,
limits.max_frame_len,
)
.await?;
let theirs: AuthProof = decode(&read_frame(recv, limits.max_frame_len).await?)?;
verify(
keys.auth_key(),
ROLE_RESPONDER,
PROTOCOL_VERSION,
&network_bytes,
&initiator,
&responder,
cb.as_ref(),
&nonce_i,
&nonce_r,
&theirs.proof,
)?;
Ok(HandshakeOutcome {
network_id,
peer,
role: Role::Initiator,
})
}
/// Runs the responder side of the handshake.
///
/// `lookup` maps the network id the peer asked for to the local key material,
/// returning `None` if this agent does not have that network active. Routing
/// stays in the agent; the protocol stays here.
///
/// Bounded by [`Limits::handshake_timeout`].
pub async fn respond<F>(
conn: &Connection,
send: &mut SendStream,
recv: &mut RecvStream,
local_id: EndpointId,
limits: &Limits,
lookup: F,
) -> Result<HandshakeOutcome, ProtocolError>
where
F: FnOnce(NetworkId) -> Option<NetworkKeys>,
{
tokio::time::timeout(
limits.handshake_timeout,
respond_inner(conn, send, recv, local_id, limits, lookup),
)
.await
.unwrap_or(Err(ProtocolError::HandshakeTimeout))
}
async fn respond_inner<F>(
conn: &Connection,
send: &mut SendStream,
recv: &mut RecvStream,
local_id: EndpointId,
limits: &Limits,
lookup: F,
) -> Result<HandshakeOutcome, ProtocolError>
where
F: FnOnce(NetworkId) -> Option<NetworkKeys>,
{
let hello: Hello = decode(&read_frame(recv, limits.max_frame_len).await?)?;
check_version(hello.version)?;
let network_id = NetworkId::from_bytes(hello.network_id);
let keys = lookup(network_id).ok_or(ProtocolError::UnknownNetwork)?;
let peer = conn.remote_id();
let network_bytes = hello.network_id;
let initiator = *peer.as_bytes();
let responder = *local_id.as_bytes();
let cb = channel_binding(conn, &network_bytes)?;
let nonce_i = hello.nonce;
let nonce_r = fresh_nonce();
let ack = HelloAck {
version: PROTOCOL_VERSION,
nonce: nonce_r,
};
write_frame(send, &encode(&ack)?, limits.max_frame_len).await?;
let theirs: AuthProof = decode(&read_frame(recv, limits.max_frame_len).await?)?;
verify(
keys.auth_key(),
ROLE_INITIATOR,
PROTOCOL_VERSION,
&network_bytes,
&initiator,
&responder,
cb.as_ref(),
&nonce_i,
&nonce_r,
&theirs.proof,
)?;
// Only now, after the peer proved membership, do we emit our own proof.
let mine = proof(
keys.auth_key(),
ROLE_RESPONDER,
PROTOCOL_VERSION,
&network_bytes,
&initiator,
&responder,
cb.as_ref(),
&nonce_i,
&nonce_r,
);
write_frame(
send,
&encode(&AuthProof { proof: mine })?,
limits.max_frame_len,
)
.await?;
Ok(HandshakeOutcome {
network_id,
peer,
role: Role::Responder,
})
}
/// Test-only re-export of [`channel_binding`]. See [`crate::test_support`].
#[doc(hidden)]
pub fn channel_binding_for_test(
conn: &Connection,
network_id: &[u8; 32],
) -> Result<[u8; 32], ProtocolError> {
channel_binding(conn, network_id).map(|cb| *cb)
}
/// Test-only re-export of the proof function. See [`crate::test_support`].
#[doc(hidden)]
#[allow(clippy::too_many_arguments)]
pub fn proof_for_test(
auth_key: &[u8; 32],
role: &str,
version: u16,
network_id: &[u8; 32],
initiator: &[u8; 32],
responder: &[u8; 32],
channel_binding: &[u8],
nonce_initiator: &[u8; 16],
nonce_responder: &[u8; 16],
) -> [u8; 32] {
proof(
auth_key,
role,
version,
network_id,
initiator,
responder,
channel_binding,
nonce_initiator,
nonce_responder,
)
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
/// `(auth_key, network_id, initiator_id, responder_id, nonce_i, nonce_r)`
type Fixture = ([u8; 32], [u8; 32], [u8; 32], [u8; 32], [u8; 16], [u8; 16]);
fn fixture() -> Fixture {
(
[1u8; 32], [2u8; 32], [3u8; 32], [4u8; 32], [5u8; 16], [6u8; 16],
)
}
#[test]
fn role_labels_produce_different_transcripts() {
let (key, net, ini, res, ni, nr) = fixture();
let cb = [7u8; 32];
let a = proof(&key, ROLE_INITIATOR, 1, &net, &ini, &res, &cb, &ni, &nr);
let b = proof(&key, ROLE_RESPONDER, 1, &net, &ini, &res, &cb, &ni, &nr);
assert_ne!(a, b, "reflecting a proof back must not verify");
}
#[test]
fn channel_binding_changes_the_proof() {
let (key, net, ini, res, ni, nr) = fixture();
let a = proof(
&key,
ROLE_INITIATOR,
1,
&net,
&ini,
&res,
&[7u8; 32],
&ni,
&nr,
);
let b = proof(
&key,
ROLE_INITIATOR,
1,
&net,
&ini,
&res,
&[8u8; 32],
&ni,
&nr,
);
assert_ne!(a, b, "a proof must not be replayable on another connection");
}
#[test]
fn identities_and_network_are_bound() {
let (key, net, ini, res, ni, nr) = fixture();
let cb = [7u8; 32];
let base = proof(&key, ROLE_INITIATOR, 1, &net, &ini, &res, &cb, &ni, &nr);
let other_net = proof(
&key,
ROLE_INITIATOR,
1,
&[9u8; 32],
&ini,
&res,
&cb,
&ni,
&nr,
);
let swapped = proof(&key, ROLE_INITIATOR, 1, &net, &res, &ini, &cb, &ni, &nr);
assert_ne!(base, other_net);
assert_ne!(base, swapped);
}
#[test]
fn transcript_encoding_is_unambiguous() {
// Two different field splits that would collide under naive concatenation.
let a = transcript(
"ab", 1, &[0u8; 32], &[0u8; 32], &[0u8; 32], b"cd", &[0u8; 16], &[0u8; 16],
);
let b = transcript(
"a", 1, &[0u8; 32], &[0u8; 32], &[0u8; 32], b"bcd", &[0u8; 16], &[0u8; 16],
);
assert_ne!(a, b);
}
#[test]
fn wrong_key_fails_verification() {
let (key, net, ini, res, ni, nr) = fixture();
let cb = [7u8; 32];
let tag = proof(&key, ROLE_INITIATOR, 1, &net, &ini, &res, &cb, &ni, &nr);
let wrong = [0xAAu8; 32];
let result = verify(
&wrong,
ROLE_INITIATOR,
1,
&net,
&ini,
&res,
&cb,
&ni,
&nr,
&tag,
);
assert!(matches!(result, Err(ProtocolError::AuthenticationFailed)));
}
}
+184
View File
@@ -0,0 +1,184 @@
//! Control message formats.
//!
//! This is deliberately a small, closed set of messages, not a general RPC
//! framework. Bodies are encoded with [postcard], a compact, deterministic,
//! non-self-describing serde format.
//!
//! Every message that arrives from the network passes [`validate`] against the
//! configured [`Limits`] before it reaches anything else.
//!
//! [postcard]: https://docs.rs/postcard
use serde::{Deserialize, Serialize};
use crate::config::Limits;
use crate::dataplane::{MAX_PROTOCOL_ID_LEN, PluginCapability};
use crate::error::ProtocolError;
/// ALPN of the tsunagi control plane.
///
/// The version in the ALPN is the wire-compatibility version of the control
/// protocol. It is independent of the network identity scheme version, so
/// bumping it must not change any existing [`crate::NetworkId`].
pub const ALPN: &[u8] = b"tsunagi/ctrl/1";
/// Control protocol version carried inside the handshake.
pub const PROTOCOL_VERSION: u16 = 1;
/// First message of the handshake, sent by the initiator.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Hello {
/// Control protocol version the initiator speaks.
pub version: u16,
/// Public network identifier the initiator wants to join.
pub network_id: [u8; 32],
/// Initiator's fresh handshake nonce.
pub nonce: [u8; 16],
}
/// Responder's reply to [`Hello`]. Carries no proof yet.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HelloAck {
/// Control protocol version the responder speaks.
pub version: u16,
/// Responder's fresh handshake nonce.
pub nonce: [u8; 16],
}
/// A handshake proof, i.e. one HMAC tag over a role-specific transcript.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuthProof {
/// HMAC-SHA256 tag.
pub proof: [u8; 32],
}
/// What this agent tells a peer about itself.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Announcement {
/// Human-readable hostname. A mutable binding, not an identity.
pub hostname: String,
/// Announced IP plugin capabilities. Opaque to the core.
pub capabilities: Vec<PluginCapability>,
}
/// A control message exchanged after a successful handshake.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum ControlMessage {
/// Hostname and capability announcement.
Announce(Announcement),
/// A small request used to verify that the exchange works.
Ping {
/// Caller-chosen sequence number, echoed back.
seq: u64,
/// Opaque bounded payload, echoed back.
payload: Vec<u8>,
},
/// The reply to a [`ControlMessage::Ping`].
Pong {
/// Sequence number of the request being answered.
seq: u64,
/// Echoed payload.
payload: Vec<u8>,
},
/// Graceful goodbye.
///
/// A peer going away is not a revocation of anything.
Bye {
/// Short free-text reason.
reason: String,
},
}
/// A control message together with the network it belongs to.
///
/// Every session is bound to exactly one network at handshake time. The
/// `network_id` here is re-checked on every message, so an authenticated
/// session for network A can never be used to speak to network B, even over a
/// shared physical connection.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Envelope {
/// Network this message belongs to.
pub network_id: [u8; 32],
/// The message itself.
pub message: ControlMessage,
}
/// Encodes a value into a postcard byte vector.
pub fn encode<T: Serialize>(value: &T) -> Result<Vec<u8>, ProtocolError> {
postcard::to_stdvec(value).map_err(|_| ProtocolError::Malformed("cannot encode message"))
}
/// Decodes a value from postcard bytes.
pub fn decode<T: for<'de> Deserialize<'de>>(bytes: &[u8]) -> Result<T, ProtocolError> {
postcard::from_bytes(bytes).map_err(|_| ProtocolError::Malformed("cannot decode message"))
}
fn check_len(field: &'static str, len: usize, limit: usize) -> Result<(), ProtocolError> {
if len > limit {
return Err(ProtocolError::FieldTooLarge { field, len, limit });
}
Ok(())
}
/// Validates a decoded capability against the configured limits.
pub fn validate_capability(
capability: &PluginCapability,
limits: &Limits,
) -> Result<(), ProtocolError> {
if capability.protocol.is_empty() {
return Err(ProtocolError::Malformed("empty plugin protocol id"));
}
check_len(
"capability.protocol",
capability.protocol.len(),
MAX_PROTOCOL_ID_LEN,
)?;
check_len(
"capability.data",
capability.data.len(),
limits.max_capability_data_len,
)?;
Ok(())
}
/// Validates a decoded control message against the configured limits.
///
/// Returning an error rejects that single message. It never stops the session's
/// network, the other networks or the agent.
pub fn validate(message: &ControlMessage, limits: &Limits) -> Result<(), ProtocolError> {
match message {
ControlMessage::Announce(announcement) => {
check_len(
"announce.hostname",
announcement.hostname.len(),
limits.max_hostname_len,
)?;
check_len(
"announce.capabilities",
announcement.capabilities.len(),
limits.max_capabilities,
)?;
for capability in &announcement.capabilities {
validate_capability(capability, limits)?;
}
}
ControlMessage::Ping { payload, .. } | ControlMessage::Pong { payload, .. } => {
check_len("echo.payload", payload.len(), limits.max_echo_payload_len)?;
}
ControlMessage::Bye { reason } => {
check_len("bye.reason", reason.len(), limits.max_reason_len)?;
}
}
Ok(())
}
/// A short, stable label for a message kind, for metrics and diagnostics.
pub fn kind(message: &ControlMessage) -> &'static str {
match message {
ControlMessage::Announce(_) => "announce",
ControlMessage::Ping { .. } => "ping",
ControlMessage::Pong { .. } => "pong",
ControlMessage::Bye { .. } => "bye",
}
}
+27
View File
@@ -0,0 +1,27 @@
//! The control protocol: framing, messages and the network membership
//! handshake.
//!
//! Only control messages travel over iroh. User IP traffic is never tunnelled
//! through this protocol.
//!
//! Layering, outermost first:
//!
//! 1. iroh/QUIC connection with ALPN [`message::ALPN`] — endpoint
//! authentication, confidentiality and integrity.
//! 2. One bidirectional stream per session, carrying length-prefixed frames
//! ([`frame`]).
//! 3. The [`handshake`], which must complete before anything else is accepted.
//! 4. [`message::Envelope`]s carrying [`message::ControlMessage`]s, each
//! re-checked against the session's network id.
//!
//! Nothing here adds its own encryption on top of iroh.
pub mod frame;
pub mod handshake;
pub mod message;
pub use frame::{read_frame, write_frame};
pub use handshake::{HandshakeOutcome, Role};
pub use message::{
ALPN, Announcement, AuthProof, ControlMessage, Envelope, Hello, HelloAck, PROTOCOL_VERSION,
};