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