Add protected peer auth check

This commit is contained in:
Eric Wendland 2026-05-18 17:01:50 +02:00
commit b0f208b05a
7 changed files with 347 additions and 9 deletions

View file

@ -106,8 +106,8 @@ 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`
over Iroh, untrusted discovery-backend trait, custom relay-map config, and and `geth peer auth-check` over Iroh, untrusted discovery-backend trait,
Iroh local-network discovery toggle exist. 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.

View file

@ -77,6 +77,7 @@ The bootstrap implementation provides:
- `geth peer import <path>` - `geth peer import <path>`
- `geth peer list` - `geth peer list`
- `geth peer ping <node-id>` - `geth peer ping <node-id>`
- `geth peer auth-check <node-id> <resource> <capability>`
- `geth resource list` - `geth resource list`
- `geth resource create <kind> <name>` - `geth resource create <kind> <name>`
- `geth keychain init [--admin-key <path>]` - `geth keychain init [--admin-key <path>]`
@ -121,6 +122,9 @@ The bootstrap implementation provides:
include the Iroh EndpointID plus currently known relay/direct addresses. include the Iroh EndpointID plus currently known relay/direct addresses.
`geth peer ping <node-id>` uses the local daemon's Iroh endpoint to dial an `geth peer ping <node-id>` uses the local daemon's Iroh endpoint to dial an
imported peer card and exchange a signed candidate-only peer-card ping. imported peer card and exchange a signed candidate-only peer-card ping.
`geth peer auth-check <node-id> <resource> <capability>` 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. Importing or pinging a peer card never grants capabilities by itself.
Other command groups exist as explicit stubs: `ssh proxy`. Other command groups exist as explicit stubs: `ssh proxy`.

View file

@ -140,6 +140,11 @@ pub enum PeerCommand {
Ping { Ping {
node: String, node: String,
}, },
AuthCheck {
node: String,
resource: String,
capability: String,
},
} }
#[derive(Debug, Subcommand)] #[derive(Debug, Subcommand)]
@ -464,6 +469,15 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
PeerCommand::Import { path } => ControlRequest::PeerCardImport { path }, PeerCommand::Import { path } => ControlRequest::PeerCardImport { path },
PeerCommand::List => ControlRequest::PeerCardList, PeerCommand::List => ControlRequest::PeerCardList,
PeerCommand::Ping { node } => ControlRequest::PeerPing { node }, PeerCommand::Ping { node } => ControlRequest::PeerPing { node },
PeerCommand::AuthCheck {
node,
resource,
capability,
} => ControlRequest::PeerAuthCheck {
node,
resource,
capability,
},
}, },
Command::Resource { Command::Resource {
command: ResourceCommand::List, command: ResourceCommand::List,
@ -819,6 +833,27 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> {
println!("alpn: {alpn}"); println!("alpn: {alpn}");
println!("note: {note}"); 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 } => { ControlResponse::ResourceList { resources } => {
if resources.is_empty() { if resources.is_empty() {
println!("no resources"); println!("no resources");

View file

@ -31,6 +31,11 @@ pub enum ControlRequest {
PeerPing { PeerPing {
node: String, node: String,
}, },
PeerAuthCheck {
node: String,
resource: String,
capability: String,
},
ResourceList, ResourceList,
ResourceCreate { ResourceCreate {
kind: String, kind: String,
@ -238,6 +243,17 @@ pub enum ControlResponse {
alpn: String, alpn: String,
note: 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 { ResourceList {
resources: Vec<ResourceDescriptor>, resources: Vec<ResourceDescriptor>,
}, },
@ -441,7 +457,16 @@ pub struct CasBlob {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "kebab-case")] #[serde(tag = "type", rename_all = "kebab-case")]
pub enum PeerControlRequest { 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)] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@ -456,6 +481,19 @@ pub enum PeerControlResponse {
nonce: String, nonce: String,
note: 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 { Error {
message: String, message: String,
}, },
@ -619,6 +657,16 @@ mod tests {
request 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 { let response = PeerControlResponse::Pong {
node_id: "node:peer".to_owned(), node_id: "node:peer".to_owned(),
agent_id: "agent:peer".to_owned(), agent_id: "agent:peer".to_owned(),
@ -633,5 +681,24 @@ mod tests {
.expect("decode"), .expect("decode"),
response 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
);
} }
} }

View file

@ -232,6 +232,11 @@ pub async fn handle_request_async(
match request { match request {
ControlRequest::PeerCardExport { out } => export_peer_card(node, out, true).await, ControlRequest::PeerCardExport { out } => export_peer_card(node, out, true).await,
ControlRequest::PeerPing { node: peer_node } => peer_ping(node, &peer_node).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), other => handle_request(node, other),
} }
} }
@ -322,6 +327,7 @@ async fn peer_ping(node: &LocalNode, peer_node: &str) -> Result<ControlResponse,
.endpoints .endpoints
.first() .first()
.ok_or(geth_discovery::DiscoveryError::MissingEndpoint)?; .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 node_addr = iroh_node_addr_from_candidate(candidate)?;
let endpoint = node let endpoint = node
.iroh_endpoint .iroh_endpoint
@ -386,6 +392,111 @@ async fn peer_ping(node: &LocalNode, peer_node: &str) -> Result<ControlResponse,
"peer ping response nonce did not match request".to_owned(), "peer ping response nonce did not match request".to_owned(),
)), )),
PeerControlResponse::Error { message } => Err(NodeError::IrohPeer(message)), PeerControlResponse::Error { message } => 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<ControlResponse, NodeError> {
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)? { let response = match geth_control::decode_peer_request(text)? {
PeerControlRequest::Ping { peer_card, nonce } => { PeerControlRequest::Ping { peer_card, nonce } => {
peer_card.validate_candidate()?; peer_card.validate_candidate()?;
ensure_peer_card_matches_endpoint(&peer_card, &remote_endpoint_id)?;
let discovered = DiscoveredPeer::candidate( let discovered = DiscoveredPeer::candidate(
peer_card.clone(), peer_card.clone(),
UnixMillis(geth_store::now_ms()), 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(), 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()) send.write_all(geth_control::encode_peer_response(&response)?.as_bytes())
.await .await
@ -487,6 +638,21 @@ fn iroh_node_addr_from_candidate(
Ok(node_addr) 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<u8>) -> String { fn display_alpn(alpn: Vec<u8>) -> String {
String::from_utf8_lossy(&alpn).into_owned() String::from_utf8_lossy(&alpn).into_owned()
} }
@ -571,6 +737,7 @@ pub fn handle_request(
}) })
} }
ControlRequest::PeerPing { .. } => Err(NodeError::IrohEndpointUnavailable), ControlRequest::PeerPing { .. } => Err(NodeError::IrohEndpointUnavailable),
ControlRequest::PeerAuthCheck { .. } => Err(NodeError::IrohEndpointUnavailable),
ControlRequest::ResourceList => Ok(ControlResponse::ResourceList { ControlRequest::ResourceList => Ok(ControlResponse::ResourceList {
resources: store resources: store
.list_resources()? .list_resources()?
@ -2046,6 +2213,65 @@ mod tests {
other => panic!("unexpected ping response: {other:?}"), 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; left_endpoint.shutdown().await;
right_endpoint.shutdown().await; right_endpoint.shutdown().await;
} }

View file

@ -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 exchange of signed peer cards as untrusted candidates. Peer cards include the
Iroh EndpointID plus relay/direct address candidates when the daemon can observe Iroh EndpointID plus relay/direct address candidates when the daemon can observe
them. `geth peer ping <node-id>` dials an imported peer card over Iroh and them. `geth peer ping <node-id>` dials an imported peer card over Iroh and
exchanges signed peer-card metadata. Automatic signed peer-card advertisement exchanges signed peer-card metadata. `geth peer auth-check <node-id>
over LAN discovery remains separate future work. <resource> <capability>` 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, 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
@ -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 untrusted metadata in `peer_cards`; trust reduction is future work. `auth
explain` reports when a subject is only a discovered peer candidate and denies 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 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 starts this endpoint during `geth daemon run` and keeps it alive for
the daemon lifetime. When endpoint startup succeeds, the Iroh EndpointID is the daemon lifetime. When endpoint startup succeeds, the Iroh EndpointID is

View file

@ -127,7 +127,7 @@ geth-to-geth connections without granting trust from discovery alone.
- No discovery result grants capabilities or trust. - No discovery result grants capabilities or trust.
- `auth explain` can distinguish "discovered" from "trusted". - `auth explain` can distinguish "discovered" from "trusted".
- `[~]` Basic authenticated peer connection. - `[x]` Basic authenticated peer connection.
Acceptance criteria: Acceptance criteria:
- `[x]` `geth peer ping <node-id>` dials another node over Iroh using an - `[x]` `geth peer ping <node-id>` dials another node over Iroh using an
imported signed peer card. imported signed peer card.
@ -135,9 +135,9 @@ geth-to-geth connections without granting trust from discovery alone.
as a candidate only. as a candidate only.
- `[x]` The ping response records negotiated ALPN and remote endpoint - `[x]` The ping response records negotiated ALPN and remote endpoint
identity. 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. 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. EndpointID and lack resource capabilities.
## Phase 2: Trust And Authorization ## Phase 2: Trust And Authorization