Add authorized file-root sync

This commit is contained in:
Eric Wendland 2026-05-20 13:22:40 +02:00
commit 1caaedda90
7 changed files with 489 additions and 11 deletions

View file

@ -257,6 +257,11 @@ pub async fn handle_request_async(
hash,
bearer_secret,
} => cas_fetch_from_peer(node, &peer_node, hash, bearer_secret).await,
ControlRequest::CasRootSync {
node: peer_node,
name,
bearer_secret,
} => cas_root_sync_from_peer(node, &peer_node, &name, bearer_secret).await,
ControlRequest::SshCertSync {
node: peer_node,
bearer_secret,
@ -581,6 +586,7 @@ async fn peer_ping(node: &LocalNode, peer_node: &str) -> Result<ControlResponse,
)),
PeerControlResponse::SyncStatus { .. }
| PeerControlResponse::CasFetched { .. }
| PeerControlResponse::CasRootSynced { .. }
| PeerControlResponse::SshCertSynced { .. }
| PeerControlResponse::SshRevocationSynced { .. }
| PeerControlResponse::KvSynced { .. }
@ -697,6 +703,7 @@ async fn peer_auth_check(
)),
PeerControlResponse::SyncStatus { .. }
| PeerControlResponse::CasFetched { .. }
| PeerControlResponse::CasRootSynced { .. }
| PeerControlResponse::SshCertSynced { .. }
| PeerControlResponse::SshRevocationSynced { .. }
| PeerControlResponse::KvSynced { .. }
@ -845,6 +852,7 @@ async fn cas_fetch_from_peer(
PeerControlResponse::Pong { .. }
| PeerControlResponse::AuthChecked { .. }
| PeerControlResponse::SyncStatus { .. }
| PeerControlResponse::CasRootSynced { .. }
| PeerControlResponse::SshCertSynced { .. }
| PeerControlResponse::SshRevocationSynced { .. }
| PeerControlResponse::KvSynced { .. }
@ -860,6 +868,137 @@ async fn cas_fetch_from_peer(
}
}
async fn cas_root_sync_from_peer(
node: &LocalNode,
peer_node: &str,
name: &str,
bearer_secret: Option<String>,
) -> Result<ControlResponse, NodeError> {
geth_cas::validate_file_root_name(name)?;
let response = request_peer_control(node, peer_node, "cas-root-sync", |peer_card, nonce| {
PeerControlRequest::CasRootSync {
peer_card,
name: name.to_owned(),
nonce: nonce.clone(),
bearer_proof: bearer_proof(
bearer_secret,
&format!("resource:cas-tree:{name}"),
"cas.fetch",
&nonce,
),
}
})
.await?;
match response {
PeerControlResponse::CasRootSynced {
node_id,
agent_id,
endpoint_id,
name: response_name,
root,
tree_content_base64,
allowed,
reason,
note,
..
} if response_name == name => {
if !allowed {
return Ok(ControlResponse::CasRootSynced {
peer_node_id: node_id,
peer_agent_id: agent_id,
endpoint_id,
name: response_name,
root: None,
tree_bytes_imported: false,
allowed,
reason,
note,
});
}
let store = Store::open(&node.paths.metadata_db())?;
let mut tree_bytes_imported = false;
let root = root
.map(|remote_root| {
let stored_name = remote_file_root_name(&node_id, &remote_root.name);
let stored_path = format!("remote:{node_id}:{}", remote_root.name);
if let Some(existing) = store.get_file_root_by_name(&stored_name)? {
if existing.path != stored_path {
return Err(NodeError::IrohPeer(format!(
"refusing to overwrite existing file root {} at {}",
existing.name, existing.path
)));
}
}
if let (Some(hash), Some(content)) = (
remote_root.latest_tree.clone(),
tree_content_base64.as_ref(),
) {
let decoded = base64::engine::general_purpose::STANDARD
.decode(content)
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
let info = LocalCas::new(node.paths.cas_dir()).add_bytes(&decoded)?;
if info.hash != hash {
return Err(NodeError::IrohPeer(format!(
"peer returned file-root tree bytes hash {} for announced {}",
info.hash, hash
)));
}
store.record_cas_object(
info.hash.as_str(),
info.size_bytes,
&info.path.to_string_lossy(),
)?;
tree_bytes_imported = true;
}
let stored = StoredFileRoot {
root_id: format!("file-root:remote:{node_id}:{}", remote_root.name),
resource_id: remote_root.resource.clone(),
name: stored_name,
path: stored_path,
latest_tree_hash: remote_root.latest_tree.as_ref().map(ToString::to_string),
latest_tree_json: None,
updated_at_ms: remote_root.updated_at_ms,
};
store.upsert_file_root(&stored)?;
Ok(file_root_from_stored(&stored))
})
.transpose()?;
Ok(ControlResponse::CasRootSynced {
peer_node_id: node_id,
peer_agent_id: agent_id,
endpoint_id,
name: response_name,
root,
tree_bytes_imported,
allowed,
reason,
note,
})
}
PeerControlResponse::CasRootSynced { .. } => Err(NodeError::IrohPeer(
"peer file-root sync response did not match request".to_owned(),
)),
PeerControlResponse::Error { message } => Err(NodeError::IrohPeer(message)),
_ => Err(NodeError::IrohPeer(
"peer returned wrong response type to file-root sync".to_owned(),
)),
}
}
fn remote_file_root_name(peer_node_id: &str, remote_name: &str) -> String {
let peer = peer_node_id
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-') {
ch
} else {
'-'
}
})
.collect::<String>();
format!("remote-{peer}-{remote_name}")
}
async fn ssh_cert_sync_from_peer(
node: &LocalNode,
peer_node: &str,
@ -1932,6 +2071,10 @@ async fn request_peer_control(
nonce: response_nonce,
..
}
| PeerControlResponse::CasRootSynced {
nonce: response_nonce,
..
}
| PeerControlResponse::SshProxyConnected {
nonce: response_nonce,
..
@ -2253,6 +2396,73 @@ async fn handle_iroh_control_connection(
}
}
}
PeerControlRequest::CasRootSync {
peer_card,
name,
nonce,
bearer_proof,
} => {
geth_cas::validate_file_root_name(&name)?;
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 resource = format!("resource:cas-tree:{name}");
let capability = "cas.fetch".to_owned();
let explanation = explain_peer_or_bearer(
&store,
peer_card.node_id.as_str(),
&resource,
&capability,
&nonce,
bearer_proof.as_ref(),
)?;
let (root, tree_content_base64) = if explanation.allowed {
if let Some(root) = store.get_file_root_by_name(&name)? {
let root_view = file_root_from_stored(&root);
let tree_content_base64 = root
.latest_tree_hash
.as_ref()
.map(|hash| -> Result<Option<String>, NodeError> {
let hash = BlobHash::new(hash.clone());
let content = LocalCas::new(node.paths.cas_dir()).read_bytes(&hash)?;
Ok(Some(
base64::engine::general_purpose::STANDARD.encode(content),
))
})
.transpose()?
.flatten();
(Some(root_view), tree_content_base64)
} else {
(None, None)
}
} else {
(None, None)
};
PeerControlResponse::CasRootSynced {
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,
name,
root,
tree_content_base64,
allowed: explanation.allowed,
reason: explanation.reason,
evaluated_ops: explanation.evaluated_ops,
nonce,
note: "file-root sync authenticated endpoint/card binding and required cas.fetch on the remote cas-tree resource; it imports tree metadata and CAS tree bytes but never writes files".to_owned(),
}
}
PeerControlRequest::SshCertSync {
peer_card,
since_ms,
@ -2952,6 +3162,7 @@ pub fn handle_request(
ControlRequest::PeerPing { .. } => Err(NodeError::IrohEndpointUnavailable),
ControlRequest::PeerAuthCheck { .. } => Err(NodeError::IrohEndpointUnavailable),
ControlRequest::CasFetch { .. } => Err(NodeError::IrohEndpointUnavailable),
ControlRequest::CasRootSync { .. } => Err(NodeError::IrohEndpointUnavailable),
ControlRequest::SshCertSync { .. } => Err(NodeError::IrohEndpointUnavailable),
ControlRequest::SshRevocationSync { .. } => Err(NodeError::IrohEndpointUnavailable),
ControlRequest::KvSync { .. } => Err(NodeError::IrohEndpointUnavailable),
@ -5246,6 +5457,39 @@ mod tests {
let right_blob = LocalCas::new(right.paths.cas_dir())
.add_bytes(b"remote cas bytes")
.expect("right cas add");
let left_file_root = left_home.path().join("files");
std::fs::create_dir_all(&left_file_root).expect("create left file root");
std::fs::write(left_file_root.join("note.txt"), b"local tree").expect("write left file");
handle_request(
&left,
ControlRequest::CasRootAdd {
name: "shared".to_owned(),
path: left_file_root.clone(),
},
)
.expect("left file root add");
let right_file_root = right_home.path().join("files");
std::fs::create_dir_all(&right_file_root).expect("create right file root");
std::fs::write(right_file_root.join("note.txt"), b"synced tree").expect("write right file");
handle_request(
&right,
ControlRequest::CasRootAdd {
name: "shared".to_owned(),
path: right_file_root,
},
)
.expect("right file root add");
let right_tree_hash = match handle_request(
&right,
ControlRequest::CasRootScan {
name: "shared".to_owned(),
},
)
.expect("right file root scan")
{
ControlResponse::CasRootScanned { scan } => scan.tree.hash,
other => panic!("unexpected right file root scan response: {other:?}"),
};
let right_pubkey = right_home.path().join("request.pub");
std::fs::write(
&right_pubkey,
@ -5423,6 +5667,30 @@ mod tests {
other => panic!("unexpected denied CAS fetch response: {other:?}"),
}
let denied_root_sync = handle_request_async(
&left,
ControlRequest::CasRootSync {
node: right_card.node_id.to_string(),
name: "shared".to_owned(),
bearer_secret: None,
},
)
.await
.expect("denied file root sync");
match denied_root_sync {
ControlResponse::CasRootSynced {
allowed,
root,
reason,
..
} => {
assert!(!allowed);
assert!(root.is_none());
assert!(reason.contains("no active direct or group grant"));
}
other => panic!("unexpected denied file root sync response: {other:?}"),
}
let denied_kv_sync = handle_request_async(
&left,
ControlRequest::KvSync {
@ -5672,6 +5940,16 @@ mod tests {
},
)
.expect("grant left peer");
handle_request(
&right,
ControlRequest::AuthGrant {
subject: left.node_id.clone(),
resource: "resource:cas-tree:shared".to_owned(),
capability: "cas.fetch".to_owned(),
grant_id: Some("grant:left-cas-tree-fetch".to_owned()),
},
)
.expect("grant left file root fetch");
handle_request(
&right,
ControlRequest::AuthGrant {
@ -5817,6 +6095,56 @@ mod tests {
assert_eq!(providers.len(), 1);
assert_eq!(providers[0].peer_node_id, right.node_id);
let root_sync = handle_request_async(
&left,
ControlRequest::CasRootSync {
node: right_card.node_id.to_string(),
name: "shared".to_owned(),
bearer_secret: None,
},
)
.await
.expect("allowed file root sync");
match root_sync {
ControlResponse::CasRootSynced {
allowed,
root,
tree_bytes_imported,
reason,
note,
..
} => {
assert!(allowed);
assert!(tree_bytes_imported);
let root = root.expect("synced root");
assert_ne!(root.name, "shared");
assert!(root.name.starts_with("remote-"));
assert!(root.name.ends_with("-shared"));
assert_eq!(root.latest_tree, Some(right_tree_hash.clone()));
assert!(root.path.starts_with("remote:"));
assert!(reason.contains("direct grant"));
assert!(note.contains("never writes files"));
}
other => panic!("unexpected allowed file root sync response: {other:?}"),
}
assert!(
LocalCas::new(left.paths.cas_dir())
.has(&right_tree_hash)
.expect("left has synced tree object")
);
let local_shared = match handle_request(
&left,
ControlRequest::CasRootScan {
name: "shared".to_owned(),
},
)
.expect("left local shared root remains scannable")
{
ControlResponse::CasRootScanned { scan } => scan.root,
other => panic!("unexpected left local file root scan response: {other:?}"),
};
assert_eq!(local_shared.path, left_file_root.to_string_lossy());
let kv_synced = handle_request_async(
&left,
ControlRequest::KvSync {