Add protected peer auth check
This commit is contained in:
parent
679eeb48a3
commit
b0f208b05a
7 changed files with 347 additions and 9 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue