diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c20f6f3..11a11747 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +## [0.49.3] - 2026-10-22 + ### Fixed +* `ic-agent`: Malformed data in a validly signed certificate now returns an error instead of panicking. A subnet could previously crash the agent by certifying a node ID longer than 29 bytes under `/subnet//node`, or a bare leaf at `/canister_ranges/` or `/api_boundary_nodes`. This affected `Agent::fetch_subnet_by_id`, `Agent::fetch_subnet_by_canister` and `Agent::fetch_api_boundary_nodes_by_{canister,subnet}_id`. An oversized `subnet_id` in a delegation is now rejected the same way. * `ic-agent`: Canister-to-subnet resolution now only uses canister ranges that came from an NNS-root-signed delegation. `Agent::fetch_subnet_by_id` previously cached the ranges from the certificate's outer tree, which the subnet signs itself, and those ranges are not authoritative for deciding which subnet may answer for a given canister. Resolution now stays with `Agent::fetch_subnet_by_canister`, which validates ranges against the delegation and enforces containment. Subnets fetched by ID are still cached by ID for their node keys, and still report their self-declared ranges via `Subnet::iter_canister_ranges` and `Subnet::contains_canister`; those are now documented as non-authoritative on that path. Update calls and `read_state` are unchanged — they re-derive ranges from the delegation on every call. ## [0.49.2] - 2026-07-23 diff --git a/Cargo.lock b/Cargo.lock index 9461949a..d117c2f5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -176,13 +176,13 @@ dependencies = [ [[package]] name = "async-trait" -version = "0.1.89" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.9", ] [[package]] @@ -1420,7 +1420,7 @@ dependencies = [ [[package]] name = "ic-agent" -version = "0.49.2" +version = "0.49.3" dependencies = [ "anyhow", "arc-swap", @@ -1444,7 +1444,7 @@ dependencies = [ "http-body-util", "ic-certification", "ic-ed25519", - "ic-transport-types 0.49.2", + "ic-transport-types 0.49.3", "ic-verify-bls-signature", "ic_principal", "js-sys", @@ -1511,7 +1511,7 @@ dependencies = [ [[package]] name = "ic-identity-hsm" -version = "0.49.2" +version = "0.49.3" dependencies = [ "hex", "ic-agent", @@ -1592,7 +1592,7 @@ dependencies = [ [[package]] name = "ic-transport-types" -version = "0.49.2" +version = "0.49.3" dependencies = [ "candid", "hex", @@ -1609,7 +1609,7 @@ dependencies = [ [[package]] name = "ic-utils" -version = "0.49.2" +version = "0.49.3" dependencies = [ "async-trait", "candid", @@ -1779,7 +1779,7 @@ dependencies = [ [[package]] name = "icx" -version = "0.49.2" +version = "0.49.3" dependencies = [ "anyhow", "candid", @@ -1798,7 +1798,7 @@ dependencies = [ [[package]] name = "icx-cert" -version = "0.49.2" +version = "0.49.3" dependencies = [ "anyhow", "base64 0.22.1", @@ -3603,6 +3603,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" diff --git a/Cargo.toml b/Cargo.toml index 79669016..6436eedd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,7 @@ members = [ ] [workspace.package] -version = "0.49.2" +version = "0.49.3" authors = ["DFINITY Stiftung "] edition = "2021" repository = "https://github.com/dfinity/agent-rs" @@ -37,10 +37,10 @@ license = "Apache-2.0" # a comment listing those crates). Otherwise, features are declared in the individual crate Cargo.toml. # # The path dependencies below ensure all workspace members use the same version of internal crates. -ic-agent = { path = "ic-agent", version = "0.49.2", default-features = false } -ic-identity-hsm = { path = "ic-identity-hsm", version = "0.49.2" } -ic-transport-types = { path = "ic-transport-types", version = "0.49.2" } -ic-utils = { path = "ic-utils", version = "0.49.2" } +ic-agent = { path = "ic-agent", version = "0.49.3", default-features = false } +ic-identity-hsm = { path = "ic-identity-hsm", version = "0.49.3" } +ic-transport-types = { path = "ic-transport-types", version = "0.49.3" } +ic-utils = { path = "ic-utils", version = "0.49.3" } ic-utils-bindgen = { path = "ic-utils-bindgen" } ref-tests = { path = "ref-tests" } diff --git a/ic-agent/src/agent/mod.rs b/ic-agent/src/agent/mod.rs index b797994f..5c186d4a 100644 --- a/ic-agent/src/agent/mod.rs +++ b/ic-agent/src/agent/mod.rs @@ -1485,7 +1485,7 @@ impl Agent { .read_state_raw(vec![vec!["subnet".into()]], *canister) .await?; let subnet_id = if let Some(delegation) = canister_cert.delegation.as_ref() { - Principal::from_slice(&delegation.subnet_id) + Principal::try_from_slice(&delegation.subnet_id)? } else { // if no delegation, it comes from the root subnet Principal::self_authenticating(&self.root_key.read().unwrap()[..]) diff --git a/ic-agent/src/agent/response_authentication.rs b/ic-agent/src/agent/response_authentication.rs index ccb34e2b..b16a7d70 100644 --- a/ic-agent/src/agent/response_authentication.rs +++ b/ic-agent/src/agent/response_authentication.rs @@ -221,7 +221,7 @@ pub(crate) fn lookup_incomplete_subnet + Clone>( .collect(), )); } - let node_id = Principal::from_slice(path[0].as_bytes()); + let node_id = Principal::try_from_slice(path[0].as_bytes())?; let node_key = lookup_value(&node_keys_subtree, [node_id.as_slice(), b"public_key"])?; node_keys.insert(node_id, node_key.to_vec()); } @@ -270,10 +270,14 @@ pub(crate) fn lookup_canister_ranges + Clone>( )?; let mut canister_ranges = RangeInclusiveSet::new_with_step_fns(); for shard in canister_ranges_tree.list_paths() { + // /canister_ranges// + let [shard] = shard.as_slice() else { + return Err(AgentError::CertificateVerificationFailed()); + }; let shard_ranges: Vec<(Principal, Principal)> = serde_cbor::from_slice::>(lookup_value( &canister_ranges_tree, - [shard[0].as_bytes()], + [shard.as_bytes()], )?)?; for (low, high) in shard_ranges { canister_ranges.insert(low..=high); @@ -298,7 +302,14 @@ pub(crate) fn lookup_api_boundary_nodes + Clone>( let mut api_bns = Vec::::new(); let paths = api_bn_tree.list_paths(); - let node_ids: HashSet<&[u8]> = paths.iter().map(|path| path[0].as_bytes()).collect(); + let node_ids = paths + .iter() + .map(|path| { + path.first() + .map(Label::as_bytes) + .ok_or_else(AgentError::CertificateVerificationFailed) + }) + .collect::, _>>()?; for node_id in node_ids { let domain = @@ -493,3 +504,73 @@ pub fn lookup_tree + Clone>( SubtreeLookupResult::Found(value) => Ok(value), } } + +#[cfg(test)] +mod tests { + use super::*; + use ic_certification::hash_tree::{fork, label, leaf}; + #[cfg(all(target_family = "wasm", feature = "wasm-bindgen"))] + use wasm_bindgen_test::wasm_bindgen_test; + + // The lookups below run on certificates whose signature has already been verified, but a + // malicious subnet can still sign a malformed tree. They must reject it rather than panic. + + fn cert(tree: HashTree>) -> Certificate> { + Certificate { + tree, + signature: vec![], + delegation: None, + } + } + + fn subnet_id() -> Principal { + Principal::from_text("uzr34-akd3s-xrdag-3ql62-ocgoh-ld2ao-tamcv-54e7j-krwgb-2gm4z-oqe") + .unwrap() + } + + #[cfg_attr(not(target_family = "wasm"), test)] + #[cfg_attr(target_family = "wasm", wasm_bindgen_test)] + fn oversized_node_id_is_rejected() { + let subnet_id = subnet_id(); + let node = label( + vec![0xff; 30], + label(b"public_key".to_vec(), leaf(b"key".to_vec())), + ); + let subnet = fork( + label(b"node".to_vec(), node), + label(b"public_key".to_vec(), leaf(b"key".to_vec())), + ); + let tree = label( + b"subnet".to_vec(), + label(subnet_id.as_slice().to_vec(), subnet), + ); + assert!(matches!( + lookup_incomplete_subnet(&subnet_id, &cert(tree)), + Err(AgentError::PrincipalError(_)) + )); + } + + #[cfg_attr(not(target_family = "wasm"), test)] + #[cfg_attr(target_family = "wasm", wasm_bindgen_test)] + fn leaf_canister_ranges_is_rejected() { + let subnet_id = subnet_id(); + let tree = label( + b"canister_ranges".to_vec(), + label(subnet_id.as_slice().to_vec(), leaf(vec![])), + ); + assert!(matches!( + lookup_canister_ranges(&subnet_id, &cert(tree)), + Err(AgentError::CertificateVerificationFailed()) + )); + } + + #[cfg_attr(not(target_family = "wasm"), test)] + #[cfg_attr(target_family = "wasm", wasm_bindgen_test)] + fn leaf_api_boundary_nodes_is_rejected() { + let tree = label(b"api_boundary_nodes".to_vec(), leaf(vec![])); + assert!(matches!( + lookup_api_boundary_nodes(cert(tree)), + Err(AgentError::CertificateVerificationFailed()) + )); + } +}