From 81149605bcb53327f4e7b615049e50ca9968a790 Mon Sep 17 00:00:00 2001 From: Eric Wendland Date: Sat, 16 May 2026 14:24:21 +0200 Subject: [PATCH] Add peer card discovery scaffold --- AGENTS.md | 11 +-- Cargo.lock | 2 + crates/geth-auth/src/lib.rs | 12 +++ crates/geth-discovery/Cargo.toml | 1 + crates/geth-discovery/src/lib.rs | 141 +++++++++++++++++++++++++++++-- crates/geth-node/src/lib.rs | 14 ++- crates/geth-store/src/lib.rs | 62 ++++++++++++++ crates/geth/Cargo.toml | 1 + crates/geth/tests/bootstrap.rs | 32 +++++++ docs/architecture.md | 6 ++ docs/roadmap.md | 4 +- 11 files changed, 271 insertions(+), 15 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 198f855..65fd762 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -104,8 +104,9 @@ Roadmap items should be actionable and checkable: - Local daemon, local control socket, local identity, local store, local CAS, daemon-owned Iroh endpoint startup, built-in relay-mode config, SSH certificate metadata, revocation metadata, user service definitions, and a - pinned `geth-iroh` endpoint wrapper with protocol-router scaffold exist. -- Custom relay maps, mDNS discovery, peer auth over Iroh, cr-sqlite, iroh-docs, - iroh-gossip, iroh-blobs, Automerge sync, real auth enforcement, OpenSSH KRL - generation, and Keyhive/BeeKEM-style authorization are future roadmap items - unless implemented later. + pinned `geth-iroh` endpoint wrapper with protocol-router scaffold, peer-card + types, and untrusted discovery-backend trait exist. +- Custom relay maps, mDNS discovery transport, peer auth over Iroh, cr-sqlite, + iroh-docs, iroh-gossip, iroh-blobs, Automerge sync, real auth enforcement, + OpenSSH KRL generation, and Keyhive/BeeKEM-style authorization are future + roadmap items unless implemented later. diff --git a/Cargo.lock b/Cargo.lock index a4afa17..2baf345 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1032,6 +1032,7 @@ dependencies = [ "geth-config", "geth-control", "geth-node", + "geth-store", "tempfile", "tokio", "tracing-subscriber", @@ -1134,6 +1135,7 @@ version = "0.1.0" dependencies = [ "geth-types", "serde", + "thiserror 2.0.18", ] [[package]] diff --git a/crates/geth-auth/src/lib.rs b/crates/geth-auth/src/lib.rs index a52ef71..a5606cc 100644 --- a/crates/geth-auth/src/lib.rs +++ b/crates/geth-auth/src/lib.rs @@ -71,6 +71,18 @@ impl AuthExplanation { evaluated_ops: 0, } } + + #[must_use] + pub fn discovered_candidate(subject: String, resource: String, capability: String) -> Self { + Self { + subject, + resource, + capability, + allowed: false, + reason: "subject is a discovered peer candidate only; discovery does not grant trust or authorization".to_owned(), + evaluated_ops: 0, + } + } } #[cfg(test)] diff --git a/crates/geth-discovery/Cargo.toml b/crates/geth-discovery/Cargo.toml index 991b5ec..bb8c3d7 100644 --- a/crates/geth-discovery/Cargo.toml +++ b/crates/geth-discovery/Cargo.toml @@ -7,4 +7,5 @@ license.workspace = true [dependencies] serde.workspace = true +thiserror.workspace = true geth-types = { path = "../geth-types" } diff --git a/crates/geth-discovery/src/lib.rs b/crates/geth-discovery/src/lib.rs index 70aa49e..68296e1 100644 --- a/crates/geth-discovery/src/lib.rs +++ b/crates/geth-discovery/src/lib.rs @@ -1,18 +1,149 @@ -use geth_types::NodeId; +use geth_types::{AgentId, NodeId, UnixMillis}; use serde::{Deserialize, Serialize}; +pub const PEER_CARD_SIGNATURE_NAMESPACE: &str = "geth.peer-card.v1@geth.local"; + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PeerCard { - pub node: NodeId, - pub endpoints: Vec, - pub signed_by: String, + pub node_id: NodeId, + pub agent_id: AgentId, + pub endpoints: Vec, + pub issued_at: UnixMillis, + pub signature: SignatureMetadata, +} + +impl PeerCard { + pub fn validate_candidate(&self) -> Result<(), DiscoveryError> { + if self.endpoints.is_empty() { + return Err(DiscoveryError::MissingEndpoint); + } + if self.signature.namespace != PEER_CARD_SIGNATURE_NAMESPACE { + return Err(DiscoveryError::InvalidSignatureNamespace( + self.signature.namespace.clone(), + )); + } + if self.signature.signer.is_empty() || self.signature.signature.is_empty() { + return Err(DiscoveryError::UnsignedPeerCard); + } + Ok(()) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct EndpointCandidate { + pub endpoint_id: String, + pub relay_url: Option, + pub source: DiscoverySource, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SignatureMetadata { + pub namespace: String, + pub signer: String, + pub signature: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum DiscoverySource { + Manual, + Mdns, + PeerExchange, + Imported, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct DiscoveredPeer { + pub card: PeerCard, + pub discovered_at: UnixMillis, + pub source: DiscoverySource, + pub trust_state: CandidateTrustState, +} + +impl DiscoveredPeer { + pub fn candidate( + card: PeerCard, + discovered_at: UnixMillis, + source: DiscoverySource, + ) -> Result { + card.validate_candidate()?; + Ok(Self { + card, + discovered_at, + source, + trust_state: CandidateTrustState::CandidateOnly, + }) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum CandidateTrustState { + CandidateOnly, } pub trait DiscoveryBackend { - fn candidates(&self) -> Vec; + fn candidates(&self) -> Result, DiscoveryError>; +} + +#[derive(Debug, thiserror::Error)] +pub enum DiscoveryError { + #[error("peer card has no endpoint candidates")] + MissingEndpoint, + #[error("peer card is missing signature metadata")] + UnsignedPeerCard, + #[error("invalid peer card signature namespace: {0}")] + InvalidSignatureNamespace(String), } #[must_use] pub fn discovery_is_untrusted_note() -> &'static str { "discovery returns candidate peers only and never grants trust or authorization" } + +#[cfg(test)] +mod tests { + use super::*; + + fn signed_card() -> PeerCard { + PeerCard { + node_id: "node:laptop".into(), + agent_id: "agent:abc".into(), + endpoints: vec![EndpointCandidate { + endpoint_id: "endpoint:iroh".to_owned(), + relay_url: None, + source: DiscoverySource::Manual, + }], + issued_at: UnixMillis(1), + signature: SignatureMetadata { + namespace: PEER_CARD_SIGNATURE_NAMESPACE.to_owned(), + signer: "agent:abc".to_owned(), + signature: "sig:test".to_owned(), + }, + } + } + + #[test] + fn signed_peer_card_is_valid_candidate() { + signed_card() + .validate_candidate() + .expect("valid signed candidate"); + } + + #[test] + fn discovered_peer_is_candidate_only() { + let peer = DiscoveredPeer::candidate(signed_card(), UnixMillis(2), DiscoverySource::Mdns) + .expect("candidate"); + assert_eq!(peer.trust_state, CandidateTrustState::CandidateOnly); + } + + #[test] + fn unsigned_peer_card_is_not_valid_candidate() { + let mut card = signed_card(); + card.signature.signature.clear(); + assert!(matches!( + card.validate_candidate(), + Err(DiscoveryError::UnsignedPeerCard) + )); + } +} diff --git a/crates/geth-node/src/lib.rs b/crates/geth-node/src/lib.rs index fc7cc03..248bc76 100644 --- a/crates/geth-node/src/lib.rs +++ b/crates/geth-node/src/lib.rs @@ -247,9 +247,17 @@ pub fn handle_request( subject, resource, capability, - } => Ok(ControlResponse::AuthExplain(AuthExplanation::stub( - subject, resource, capability, - ))), + } => { + if store.get_peer_card(&subject)?.is_some() { + Ok(ControlResponse::AuthExplain( + AuthExplanation::discovered_candidate(subject, resource, capability), + )) + } else { + Ok(ControlResponse::AuthExplain(AuthExplanation::stub( + subject, resource, capability, + ))) + } + } ControlRequest::SshCertRequest { public_key_path, cert_kind, diff --git a/crates/geth-store/src/lib.rs b/crates/geth-store/src/lib.rs index 0db3950..3f10047 100644 --- a/crates/geth-store/src/lib.rs +++ b/crates/geth-store/src/lib.rs @@ -250,6 +250,45 @@ impl Store { .map_err(StoreError::from) } + pub fn upsert_peer_card(&self, peer_card: &StoredPeerCard) -> Result<(), StoreError> { + self.conn.execute( + "INSERT OR REPLACE INTO peer_cards(peer_id, card_json, updated_at_ms) VALUES (?1, ?2, ?3)", + params![peer_card.peer_id, peer_card.card_json, peer_card.updated_at_ms], + )?; + Ok(()) + } + + pub fn get_peer_card(&self, peer_id: &str) -> Result, StoreError> { + let mut stmt = self.conn.prepare( + "SELECT peer_id, card_json, updated_at_ms FROM peer_cards WHERE peer_id = ?1", + )?; + let mut rows = stmt.query(params![peer_id])?; + if let Some(row) = rows.next()? { + Ok(Some(StoredPeerCard { + peer_id: row.get(0)?, + card_json: row.get(1)?, + updated_at_ms: row.get(2)?, + })) + } else { + Ok(None) + } + } + + pub fn list_peer_cards(&self) -> Result, StoreError> { + let mut stmt = self + .conn + .prepare("SELECT peer_id, card_json, updated_at_ms FROM peer_cards ORDER BY peer_id")?; + let rows = stmt.query_map([], |row| { + Ok(StoredPeerCard { + peer_id: row.get(0)?, + card_json: row.get(1)?, + updated_at_ms: row.get(2)?, + }) + })?; + rows.collect::, _>>() + .map_err(StoreError::from) + } + pub fn insert_ssh_cert_request( &self, request: &StoredSshCertRequest, @@ -430,6 +469,13 @@ pub struct CasObject { pub path: String, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct StoredPeerCard { + pub peer_id: String, + pub card_json: String, + pub updated_at_ms: i64, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct StoredSshCertRequest { pub request_id: String, @@ -527,4 +573,20 @@ mod tests { vec![revocation] ); } + + #[test] + fn peer_card_roundtrip() { + let store = Store::open_memory().expect("open"); + let peer_card = StoredPeerCard { + peer_id: "node:laptop".to_owned(), + card_json: r#"{"node_id":"node:laptop"}"#.to_owned(), + updated_at_ms: 10, + }; + store.upsert_peer_card(&peer_card).expect("insert"); + assert_eq!( + store.get_peer_card("node:laptop").expect("get"), + Some(peer_card.clone()) + ); + assert_eq!(store.list_peer_cards().expect("list"), vec![peer_card]); + } } diff --git a/crates/geth/Cargo.toml b/crates/geth/Cargo.toml index 53a58ba..a5aa98c 100644 --- a/crates/geth/Cargo.toml +++ b/crates/geth/Cargo.toml @@ -20,4 +20,5 @@ geth-cas = { path = "../geth-cas" } geth-config = { path = "../geth-config" } geth-control = { path = "../geth-control" } geth-node = { path = "../geth-node" } +geth-store = { path = "../geth-store" } tempfile.workspace = true diff --git a/crates/geth/tests/bootstrap.rs b/crates/geth/tests/bootstrap.rs index 2b6af15..c01d216 100644 --- a/crates/geth/tests/bootstrap.rs +++ b/crates/geth/tests/bootstrap.rs @@ -116,6 +116,38 @@ fn initialized_node_can_roundtrip_cas_blob() { ); } +#[test] +fn auth_explain_distinguishes_discovered_peer_candidates() { + let home = tempfile::tempdir().expect("tempdir"); + let paths = geth_config::GethPaths::from_home(home.path()); + let node = geth_node::init_node(&paths).expect("init node"); + let store = geth_store::Store::open(&paths.metadata_db()).expect("open store"); + store + .upsert_peer_card(&geth_store::StoredPeerCard { + peer_id: "node:discovered".to_owned(), + card_json: r#"{"node_id":"node:discovered"}"#.to_owned(), + updated_at_ms: 1, + }) + .expect("insert peer card"); + + let response = geth_node::handle_request( + &node, + geth_control::ControlRequest::AuthExplain { + subject: "node:discovered".to_owned(), + resource: "resource:cas:local".to_owned(), + capability: "cas.fetch".to_owned(), + }, + ) + .expect("auth explain"); + match response { + geth_control::ControlResponse::AuthExplain(explanation) => { + assert!(!explanation.allowed); + assert!(explanation.reason.contains("discovered peer candidate")); + } + other => panic!("unexpected response: {other:?}"), + } +} + #[test] fn ssh_cert_request_approval_and_revocation_export_use_local_state() { let home = tempfile::tempdir().expect("tempdir"); diff --git a/docs/architecture.md b/docs/architecture.md index b07e070..ec84d45 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -38,6 +38,12 @@ connectivity and mDNS/LAN discovery for local networks. These are connectivity and candidate-discovery mechanisms only. They do not grant trust, mutate authorization state, or make EndpointID knowledge sufficient for access. +Peer cards are the discovery payload. A peer card carries node ID, agent ID, +endpoint candidates, timestamp, and signature metadata. The current scaffold +stores peer cards as untrusted metadata in `peer_cards`; signature verification +and trust reduction are future work. `auth explain` reports when a subject is +only a discovered peer candidate and denies access. + The daemon starts this endpoint during `geth daemon run` and keeps it alive for the daemon lifetime. When endpoint startup succeeds, the Iroh EndpointID is recorded as a transport binding for the stable geth node identity. If local UDP diff --git a/docs/roadmap.md b/docs/roadmap.md index ff39ce2..44f820c 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -101,14 +101,14 @@ geth-to-geth connections without granting trust from discovery alone. - Unknown ALPNs are rejected explicitly. - Tests cover registration collisions and unknown protocol handling. -- `[ ]` Peer cards. +- `[x]` Peer cards. Acceptance criteria: - A peer card contains node ID, agent ID, endpoint candidates, timestamp, and signature metadata. - Peer cards are stored in `peer_cards`. - Invalid or unsigned peer cards do not update trust state. -- `[ ]` Untrusted discovery backend trait. +- `[x]` Untrusted discovery backend trait. Acceptance criteria: - Discovery returns candidate peer cards only. - No discovery result grants capabilities or trust.