Advertise signed peer cards on LAN

This commit is contained in:
Eric Wendland 2026-05-18 17:05:43 +02:00
commit d5a548182b
10 changed files with 306 additions and 7 deletions

View file

@ -106,8 +106,9 @@ Roadmap items should be actionable and checkable:
certificate metadata, revocation metadata, user service definitions, and a certificate metadata, revocation metadata, user service definitions, and a
pinned `geth-iroh` endpoint wrapper with protocol-router scaffold, peer-card pinned `geth-iroh` endpoint wrapper with protocol-router scaffold, peer-card
types, manual signed peer-card export/import/list commands, `geth peer ping` types, manual signed peer-card export/import/list commands, `geth peer ping`
and `geth peer auth-check` over Iroh, untrusted discovery-backend trait, and `geth peer auth-check` over Iroh, signed peer-card LAN discovery payloads,
custom relay-map config, and Iroh local-network discovery toggle exist. untrusted discovery-backend trait, custom relay-map config, and Iroh
local-network discovery toggle exist.
- Canonical signed-operation envelopes exist for keychain/auth signature - Canonical signed-operation envelopes exist for keychain/auth signature
payloads. The keychain reducer builds an active identity view for admin keys, payloads. The keychain reducer builds an active identity view for admin keys,
users, devices, nodes, agents, and endpoint bindings. users, devices, nodes, agents, and endpoint bindings.

3
Cargo.lock generated
View file

