From b0f208b05acc71ef1181f49b83a9aa8d86f1d1f2 Mon Sep 17 00:00:00 2001 From: Eric Wendland Date: Mon, 18 May 2026 17:01:50 +0200 Subject: [PATCH] Add protected peer auth check --- AGENTS.md | 4 +- README.md | 4 + crates/geth-cli/src/lib.rs | 35 +++++ crates/geth-control/src/lib.rs | 69 +++++++++- crates/geth-node/src/lib.rs | 226 +++++++++++++++++++++++++++++++++ docs/architecture.md | 12 +- docs/roadmap.md | 6 +- 7 files changed, 347 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9d352b9..ee9b9cb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -106,8 +106,8 @@ Roadmap items should be actionable and checkable: certificate metadata, revocation metadata, user service definitions, and a pinned `geth-iroh` endpoint wrapper with protocol-router scaffold, peer-card types, manual signed peer-card export/import/list commands, `geth peer ping` - over Iroh, untrusted discovery-backend trait, custom relay-map config, and - Iroh local-network discovery toggle exist. + and `geth peer auth-check` over Iroh, untrusted discovery-backend trait, + custom relay-map config, and Iroh local-network discovery toggle exist. - Canonical signed-operation envelopes exist for keychain/auth signature payloads. The keychain reducer builds an active identity view for admin keys, users, devices, nodes, agents, and endpoint bindings. diff --git a/README.md b/README.md index d2c497e..10cf8a4 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,7 @@ The bootstrap implementation provides: - `geth peer import ` - `geth peer list` - `geth peer ping ` +- `geth peer auth-check ` - `geth resource list` - `geth resource create ` - `geth keychain init [--admin-key ]` @@ -121,6 +122,9 @@ The bootstrap implementation provides: include the Iroh EndpointID plus currently known relay/direct addresses. `geth peer ping ` uses the local daemon's Iroh endpoint to dial an imported peer card and exchange a signed candidate-only peer-card ping. +`geth peer auth-check ` sends a protected +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. Importing or pinging a peer card never grants capabilities by itself. Other command groups exist as explicit stubs: `ssh proxy`. diff --git a/crates/geth-cli/src/lib.rs b/crates/geth-cli/src/lib.rs index f80cce8..fda3f57 100644 --- a/crates/geth-cli/src/lib.rs +++ b/crates/geth-cli/src/lib.rs @@ -140,6 +140,11 @@ pub enum PeerCommand { Ping { node: String, }, + AuthCheck { + node: String, + resource: String, + capability: String, + }, } #[derive(Debug, Subcommand)] @@ -464,6 +469,15 @@ fn request_for_command(command: Command) -> Result { PeerCommand::Import { path } => ControlRequest::PeerCardImport { path }, PeerCommand::List => ControlRequest::PeerCardList, PeerCommand::Ping { node } => ControlRequest::PeerPing { node }, + PeerCommand::AuthCheck { + node, + resource, + capability, + } => ControlRequest::PeerAuthCheck { + node, + resource, + capability, + }, }, Command::Resource { command: ResourceCommand::List, @@ -819,6 +833,27 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> { println!("alpn: {alpn}"); println!("note: {note}"); } + ControlResponse::PeerAuthChecked { + peer_node_id, + peer_agent_id, + endpoint_id, + resource, + capability, + allowed, + reason, + evaluated_ops, + note, + } => { + println!("peer auth: {peer_node_id}"); + println!("agent: {peer_agent_id}"); + println!("endpoint: {endpoint_id}"); + println!("resource: {resource}"); + println!("capability: {capability}"); + println!("allowed: {allowed}"); + println!("reason: {reason}"); + println!("evaluated_ops: {evaluated_ops}"); + println!("note: {note}"); + } ControlResponse::ResourceList { resources } => { if resources.is_empty() { println!("no resources"); diff --git a/crates/geth-control/src/lib.rs b/crates/geth-control/src/lib.rs index b74aea5..56d3cee 100644 --- a/crates/geth-control/src/lib.rs +++ b/crates/geth-control/src/lib.rs @@ -31,6 +31,11 @@ pub enum ControlRequest { PeerPing { node: String, }, + PeerAuthCheck { + node: String, + resource: String, + capability: String, + }, ResourceList, ResourceCreate { kind: String, @@ -238,6 +243,17 @@ pub enum ControlResponse { alpn: String, note: String, }, + PeerAuthChecked { + peer_node_id: String, + peer_agent_id: String, + endpoint_id: String, + resource: String, + capability: String, + allowed: bool, + reason: String, + evaluated_ops: usize, + note: String, + }, ResourceList { resources: Vec, }, @@ -441,7 +457,16 @@ pub struct CasBlob { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "kebab-case")] pub enum PeerControlRequest { - Ping { peer_card: PeerCard, nonce: String }, + Ping { + peer_card: PeerCard, + nonce: String, + }, + AuthCheck { + peer_card: PeerCard, + resource: String, + capability: String, + nonce: String, + }, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -456,6 +481,19 @@ pub enum PeerControlResponse { nonce: String, note: String, }, + AuthChecked { + node_id: String, + agent_id: String, + endpoint_id: String, + remote_endpoint_id: String, + resource: String, + capability: String, + allowed: bool, + reason: String, + evaluated_ops: usize, + nonce: String, + note: String, + }, Error { message: String, }, @@ -619,6 +657,16 @@ mod tests { request ); + let request = ControlRequest::PeerAuthCheck { + node: "node:peer".to_owned(), + resource: "resource:cas:local".to_owned(), + capability: "cas.fetch".to_owned(), + }; + assert_eq!( + decode_request(&encode_request(&request).expect("encode")).expect("decode"), + request + ); + let response = PeerControlResponse::Pong { node_id: "node:peer".to_owned(), agent_id: "agent:peer".to_owned(), @@ -633,5 +681,24 @@ mod tests { .expect("decode"), response ); + + let response = PeerControlResponse::AuthChecked { + node_id: "node:peer".to_owned(), + agent_id: "agent:peer".to_owned(), + endpoint_id: "endpoint:peer".to_owned(), + remote_endpoint_id: "endpoint:caller".to_owned(), + resource: "resource:cas:local".to_owned(), + capability: "cas.fetch".to_owned(), + allowed: false, + reason: "no grant".to_owned(), + evaluated_ops: 0, + nonce: "nonce".to_owned(), + note: "protected".to_owned(), + }; + assert_eq!( + decode_peer_response(&encode_peer_response(&response).expect("encode")) + .expect("decode"), + response + ); } } diff --git a/crates/geth-node/src/lib.rs b/crates/geth-node/src/lib.rs index 42afa34..3e159a8 100644 --- a/crates/geth-node/src/lib.rs +++ b/crates/geth-node/src/lib.rs @@ -232,6 +232,11 @@ pub async fn handle_request_async( match request { ControlRequest::PeerCardExport { out } => export_peer_card(node, out, true).await, ControlRequest::PeerPing { node: peer_node } => peer_ping(node, &peer_node).await, + ControlRequest::PeerAuthCheck { + node: peer_node, + resource, + capability, + } => peer_auth_check(node, &peer_node, resource, capability).await, other => handle_request(node, other), } } @@ -322,6 +327,7 @@ async fn peer_ping(node: &LocalNode, peer_node: &str) -> Result Result Err(NodeError::IrohPeer(message)), + PeerControlResponse::AuthChecked { .. } => Err(NodeError::IrohPeer( + "peer returned auth-check response to ping request".to_owned(), + )), + } +} + +async fn peer_auth_check( + node: &LocalNode, + peer_node: &str, + resource: String, + capability: String, +) -> Result { + let store = Store::open(&node.paths.metadata_db())?; + let stored = store + .get_peer_card(peer_node)? + .ok_or_else(|| NodeError::PeerNotFound(peer_node.to_owned()))?; + let peer_card: PeerCard = serde_json::from_str(&stored.card_json)?; + peer_card.validate_candidate()?; + let candidate = peer_card + .endpoints + .first() + .ok_or(geth_discovery::DiscoveryError::MissingEndpoint)?; + ensure_peer_card_matches_endpoint(&peer_card, &candidate.endpoint_id)?; + let node_addr = iroh_node_addr_from_candidate(candidate)?; + let endpoint = node + .iroh_endpoint + .lock() + .map_err(|_| NodeError::RuntimeLockPoisoned)? + .clone() + .ok_or(NodeError::IrohEndpointUnavailable)?; + let self_card = local_peer_card(node, DiscoverySource::PeerExchange, true).await?; + let nonce = geth_crypto::blake3_hex( + format!( + "{}\0{}\0{}\0{}\0{}", + node.node_id, + peer_node, + resource, + capability, + geth_store::now_ms() + ) + .as_bytes(), + ); + let request = PeerControlRequest::AuthCheck { + peer_card: self_card, + resource: resource.clone(), + capability: capability.clone(), + nonce: nonce.clone(), + }; + + let conn = endpoint + .endpoint() + .connect(node_addr, geth_iroh::ALPN_CONTROL) + .await + .map_err(|error| NodeError::IrohPeer(error.to_string()))?; + let (mut send, mut recv) = conn + .open_bi() + .await + .map_err(|error| NodeError::IrohPeer(error.to_string()))?; + send.write_all(geth_control::encode_peer_request(&request)?.as_bytes()) + .await + .map_err(|error| NodeError::IrohPeer(error.to_string()))?; + send.finish() + .map_err(|error| NodeError::IrohPeer(error.to_string()))?; + let bytes = recv + .read_to_end(64 * 1024) + .await + .map_err(|error| NodeError::IrohPeer(error.to_string()))?; + let text = + std::str::from_utf8(&bytes).map_err(|error| NodeError::IrohPeer(error.to_string()))?; + match geth_control::decode_peer_response(text)? { + PeerControlResponse::AuthChecked { + node_id, + agent_id, + endpoint_id, + resource: response_resource, + capability: response_capability, + allowed, + reason, + evaluated_ops, + nonce: response_nonce, + note, + .. + } if response_nonce == nonce + && response_resource == resource + && response_capability == capability => + { + Ok(ControlResponse::PeerAuthChecked { + peer_node_id: node_id, + peer_agent_id: agent_id, + endpoint_id, + resource: response_resource, + capability: response_capability, + allowed, + reason, + evaluated_ops, + note, + }) + } + PeerControlResponse::AuthChecked { .. } => Err(NodeError::IrohPeer( + "peer auth-check response did not match request".to_owned(), + )), + PeerControlResponse::Error { message } => Err(NodeError::IrohPeer(message)), + PeerControlResponse::Pong { .. } => Err(NodeError::IrohPeer( + "peer returned pong to auth-check request".to_owned(), + )), } } @@ -431,6 +542,7 @@ async fn handle_iroh_control_connection( let response = match geth_control::decode_peer_request(text)? { PeerControlRequest::Ping { peer_card, nonce } => { peer_card.validate_candidate()?; + ensure_peer_card_matches_endpoint(&peer_card, &remote_endpoint_id)?; let discovered = DiscoveredPeer::candidate( peer_card.clone(), UnixMillis(geth_store::now_ms()), @@ -452,6 +564,45 @@ async fn handle_iroh_control_connection( note: "peer endpoint authenticated by Iroh and peer-card signature; candidate status does not grant resource capabilities".to_owned(), } } + PeerControlRequest::AuthCheck { + peer_card, + resource, + capability, + nonce, + } => { + peer_card.validate_candidate()?; + ensure_peer_card_matches_endpoint(&peer_card, &remote_endpoint_id)?; + let discovered = DiscoveredPeer::candidate( + peer_card.clone(), + UnixMillis(geth_store::now_ms()), + DiscoverySource::PeerExchange, + )?; + let store = Store::open(&node.paths.metadata_db())?; + store.upsert_peer_card(&StoredPeerCard { + peer_id: peer_card.node_id.to_string(), + card_json: serde_json::to_string(&peer_card)?, + updated_at_ms: discovered.discovered_at.0, + })?; + let explanation = geth_auth::explain_auth_ops( + &load_auth_ops_for_resource(&store, &resource)?, + PrincipalId::new(peer_card.node_id.to_string()), + ResourceId::new(resource.clone()), + Capability::new(capability.clone()), + ); + PeerControlResponse::AuthChecked { + node_id: node.node_id.clone(), + agent_id: node.agent_id.clone(), + endpoint_id: node.iroh_status.endpoint_id.clone().unwrap_or_default(), + remote_endpoint_id, + resource, + capability, + allowed: explanation.allowed, + reason: explanation.reason, + evaluated_ops: explanation.evaluated_ops, + nonce, + note: "protected peer request authenticated endpoint/card binding before resource capability evaluation".to_owned(), + } + } }; send.write_all(geth_control::encode_peer_response(&response)?.as_bytes()) .await @@ -487,6 +638,21 @@ fn iroh_node_addr_from_candidate( Ok(node_addr) } +fn ensure_peer_card_matches_endpoint(card: &PeerCard, endpoint_id: &str) -> Result<(), NodeError> { + if card + .endpoints + .iter() + .any(|candidate| candidate.endpoint_id == endpoint_id) + { + Ok(()) + } else { + Err(NodeError::IrohPeer(format!( + "signed peer card for {} does not bind Iroh endpoint {}", + card.node_id, endpoint_id + ))) + } +} + fn display_alpn(alpn: Vec) -> String { String::from_utf8_lossy(&alpn).into_owned() } @@ -571,6 +737,7 @@ pub fn handle_request( }) } ControlRequest::PeerPing { .. } => Err(NodeError::IrohEndpointUnavailable), + ControlRequest::PeerAuthCheck { .. } => Err(NodeError::IrohEndpointUnavailable), ControlRequest::ResourceList => Ok(ControlResponse::ResourceList { resources: store .list_resources()? @@ -2046,6 +2213,65 @@ mod tests { other => panic!("unexpected ping response: {other:?}"), } + let denied = handle_request_async( + &left, + ControlRequest::PeerAuthCheck { + node: right_card.node_id.to_string(), + resource: "resource:cas:local".to_owned(), + capability: "cas.fetch".to_owned(), + }, + ) + .await + .expect("denied peer auth check"); + match denied { + ControlResponse::PeerAuthChecked { + allowed, + reason, + note, + .. + } => { + assert!(!allowed); + assert!(reason.contains("no active direct or group grant")); + assert!(note.contains("authenticated endpoint/card binding")); + } + other => panic!("unexpected denied auth response: {other:?}"), + } + + handle_request( + &right, + ControlRequest::AuthGrant { + subject: left.node_id.clone(), + resource: "resource:cas:local".to_owned(), + capability: "cas.fetch".to_owned(), + grant_id: Some("grant:left-cas-fetch".to_owned()), + }, + ) + .expect("grant left peer"); + + let allowed = handle_request_async( + &left, + ControlRequest::PeerAuthCheck { + node: right_card.node_id.to_string(), + resource: "resource:cas:local".to_owned(), + capability: "cas.fetch".to_owned(), + }, + ) + .await + .expect("allowed peer auth check"); + match allowed { + ControlResponse::PeerAuthChecked { + allowed, + reason, + evaluated_ops, + .. + } => { + assert!(allowed); + assert!(reason.contains("direct grant")); + assert_eq!(evaluated_ops, 1); + } + other => panic!("unexpected allowed auth response: {other:?}"), + } + left_endpoint.shutdown().await; right_endpoint.shutdown().await; } diff --git a/docs/architecture.md b/docs/architecture.md index 214cbe4..4aed18b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -46,8 +46,11 @@ discovers Iroh node addressing. `geth peer export/import/list` supports manual exchange of signed peer cards as untrusted candidates. Peer cards include the Iroh EndpointID plus relay/direct address candidates when the daemon can observe them. `geth peer ping ` dials an imported peer card over Iroh and -exchanges signed peer-card metadata. Automatic signed peer-card advertisement -over LAN discovery remains separate future work. +exchanges signed peer-card metadata. `geth peer auth-check + ` sends a protected Iroh control request that validates +the caller's signed peer card against the actual Iroh EndpointID before +evaluating resource-local capabilities. Automatic signed peer-card +advertisement over LAN discovery remains separate future work. Peer cards are the discovery payload. A peer card carries node ID, agent ID, endpoint candidates, timestamp, signing public key, and an Ed25519 signature @@ -55,7 +58,10 @@ over a canonical payload. Imported and ping-discovered peer cards are stored as untrusted metadata in `peer_cards`; trust reduction is future work. `auth explain` reports when a subject is only a discovered peer candidate and denies access. The peer ping path authenticates the Iroh endpoint and peer-card -signature, but it does not authorize any resource module. +signature, but it does not authorize any resource module. Protected peer +control requests must also prove that the signed peer card binds the observed +Iroh EndpointID, then reduce resource auth ops; an EndpointID alone is not +accepted as a resource principal. 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 diff --git a/docs/roadmap.md b/docs/roadmap.md index 5ab906d..c8d04c6 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -127,7 +127,7 @@ geth-to-geth connections without granting trust from discovery alone. - No discovery result grants capabilities or trust. - `auth explain` can distinguish "discovered" from "trusted". -- `[~]` Basic authenticated peer connection. +- `[x]` Basic authenticated peer connection. Acceptance criteria: - `[x]` `geth peer ping ` dials another node over Iroh using an imported signed peer card. @@ -135,9 +135,9 @@ geth-to-geth connections without granting trust from discovery alone. as a candidate only. - `[x]` The ping response records negotiated ALPN and remote endpoint identity. - - `[ ]` The remote side proves an agent/node binding before protected module + - `[x]` The remote side proves an agent/node binding before protected module access. - - `[ ]` Protected module handlers reject requests that only know an + - `[x]` Protected module handlers reject requests that only know an EndpointID and lack resource capabilities. ## Phase 2: Trust And Authorization