Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>/node`, or a bare leaf at `/canister_ranges/<subnet_id>` 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
Expand Down
31 changes: 21 additions & 10 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 5 additions & 5 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
]

[workspace.package]
version = "0.49.2"
version = "0.49.3"
authors = ["DFINITY Stiftung <sdk@dfinity.org>"]
edition = "2021"
repository = "https://github.com/dfinity/agent-rs"
Expand All @@ -37,10 +37,10 @@
# 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" }

Expand All @@ -58,7 +58,7 @@
candid = "0.10.10"
candid_parser = "0.3.0"
clap = { version = "4.5.21", features = ["derive", "cargo", "color"] } # icx, icx-cert
der = "0.8.0"

Check warning on line 61 in Cargo.toml

View workflow job for this annotation

GitHub Actions / lint

unused workspace dependency `der`

Check warning on line 61 in Cargo.toml

View workflow job for this annotation

GitHub Actions / lint

unused workspace dependency `der`
ecdsa = "0.16"
ed25519-consensus = "2.1.0"
elliptic-curve = "0.13"
Expand Down
2 changes: 1 addition & 1 deletion ic-agent/src/agent/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()[..])
Expand Down
87 changes: 84 additions & 3 deletions ic-agent/src/agent/response_authentication.rs
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ pub(crate) fn lookup_incomplete_subnet<Storage: AsRef<[u8]> + 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());
}
Expand Down Expand Up @@ -270,10 +270,14 @@ pub(crate) fn lookup_canister_ranges<Storage: AsRef<[u8]> + Clone>(
)?;
let mut canister_ranges = RangeInclusiveSet::new_with_step_fns();
for shard in canister_ranges_tree.list_paths() {
// /canister_ranges/<subnet_id>/<shard>
let [shard] = shard.as_slice() else {
return Err(AgentError::CertificateVerificationFailed());
};
let shard_ranges: Vec<(Principal, Principal)> =
serde_cbor::from_slice::<Vec<(Principal, Principal)>>(lookup_value(
&canister_ranges_tree,
[shard[0].as_bytes()],
[shard.as_bytes()],
)?)?;
for (low, high) in shard_ranges {
canister_ranges.insert(low..=high);
Expand All @@ -298,7 +302,14 @@ pub(crate) fn lookup_api_boundary_nodes<Storage: AsRef<[u8]> + Clone>(

let mut api_bns = Vec::<ApiBoundaryNode>::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::<Result<HashSet<&[u8]>, _>>()?;

for node_id in node_ids {
let domain =
Expand Down Expand Up @@ -493,3 +504,73 @@ pub fn lookup_tree<P: LookupPath, Storage: AsRef<[u8]> + 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<Vec<u8>>) -> Certificate<Vec<u8>> {
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())
));
}
}
Loading