@ -1172,10 +1172,12 @@ dependencies = [
name = "geth-discovery" name = "geth-discovery"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"base64",
"geth-crypto", "geth-crypto",
"geth-types", "geth-types",
"hex", "hex",
"serde", "serde",
"serde_json",
"thiserror 2.0.18", "thiserror 2.0.18",
] ]
@ -1247,6 +1249,7 @@ dependencies = [
"geth-types", "geth-types",
"iroh", "iroh",
"serde_json", "serde_json",
"swarm-discovery",
"tempfile", "tempfile",
"thiserror 2.0.18", "thiserror 2.0.18",
"tokio", "tokio",

View file

@ -51,6 +51,7 @@ rand_core = { version = "0.6", features = ["getrandom"] }
rusqlite = { version = "0.32", features = ["bundled"] } rusqlite = { version = "0.32", features = ["bundled"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
swarm-discovery = "0.4.1"
tempfile = "3" tempfile = "3"
thiserror = "2" thiserror = "2"
time = { version = "0.3", features = ["formatting", "serde"] } time = { version = "0.3", features = ["formatting", "serde"] }

View file

@ -126,6 +126,9 @@ imported peer card and exchange a signed candidate-only peer-card ping.
Iroh control request: the remote daemon verifies that the caller's signed peer Iroh control request: the remote daemon verifies that the caller's signed peer
card binds the actual Iroh EndpointID before reducing resource-local auth ops. card binds the actual Iroh EndpointID before reducing resource-local auth ops.
Importing or pinging a peer card never grants capabilities by itself. Importing or pinging a peer card never grants capabilities by itself.
When `[iroh].local_discovery = true`, the daemon also advertises and discovers
signed peer cards on LAN using a geth-specific mDNS TXT payload. That payload is
candidate metadata only; all geth node-to-node requests still run over Iroh.
Other command groups exist as explicit stubs: `ssh proxy`. Other command groups exist as explicit stubs: `ssh proxy`.

View file

@ -6,7 +6,9 @@ rust-version.workspace = true
license.workspace = true license.workspace = true
[dependencies] [dependencies]
base64.workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true thiserror.workspace = true
hex.workspace = true hex.workspace = true
geth-crypto = { path = "../geth-crypto" } geth-crypto = { path = "../geth-crypto" }

View file

@ -1,8 +1,16 @@
use base64::Engine;
use geth_crypto::AgentKey; use geth_crypto::AgentKey;
use geth_types::{AgentId, NodeId, UnixMillis}; use geth_types::{AgentId, NodeId, UnixMillis};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
pub const PEER_CARD_SIGNATURE_NAMESPACE: &str = "geth.peer-card.v1@geth.local"; pub const PEER_CARD_SIGNATURE_NAMESPACE: &str = "geth.peer-card.v1@geth.local";
pub const PEER_CARD_LAN_DISCOVERY_SERVICE: &str = "geth-peer-card";
pub const PEER_CARD_TXT_VERSION_KEY: &str = "geth";
pub const PEER_CARD_TXT_VERSION: &str = "peer-card-v1";
pub const PEER_CARD_TXT_CHUNKS_KEY: &str = "card-chunks";
pub const PEER_CARD_TXT_CHUNK_PREFIX: &str = "card-";
const PEER_CARD_TXT_CHUNK_BYTES: usize = 200;
const PEER_CARD_TXT_MAX_CHUNKS: usize = 32;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PeerCard { pub struct PeerCard {
@ -164,6 +172,86 @@ pub trait DiscoveryBackend {
fn candidates(&self) -> Result<Vec<DiscoveredPeer>, DiscoveryError>; fn candidates(&self) -> Result<Vec<DiscoveredPeer>, DiscoveryError>;
} }
pub fn peer_card_txt_attributes(
card: &PeerCard,
) -> Result<Vec<(String, Option<String>)>, DiscoveryError> {
card.validate_candidate()?;
let json = serde_json::to_vec(card)?;
let encoded = base64::engine::general_purpose::STANDARD_NO_PAD.encode(json);
let chunks = encoded
.as_bytes()
.chunks(PEER_CARD_TXT_CHUNK_BYTES)
.map(|chunk| {
std::str::from_utf8(chunk)
.expect("base64 is utf-8")
.to_owned()
})
.collect::<Vec<_>>();
if chunks.is_empty() || chunks.len() > PEER_CARD_TXT_MAX_CHUNKS {
return Err(DiscoveryError::PeerCardTxtTooLarge {
chunks: chunks.len(),
max_chunks: PEER_CARD_TXT_MAX_CHUNKS,
});
}
let mut attributes = vec![
(
PEER_CARD_TXT_VERSION_KEY.to_owned(),
Some(PEER_CARD_TXT_VERSION.to_owned()),
),
(
PEER_CARD_TXT_CHUNKS_KEY.to_owned(),
Some(chunks.len().to_string()),
),
];
for (index, chunk) in chunks.into_iter().enumerate() {
attributes.push((
format!("{PEER_CARD_TXT_CHUNK_PREFIX}{index:02}"),
Some(chunk),
));
}
Ok(attributes)
}
pub fn peer_card_from_txt_attributes<'a>(
attributes: impl IntoIterator<Item = (&'a str, Option<&'a str>)>,
) -> Result<PeerCard, DiscoveryError> {
let attributes = attributes.into_iter().collect::<Vec<_>>();
let version = txt_value(&attributes, PEER_CARD_TXT_VERSION_KEY)
.ok_or_else(|| DiscoveryError::MissingTxtAttribute(PEER_CARD_TXT_VERSION_KEY.to_owned()))?;
if version != PEER_CARD_TXT_VERSION {
return Err(DiscoveryError::UnsupportedTxtVersion(version.to_owned()));
}
let chunk_count = txt_value(&attributes, PEER_CARD_TXT_CHUNKS_KEY)
.ok_or_else(|| DiscoveryError::MissingTxtAttribute(PEER_CARD_TXT_CHUNKS_KEY.to_owned()))?
.parse::<usize>()
.map_err(|_| DiscoveryError::InvalidTxtAttribute(PEER_CARD_TXT_CHUNKS_KEY.to_owned()))?;
if chunk_count == 0 || chunk_count > PEER_CARD_TXT_MAX_CHUNKS {
return Err(DiscoveryError::PeerCardTxtTooLarge {
chunks: chunk_count,
max_chunks: PEER_CARD_TXT_MAX_CHUNKS,
});
}
let mut encoded = String::new();
for index in 0..chunk_count {
let key = format!("{PEER_CARD_TXT_CHUNK_PREFIX}{index:02}");
let chunk = txt_value(&attributes, &key)
.ok_or_else(|| DiscoveryError::MissingTxtAttribute(key.clone()))?;
encoded.push_str(chunk);
}
let json = base64::engine::general_purpose::STANDARD_NO_PAD.decode(encoded)?;
let card: PeerCard = serde_json::from_slice(&json)?;
card.validate_candidate()?;
Ok(card)
}
fn txt_value<'a>(attributes: &[(&'a str, Option<&'a str>)], key: &str) -> Option<&'a str> {
attributes
.iter()
.find_map(|(candidate, value)| (*candidate == key).then_some(*value).flatten())
}
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum DiscoveryError { pub enum DiscoveryError {
#[error("peer card has no endpoint candidates")] #[error("peer card has no endpoint candidates")]
@ -180,6 +268,18 @@ pub enum DiscoveryError {
Hex(#[from] hex::FromHexError), Hex(#[from] hex::FromHexError),
#[error("peer card signature error: {0}")] #[error("peer card signature error: {0}")]
Crypto(#[from] geth_crypto::CryptoError), Crypto(#[from] geth_crypto::CryptoError),
#[error("peer card TXT payload is too large: {chunks} chunks, max {max_chunks}")]
PeerCardTxtTooLarge { chunks: usize, max_chunks: usize },
#[error("missing peer card TXT attribute: {0}")]
MissingTxtAttribute(String),
#[error("invalid peer card TXT attribute: {0}")]
InvalidTxtAttribute(String),
#[error("unsupported peer card TXT version: {0}")]
UnsupportedTxtVersion(String),
#[error("peer card TXT base64 error: {0}")]
Base64(#[from] base64::DecodeError),
#[error("peer card JSON error: {0}")]
Json(#[from] serde_json::Error),
} }
#[must_use] #[must_use]
@ -241,4 +341,47 @@ mod tests {
Err(DiscoveryError::Crypto(geth_crypto::CryptoError::Verify)) Err(DiscoveryError::Crypto(geth_crypto::CryptoError::Verify))
)); ));
} }
#[test]
fn peer_card_txt_attributes_roundtrip_signed_payload() {
let card = signed_card();
let attributes = peer_card_txt_attributes(&card).expect("txt attributes");
assert_eq!(
attributes
.iter()
.find(|(key, _)| key == PEER_CARD_TXT_VERSION_KEY)
.and_then(|(_, value)| value.as_deref()),
Some(PEER_CARD_TXT_VERSION)
);
assert!(attributes.iter().any(|(key, _)| key == "card-00"));
assert!(
attributes
.iter()
.all(|(key, value)| key.len() + value.as_deref().unwrap_or("").len() <= 254)
);
let decoded = peer_card_from_txt_attributes(
attributes
.iter()
.map(|(key, value)| (key.as_str(), value.as_deref())),
)
.expect("decode txt attributes");
assert_eq!(decoded, card);
}
#[test]
fn peer_card_txt_attributes_reject_missing_chunk() {
let card = signed_card();
let attributes = peer_card_txt_attributes(&card).expect("txt attributes");
let without_first_chunk = attributes
.iter()
.filter(|(key, _)| key != "card-00")
.map(|(key, value)| (key.as_str(), value.as_deref()));
assert!(matches!(
peer_card_from_txt_attributes(without_first_chunk),
Err(DiscoveryError::MissingTxtAttribute(key)) if key == "card-00"
));
}
} }

View file

@ -29,6 +29,7 @@ geth-ssh-identity = { path = "../geth-ssh-identity" }
geth-store = { path = "../geth-store" } geth-store = { path = "../geth-store" }
geth-types = { path = "../geth-types" } geth-types = { path = "../geth-types" }
iroh.workspace = true iroh.workspace = true
swarm-discovery.workspace = true
[dev-dependencies] [dev-dependencies]
tempfile.workspace = true tempfile.workspace = true

View file

@ -13,7 +13,8 @@ use geth_control::{
use geth_crypto::AgentKey; use geth_crypto::AgentKey;
use geth_db::DbResource; use geth_db::DbResource;
use geth_discovery::{ use geth_discovery::{
DiscoveredPeer, DiscoverySource, EndpointCandidate, PeerCard, discovery_is_untrusted_note, DiscoveredPeer, DiscoverySource, EndpointCandidate, PEER_CARD_LAN_DISCOVERY_SERVICE, PeerCard,
discovery_is_untrusted_note, peer_card_from_txt_attributes, peer_card_txt_attributes,
}; };
use geth_document::{DocumentResource, DocumentState}; use geth_document::{DocumentResource, DocumentState};
use geth_iroh::{EndpointStatus, GethIrohConfig, GethIrohEndpoint, GethRelayMode}; use geth_iroh::{EndpointStatus, GethIrohConfig, GethIrohEndpoint, GethRelayMode};
@ -193,6 +194,7 @@ pub async fn run_daemon(paths: GethPaths) -> Result<(), NodeError> {
{ {
spawn_iroh_control_accept_loop(node.clone(), endpoint); spawn_iroh_control_accept_loop(node.clone(), endpoint);
} }
let _peer_card_lan_discovery = start_peer_card_lan_discovery(&node).await;
if Path::new(&paths.socket_path()).exists() { if Path::new(&paths.socket_path()).exists() {
std::fs::remove_file(paths.socket_path())?; std::fs::remove_file(paths.socket_path())?;
} }
@ -259,6 +261,121 @@ async fn handle_stream(node: LocalNode, stream: UnixStream) -> Result<(), NodeEr
Ok(()) Ok(())
} }
async fn start_peer_card_lan_discovery(node: &LocalNode) -> Option<swarm_discovery::DropGuard> {
if !node.iroh_status.local_discovery || !node.iroh_status.enabled {
return None;
}
let card = match local_peer_card(node, DiscoverySource::Mdns, true).await {
Ok(card) => card,
Err(error) => {
tracing::warn!(%error, "signed peer-card LAN discovery disabled");
return None;
}
};
let attributes = match peer_card_txt_attributes(&card) {
Ok(attributes) => attributes,
Err(error) => {
tracing::warn!(%error, "could not encode peer-card LAN TXT payload");
return None;
}
};
let Some((port, addrs)) = lan_discovery_addrs(&card) else {
tracing::warn!(
"signed peer-card LAN discovery disabled because no direct Iroh address is known"
);
return None;
};
let paths = node.paths.clone();
let self_node_id = node.node_id.clone();
let discoverer = match swarm_discovery::Discoverer::new_interactive(
PEER_CARD_LAN_DISCOVERY_SERVICE.to_owned(),
lan_discovery_peer_id(&node.agent_id),
)
.with_addrs(port, addrs)
.with_txt_attributes(attributes)
{
Ok(discoverer) => discoverer,
Err(error) => {
tracing::warn!(%error, "could not build peer-card LAN discovery");
return None;
}
}
.with_callback(move |_peer_id, peer| {
if peer.is_expiry() {
return;
}
let card = match peer_card_from_txt_attributes(peer.txt_attributes()) {
Ok(card) => card,
Err(error) => {
tracing::debug!(%error, "ignoring invalid peer-card LAN discovery payload");
return;
}
};
if card.node_id.as_str() == self_node_id {
return;
}
let discovered = match DiscoveredPeer::candidate(
card.clone(),
UnixMillis(geth_store::now_ms()),
DiscoverySource::Mdns,
) {
Ok(discovered) => discovered,
Err(error) => {
tracing::debug!(%error, "ignoring invalid LAN peer-card candidate");
return;
}
};
match Store::open(&paths.metadata_db()).and_then(|store| {
store.upsert_peer_card(&StoredPeerCard {
peer_id: card.node_id.to_string(),
card_json: serde_json::to_string(&card).map_err(geth_store::StoreError::from)?,
updated_at_ms: discovered.discovered_at.0,
})
}) {
Ok(()) => tracing::debug!(node = %card.node_id, "stored LAN peer-card candidate"),
Err(error) => tracing::warn!(%error, "could not store LAN peer-card candidate"),
}
});
match discoverer.spawn(&tokio::runtime::Handle::current()) {
Ok(guard) => {
tracing::info!(
service = PEER_CARD_LAN_DISCOVERY_SERVICE,
"signed peer-card LAN discovery running"
);
Some(guard)
}
Err(error) => {
tracing::warn!(%error, "could not start signed peer-card LAN discovery");
None
}
}
}
fn lan_discovery_peer_id(agent_id: &str) -> String {
format!("geth-{}", geth_crypto::blake3_hex(agent_id.as_bytes()))
}
fn lan_discovery_addrs(card: &PeerCard) -> Option<(u16, Vec<std::net::IpAddr>)> {
let mut parsed = card
.endpoints
.iter()
.flat_map(|endpoint| endpoint.direct_addresses.iter())
.filter_map(|addr| addr.parse::<std::net::SocketAddr>().ok())
.collect::<Vec<_>>();
parsed.sort_unstable();
parsed.dedup();
let port = parsed.first()?.port();
let addrs = parsed
.into_iter()
.filter(|addr| addr.port() == port)
.map(|addr| addr.ip())
.collect::<Vec<_>>();
(!addrs.is_empty()).then_some((port, addrs))
}
async fn export_peer_card( async fn export_peer_card(
node: &LocalNode, node: &LocalNode,
out: Option<std::path::PathBuf>, out: Option<std::path::PathBuf>,
@ -2143,6 +2260,32 @@ mod tests {
.expect("write config"); .expect("write config");
} }
#[test]
fn lan_discovery_address_selection_uses_iroh_direct_addresses() {
let key = AgentKey::generate();
let card = PeerCard::signed(
"node:test".into(),
&key,
vec![EndpointCandidate {
endpoint_id: "endpoint:test".to_owned(),
relay_url: None,
direct_addresses: vec![
"127.0.0.1:1111".to_owned(),
"127.0.0.2:1111".to_owned(),
"127.0.0.3:2222".to_owned(),
],
source: DiscoverySource::Mdns,
}],
UnixMillis(1),
)
.expect("peer card");
let (port, addrs) = lan_discovery_addrs(&card).expect("lan addresses");
assert_eq!(port, 1111);
assert_eq!(addrs.len(), 2);
}
#[tokio::test] #[tokio::test]
async fn peer_ping_uses_signed_peer_card_over_iroh() { async fn peer_ping_uses_signed_peer_card_over_iroh() {
let left_home = tempfile::tempdir().expect("left home"); let left_home = tempfile::tempdir().expect("left home");

View file

@ -49,8 +49,10 @@ them. `geth peer ping <node-id>` dials an imported peer card over Iroh and
exchanges signed peer-card metadata. `geth peer auth-check <node-id> exchanges signed peer-card metadata. `geth peer auth-check <node-id>
<resource> <capability>` sends a protected Iroh control request that validates <resource> <capability>` sends a protected Iroh control request that validates
the caller's signed peer card against the actual Iroh EndpointID before the caller's signed peer card against the actual Iroh EndpointID before
evaluating resource-local capabilities. Automatic signed peer-card evaluating resource-local capabilities. When local discovery is enabled, the
advertisement over LAN discovery remains separate future work. daemon also advertises and discovers signed peer cards through a geth-specific
mDNS service. The LAN payload is TXT-encoded signed metadata only; remote geth
traffic still uses Iroh.
Peer cards are the discovery payload. A peer card carries node ID, agent ID, Peer cards are the discovery payload. A peer card carries node ID, agent ID,
endpoint candidates, timestamp, signing public key, and an Ed25519 signature endpoint candidates, timestamp, signing public key, and an Ed25519 signature

View file

@ -94,13 +94,13 @@ geth-to-geth connections without granting trust from discovery alone.
- The daemon registers Iroh's local mDNS-like discovery service when enabled. - The daemon registers Iroh's local mDNS-like discovery service when enabled.
- `geth status --json` reports whether local-network discovery is enabled. - `geth status --json` reports whether local-network discovery is enabled.
- `[ ]` Signed peer-card LAN discovery payloads. - `[x]` Signed peer-card LAN discovery payloads.
Acceptance criteria: Acceptance criteria:
- `[x]` Manual `geth peer export/import/list` can exchange signed peer cards - `[x]` Manual `geth peer export/import/list` can exchange signed peer cards
and store them as untrusted candidates. and store them as untrusted candidates.
- `[x]` Exported daemon peer cards include Iroh EndpointID plus available - `[x]` Exported daemon peer cards include Iroh EndpointID plus available
relay/direct address candidates. relay/direct address candidates.
- `[ ]` The daemon can advertise and discover signed geth peer cards over LAN - `[x]` The daemon can advertise and discover signed geth peer cards over LAN
discovery. discovery.
- `[x]` Imported peer cards are stored only as untrusted peer candidates. - `[x]` Imported peer cards are stored only as untrusted peer candidates.
- `[x]` Discovered EndpointIDs do not grant module access without keychain/auth - `[x]` Discovered EndpointIDs do not grant module access without keychain/auth