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

@ -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<ControlRequest> {
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");

View file

@ -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<ResourceDescriptor>,
},
@ -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
);
}
}

View file

@ -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<ControlResponse,
.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
@ -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(),
)),
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)? {
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<u8>) -> 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;
}