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
+70
View File
@@ -0,0 +1,70 @@
//! Device identity and network space identity.
//!
//! These are two independent things and must not be confused:
//!
//! * [`DeviceIdentity`] wraps the persistent iroh [`SecretKey`]. Its public key
//! *is* the iroh [`EndpointId`]. It survives restarts and survives a change of
//! the network secret.
//! * [`NetworkId`] identifies a network space and is derived purely from the
//! network name and shared secret. It is unrelated to any device key.
mod network;
pub use network::{
DiscoveryKey, IDENTITY_SCHEME, MAX_NETWORK_NAME_LEN, MAX_NETWORK_SECRET_LEN,
MIN_NETWORK_SECRET_LEN, NetworkDescriptor, NetworkId, NetworkKeys, NetworkName, NetworkSecret,
SECRET_TEXT_PREFIX,
};
use iroh::{EndpointId, SecretKey};
/// The persistent identity of this device.
///
/// Created once and stored in the mandatory state store. Restarting the agent
/// must not produce a new peer, so the stored secret key is always reused.
/// Corruption of the stored key is reported as an error and never silently
/// replaced by a fresh key.
#[derive(Clone)]
pub struct DeviceIdentity {
secret: SecretKey,
}
impl DeviceIdentity {
/// Generates a brand new device identity.
pub fn generate() -> Self {
Self {
secret: SecretKey::generate(),
}
}
/// Reconstructs a device identity from its stored 32 secret key bytes.
pub fn from_secret_bytes(bytes: &[u8; 32]) -> Self {
Self {
secret: SecretKey::from_bytes(bytes),
}
}
/// The iroh endpoint id, i.e. the public key of this device.
pub fn endpoint_id(&self) -> EndpointId {
self.secret.public()
}
/// The raw secret key bytes, for persistence only.
pub(crate) fn secret_bytes(&self) -> [u8; 32] {
self.secret.to_bytes()
}
/// A clone of the iroh secret key, for endpoint construction only.
pub(crate) fn secret_key(&self) -> SecretKey {
self.secret.clone()
}
}
impl std::fmt::Debug for DeviceIdentity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DeviceIdentity")
.field("endpoint_id", &self.endpoint_id().fmt_short().to_string())
.field("secret", &"<redacted>")
.finish()
}
}
+419
View File
@@ -0,0 +1,419 @@
//! Deterministic network space identity.
//!
//! A *network space* is fully determined by a [`NetworkName`] and a
//! [`NetworkSecret`]. Two agents that were given the same pair derive the same
//! [`NetworkId`], [`DiscoveryKey`] and handshake authentication key, with no
//! coordination, no creator, no timestamp and no leader election.
//!
//! # Derivation scheme (`tsunagi-network-id-v1`)
//!
//! All inputs are encoded with an unambiguous length-prefixed encoding, written
//! here as `LP(x) = u32_be(x.len()) || x`. String concatenation is never used.
//!
//! ```text
//! salt = SHA-256( LP("tsunagi-network-id-v1") || LP(name_utf8) )
//! prk = HKDF-SHA256-Extract(salt, ikm = secret_bytes)
//! info(label) = LP("tsunagi-network-id-v1") || LP(label)
//! network_id = HKDF-Expand(prk, info("network-id"), 32)
//! discovery_key = HKDF-Expand(prk, info("discovery-key"), 32)
//! auth_key = HKDF-Expand(prk, info("handshake-auth"), 32)
//! ```
//!
//! HKDF's `info` parameter is what separates the three derived values
//! (RFC 5869 §3.2). Learning `discovery_key` — which is published to a
//! discovery backend and is therefore semi-public — does not reveal `auth_key`,
//! so the discovery key must never be used as a password or bearer token.
//!
//! The scheme label is versioned and frozen. Upgrading this crate or bumping
//! the control protocol version must not change an existing [`NetworkId`].
use hkdf::Hkdf;
use sha2::{Digest, Sha256};
use zeroize::{Zeroize, Zeroizing};
use crate::error::{Error, Result};
/// Frozen label identifying the network identity derivation scheme.
///
/// Changing this string creates a different, incompatible network space for the
/// same name and secret. It must never be changed casually.
pub const IDENTITY_SCHEME: &str = "tsunagi-network-id-v1";
/// Maximum length of a network name in UTF-8 bytes.
pub const MAX_NETWORK_NAME_LEN: usize = 64;
/// Minimum length of a network secret in bytes.
///
/// The proof-of-concept targets high-entropy shared secrets. See
/// [`NetworkSecret::generate`].
pub const MIN_NETWORK_SECRET_LEN: usize = 16;
/// Maximum length of a network secret in bytes.
pub const MAX_NETWORK_SECRET_LEN: usize = 1024;
/// Human-readable prefix of the canonical secret text encoding.
pub const SECRET_TEXT_PREFIX: &str = "tsn1";
/// Appends `LP(bytes) = u32_be(len) || bytes` to `out`.
///
/// Panics are impossible here: the caller-provided slices are already bounded by
/// [`MAX_NETWORK_NAME_LEN`] / [`MAX_NETWORK_SECRET_LEN`], and the cast is
/// saturating for anything larger.
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);
}
/// The name half of a network space.
///
/// Rules, deliberately strict and never silently applied:
///
/// * 1..=[`MAX_NETWORK_NAME_LEN`] bytes of UTF-8.
/// * No ASCII control characters.
/// * No leading or trailing ASCII whitespace — such a name is **rejected**,
/// not trimmed.
/// * Used verbatim. No case folding and no Unicode normalisation is performed,
/// so `Home` and `home` are different network spaces.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct NetworkName(String);
impl NetworkName {
/// Validates and wraps a network name.
pub fn new(name: impl Into<String>) -> Result<Self> {
let name = name.into();
if name.is_empty() {
return Err(Error::InvalidNetworkName("must not be empty"));
}
if name.len() > MAX_NETWORK_NAME_LEN {
return Err(Error::InvalidNetworkName(
"must not exceed 64 bytes of UTF-8",
));
}
if name.chars().any(|c| c.is_control()) {
return Err(Error::InvalidNetworkName(
"must not contain control characters",
));
}
let trimmed = name.trim_matches(|c: char| c.is_ascii_whitespace());
if trimmed.len() != name.len() {
return Err(Error::InvalidNetworkName(
"must not have leading or trailing ASCII whitespace",
));
}
Ok(Self(name))
}
/// Returns the name as a string slice.
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for NetworkName {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl std::str::FromStr for NetworkName {
type Err = Error;
fn from_str(s: &str) -> Result<Self> {
Self::new(s)
}
}
/// The shared secret half of a network space.
///
/// This is the single shared secret the end user configures; "password" and
/// "secret" refer to the same value. The bytes are used **verbatim**: never
/// trimmed, case-folded, normalised or truncated.
///
/// The value is zeroized on drop and redacted from [`Debug`].
#[derive(Clone, PartialEq, Eq)]
pub struct NetworkSecret(Zeroizing<Vec<u8>>);
impl NetworkSecret {
/// Wraps raw secret bytes.
///
/// Requires at least [`MIN_NETWORK_SECRET_LEN`] bytes. This crate makes no
/// security promises for short, low-entropy human passphrases: there is no
/// PAKE here, so an offline guessing attack against a weak secret is cheap
/// for anyone who can reach the handshake.
pub fn from_bytes(bytes: impl Into<Vec<u8>>) -> Result<Self> {
let bytes = Zeroizing::new(bytes.into());
if bytes.len() < MIN_NETWORK_SECRET_LEN {
return Err(Error::InvalidNetworkSecret(
"must be at least 16 bytes; use NetworkSecret::generate()",
));
}
if bytes.len() > MAX_NETWORK_SECRET_LEN {
return Err(Error::InvalidNetworkSecret("must not exceed 1024 bytes"));
}
Ok(Self(bytes))
}
/// Generates a fresh 32-byte random secret.
///
/// This is the recommended way to create a network secret.
pub fn generate() -> Self {
let mut buf = vec![0u8; 32];
rand::fill(&mut buf[..]);
Self(Zeroizing::new(buf))
}
/// Parses the canonical text form produced by [`NetworkSecret::encode`].
///
/// The format is `tsn1` followed by lowercase unpadded RFC 4648 base32.
/// Parsing is strict: no whitespace, no case mixing in the payload.
pub fn decode(text: &str) -> Result<Self> {
let payload = text
.strip_prefix(SECRET_TEXT_PREFIX)
.ok_or(Error::InvalidNetworkSecret(
"canonical secrets start with `tsn1`",
))?;
let bytes = data_encoding::BASE32_NOPAD
.decode(payload.to_ascii_uppercase().as_bytes())
.map_err(|_| Error::InvalidNetworkSecret("not valid base32"))?;
Self::from_bytes(bytes)
}
/// Encodes the secret in its canonical text form.
///
/// The returned string is zeroized on drop. Never log it.
pub fn encode(&self) -> Zeroizing<String> {
let mut encoded = data_encoding::BASE32_NOPAD.encode(&self.0);
encoded.make_ascii_lowercase();
let out = Zeroizing::new(format!("{SECRET_TEXT_PREFIX}{encoded}"));
encoded.zeroize();
out
}
/// Exposes the raw secret bytes.
///
/// Callers must not log, serialise or copy these bytes into diagnostics.
pub(crate) fn expose(&self) -> &[u8] {
&self.0
}
}
impl std::fmt::Debug for NetworkSecret {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("NetworkSecret(<redacted>)")
}
}
/// Encodes 32 bytes as lowercase unpadded base32.
fn b32(bytes: &[u8; 32]) -> String {
let mut s = data_encoding::BASE32_NOPAD.encode(bytes);
s.make_ascii_lowercase();
s
}
/// Decodes lowercase unpadded base32 into 32 bytes.
fn unb32(kind: &'static str, s: &str) -> Result<[u8; 32]> {
let bytes = data_encoding::BASE32_NOPAD
.decode(s.to_ascii_uppercase().as_bytes())
.map_err(|_| Error::InvalidEncoding {
kind,
reason: "not valid base32",
})?;
<[u8; 32]>::try_from(bytes.as_slice()).map_err(|_| Error::InvalidEncoding {
kind,
reason: "expected 32 bytes",
})
}
/// Public, non-secret identifier of a network space.
///
/// Safe to log, publish and put into status output. It does not authorise
/// anything on its own: an attacker who knows a `NetworkId` still cannot pass
/// the handshake without the secret.
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct NetworkId([u8; 32]);
impl NetworkId {
/// Returns the raw 32 bytes.
pub fn as_bytes(&self) -> &[u8; 32] {
&self.0
}
/// Builds a network id from raw bytes.
pub fn from_bytes(bytes: [u8; 32]) -> Self {
Self(bytes)
}
/// Returns a short prefix useful for logs.
pub fn fmt_short(&self) -> String {
b32(&self.0).chars().take(10).collect()
}
}
impl std::fmt::Display for NetworkId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&b32(&self.0))
}
}
impl std::fmt::Debug for NetworkId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "NetworkId({})", self.fmt_short())
}
}
impl std::str::FromStr for NetworkId {
type Err = Error;
fn from_str(s: &str) -> Result<Self> {
unb32("network id", s).map(Self)
}
}
/// Lookup key used to find candidates for a network in a discovery backend.
///
/// Derived from the secret, so it is not published in the clear the way a
/// [`NetworkId`] is. It is nevertheless **not** a credential: a discovery
/// backend, or anyone observing it, learns nothing that helps pass the
/// handshake.
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct DiscoveryKey([u8; 32]);
impl DiscoveryKey {
/// Returns the raw 32 bytes.
pub fn as_bytes(&self) -> &[u8; 32] {
&self.0
}
/// Builds a discovery key from raw bytes.
pub fn from_bytes(bytes: [u8; 32]) -> Self {
Self(bytes)
}
}
impl std::fmt::Display for DiscoveryKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&b32(&self.0))
}
}
impl std::fmt::Debug for DiscoveryKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"DiscoveryKey({}…)",
b32(&self.0).chars().take(10).collect::<String>()
)
}
}
/// The deterministic, immutable description of a network space.
///
/// There is no competing genesis: this value contains no creator identity, no
/// creation time and no owner signature, so two agents started independently
/// with the same parameters produce byte-identical descriptors. The secret is
/// never part of it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NetworkDescriptor {
/// Frozen derivation scheme label, see [`IDENTITY_SCHEME`].
pub scheme: &'static str,
/// The network name.
pub name: NetworkName,
/// The derived public network identifier.
pub network_id: NetworkId,
}
impl NetworkDescriptor {
/// Canonical, unambiguous byte encoding of the descriptor.
pub fn to_canonical_bytes(&self) -> Vec<u8> {
let mut out = Vec::new();
push_lp(&mut out, self.scheme.as_bytes());
push_lp(&mut out, self.name.as_str().as_bytes());
push_lp(&mut out, &self.network_id.0);
out
}
}
/// All key material derived from a [`NetworkName`] and [`NetworkSecret`].
///
/// The authentication key is zeroized on drop and never leaves this crate.
#[derive(Clone)]
pub struct NetworkKeys {
network_id: NetworkId,
discovery_key: DiscoveryKey,
auth_key: Zeroizing<[u8; 32]>,
name: NetworkName,
}
impl NetworkKeys {
/// Derives all network key material.
///
/// This is a pure function of `(name, secret)`. It does not depend on the
/// device key, the hostname, the wall clock or the order in which agents
/// start.
pub fn derive(name: &NetworkName, secret: &NetworkSecret) -> Self {
let mut salt_input = Vec::new();
push_lp(&mut salt_input, IDENTITY_SCHEME.as_bytes());
push_lp(&mut salt_input, name.as_str().as_bytes());
let salt = Sha256::digest(&salt_input);
let hk = Hkdf::<Sha256>::new(Some(&salt), secret.expose());
let expand = |label: &str| -> [u8; 32] {
let mut info = Vec::new();
push_lp(&mut info, IDENTITY_SCHEME.as_bytes());
push_lp(&mut info, label.as_bytes());
let mut okm = [0u8; 32];
// 32 bytes is far below HKDF-SHA256's 255*32 limit, so this cannot fail.
match hk.expand(&info, &mut okm) {
Ok(()) => okm,
Err(_) => unreachable!("HKDF-SHA256 expand of 32 bytes cannot fail"),
}
};
Self {
network_id: NetworkId(expand("network-id")),
discovery_key: DiscoveryKey(expand("discovery-key")),
auth_key: Zeroizing::new(expand("handshake-auth")),
name: name.clone(),
}
}
/// The public network identifier.
pub fn network_id(&self) -> NetworkId {
self.network_id
}
/// The discovery lookup key.
pub fn discovery_key(&self) -> DiscoveryKey {
self.discovery_key
}
/// The network name.
pub fn name(&self) -> &NetworkName {
&self.name
}
/// The immutable descriptor of this network space.
pub fn descriptor(&self) -> NetworkDescriptor {
NetworkDescriptor {
scheme: IDENTITY_SCHEME,
name: self.name.clone(),
network_id: self.network_id,
}
}
/// The handshake authentication key. Crate-internal on purpose.
pub(crate) fn auth_key(&self) -> &[u8; 32] {
&self.auth_key
}
}
impl std::fmt::Debug for NetworkKeys {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("NetworkKeys")
.field("name", &self.name)
.field("network_id", &self.network_id)
.field("discovery_key", &self.discovery_key)
.field("auth_key", &"<redacted>")
.finish()
}
}