From 0f5a2b2d1b98e13510164879918f9f986b2ca9cd Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 17:14:13 +0000 Subject: [PATCH 1/4] feat: reject update calls when sender_info restricts permissions to queries When an update call carries a sender_info whose info blob is a Candid-encoded ICRC-3 map (the format used by Internet Identity to certify request attributes, signed under the "ic-sender-info" domain separator) and the map contains the "implicit:permissions" attribute with the text value "queries", the ingress validator now rejects the call with the new UpdateCallNotPermittedBySenderInfo error. Query calls and all other cases (no sender info, unparseable info blob, absent attribute, or any other attribute value) remain permitted. This enables Internet Identity users to sign limited delegations that can only read on their behalf (call query methods) but cannot change state (call update methods), with the restriction enforced by the protocol rather than by each individual canister. https://claude.ai/code/session_01BQNgPJKgxWohrJ6owwJBzz --- Cargo.lock | 3 + rs/validator/BUILD.bazel | 3 + rs/validator/Cargo.toml | 4 +- rs/validator/ingress_message/BUILD.bazel | 1 + rs/validator/ingress_message/Cargo.toml | 1 + .../ingress_message/src/internal/mod.rs | 3 + rs/validator/ingress_message/src/lib.rs | 10 ++ .../ingress_message/tests/validate_request.rs | 62 ++++++++++ rs/validator/src/ingress_validation.rs | 82 ++++++++++++- rs/validator/src/ingress_validation/tests.rs | 110 ++++++++++++++++++ 10 files changed, 277 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 16bcb0f104fd..b3f59d6b9a6e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15688,6 +15688,7 @@ version = "0.9.0" dependencies = [ "assert_matches", "base64 0.13.1", + "candid", "hex", "ic-crypto-iccsa", "ic-crypto-interfaces-sig-verification", @@ -15705,6 +15706,7 @@ dependencies = [ "mockall", "rand 0.8.5", "serde", + "serde_bytes", "serde_cbor", "serde_json", "simple_asn1", @@ -15751,6 +15753,7 @@ version = "0.9.0" dependencies = [ "assert_matches", "base64 0.13.1", + "candid", "getrandom 0.2.17", "hex", "ic-canister-client-sender", diff --git a/rs/validator/BUILD.bazel b/rs/validator/BUILD.bazel index ae4f5fe97077..a325547cc8b8 100644 --- a/rs/validator/BUILD.bazel +++ b/rs/validator/BUILD.bazel @@ -16,7 +16,10 @@ rust_library( "//rs/crypto/tree_hash", "//rs/limits", "//rs/types/types", + "@crate_index//:candid", "@crate_index//:hex", + "@crate_index//:serde", + "@crate_index//:serde_bytes", "@crate_index//:thiserror", ], ) diff --git a/rs/validator/Cargo.toml b/rs/validator/Cargo.toml index 832ffa58409d..a2d8b952c958 100644 --- a/rs/validator/Cargo.toml +++ b/rs/validator/Cargo.toml @@ -9,6 +9,7 @@ documentation.workspace = true # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +candid = { workspace = true } hex = { workspace = true } ic-limits = { path = "../limits" } ic-crypto-iccsa = { path = "../crypto/iccsa" } @@ -17,6 +18,8 @@ ic-crypto-sha2 = { path = "../crypto/sha2" } ic-crypto-standalone-sig-verifier = { path = "../crypto/standalone-sig-verifier" } ic-crypto-tree-hash = { path = "../crypto/tree_hash" } ic-types = { path = "../types/types" } +serde = { workspace = true } +serde_bytes = { workspace = true } thiserror = { workspace = true } [dev-dependencies] @@ -28,7 +31,6 @@ ic-crypto-test-utils-reproducible-rng = { path = "../crypto/test_utils/reproduci ic-crypto-test-utils-root-of-trust = { path = "../crypto/test_utils/root_of_trust" } ic-crypto-temp-crypto = { path = "../crypto/temp_crypto" } ic-secp256r1 = { path = "../../packages/ic-secp256r1" } -serde = { workspace = true } serde_cbor = { workspace = true } serde_json = { workspace = true } simple_asn1 = { workspace = true } diff --git a/rs/validator/ingress_message/BUILD.bazel b/rs/validator/ingress_message/BUILD.bazel index 04b8496fd416..54df8e87a16c 100644 --- a/rs/validator/ingress_message/BUILD.bazel +++ b/rs/validator/ingress_message/BUILD.bazel @@ -69,6 +69,7 @@ rust_test_suite( "//rs/validator/http_request_test_utils", "@crate_index//:assert_matches", "@crate_index//:base64", + "@crate_index//:candid", "@crate_index//:hex", "@crate_index//:ic-cdk", "@crate_index//:rand", diff --git a/rs/validator/ingress_message/Cargo.toml b/rs/validator/ingress_message/Cargo.toml index fb848a758e3e..bca8efa576cc 100644 --- a/rs/validator/ingress_message/Cargo.toml +++ b/rs/validator/ingress_message/Cargo.toml @@ -26,6 +26,7 @@ js = ["time/wasm-bindgen", "getrandom/js"] [dev-dependencies] assert_matches = { workspace = true } +candid = { workspace = true } ic-canister-client-sender = { path = "../../canister_client/sender" } ic-certification-test-utils = { path = "../../certification/test-utils" } ic-limits = { path = "../../limits" } diff --git a/rs/validator/ingress_message/src/internal/mod.rs b/rs/validator/ingress_message/src/internal/mod.rs index bd5bc0b401a2..71fbbdba7883 100644 --- a/rs/validator/ingress_message/src/internal/mod.rs +++ b/rs/validator/ingress_message/src/internal/mod.rs @@ -211,6 +211,9 @@ fn to_validation_error(error: ic_validator::RequestValidationError) -> RequestVa ic_validator::RequestValidationError::SenderInfoRequiredByDelegation => { RequestValidationError::SenderInfoRequiredByDelegation } + ic_validator::RequestValidationError::UpdateCallNotPermittedBySenderInfo => { + RequestValidationError::UpdateCallNotPermittedBySenderInfo + } } } fn to_authentication_lib_error(error: ic_validator::AuthenticationError) -> AuthenticationError { diff --git a/rs/validator/ingress_message/src/lib.rs b/rs/validator/ingress_message/src/lib.rs index 775c346368ae..b03a4326cabf 100644 --- a/rs/validator/ingress_message/src/lib.rs +++ b/rs/validator/ingress_message/src/lib.rs @@ -48,6 +48,8 @@ pub use internal::TimeProvider; /// * [`RequestValidationError::InvalidSenderInfo`]: if sender info is provided but invalid. /// * [`RequestValidationError::SenderInfoRequiredByDelegation`]: if the request is an update /// call without a `sender_info` but a delegation requires one to be present. +/// * [`RequestValidationError::UpdateCallNotPermittedBySenderInfo`]: if the request is an update +/// call but the provided sender info only permits query calls. pub trait HttpRequestVerifier { fn validate_request(&self, request: &HttpRequest) -> Result<(), RequestValidationError>; } @@ -68,6 +70,7 @@ pub enum RequestValidationError { NonceTooBigError { num_bytes: usize, maximum: usize }, InvalidSenderInfo(String), SenderInfoRequiredByDelegation, + UpdateCallNotPermittedBySenderInfo, } impl Display for RequestValidationError { @@ -122,6 +125,13 @@ impl Display for RequestValidationError { but no sender_info was provided" ) } + RequestValidationError::UpdateCallNotPermittedBySenderInfo => { + write!( + f, + "Sender info does not permit update calls: \ + the \"implicit:permissions\" attribute is set to \"queries\"" + ) + } } } } diff --git a/rs/validator/ingress_message/tests/validate_request.rs b/rs/validator/ingress_message/tests/validate_request.rs index 64054ee3da13..299019fb2c9c 100644 --- a/rs/validator/ingress_message/tests/validate_request.rs +++ b/rs/validator/ingress_message/tests/validate_request.rs @@ -1992,6 +1992,7 @@ fn max_ingress_expiry_at(current_time: Time) -> Time { mod sender_info { use super::*; + use candid::Encode; use ic_types::messages::{RawSignedSenderInfo, SenderInfoContent}; use ic_validator_http_request_test_utils::AuthenticationScheme::Direct; use ic_validator_http_request_test_utils::DirectAuthenticationScheme::CanisterSignature; @@ -2048,6 +2049,67 @@ mod sender_info { assert_eq!(verifier.validate_request(&request), Ok(())); } + /// Deliberately a different mirror of the ICRC-3 `Value` type than the + /// one used by the validator for decoding, like the mirrors used by + /// actual signers (e.g. Internet Identity). + #[derive(candid::CandidType)] + enum TestIcrc3Value { + Text(String), + Map(std::collections::BTreeMap), + } + + /// Candid-encodes an ICRC-3 map with the `implicit:permissions` attribute + /// set to the given value. + fn encoded_permissions_attribute(permissions: &str) -> Vec { + let map = std::collections::BTreeMap::from([( + "implicit:permissions".to_string(), + TestIcrc3Value::Text(permissions.to_string()), + )]); + Encode!(&TestIcrc3Value::Map(map)).expect("failed to encode attributes") + } + + #[test] + fn should_reject_update_call_when_sender_info_only_permits_queries() { + let (builder, signer, sender_info) = + valid_sender_info_setup(encoded_permissions_attribute("queries")); + let verifier = builder.build(); + let request = HttpRequestBuilder::new_update_call() + .with_ingress_expiry_at(CURRENT_TIME) + .with_authentication(Direct(CanisterSignature(signer))) + .with_sender_info(sender_info) + .build(); + assert_matches!( + verifier.validate_request(&request), + Err(RequestValidationError::UpdateCallNotPermittedBySenderInfo) + ); + } + + #[test] + fn should_accept_query_when_sender_info_only_permits_queries() { + let (builder, signer, sender_info) = + valid_sender_info_setup(encoded_permissions_attribute("queries")); + let verifier = builder.build(); + let request = HttpRequestBuilder::new_query() + .with_ingress_expiry_at(CURRENT_TIME) + .with_authentication(Direct(CanisterSignature(signer))) + .with_sender_info(sender_info) + .build(); + assert_eq!(verifier.validate_request(&request), Ok(())); + } + + #[test] + fn should_accept_update_call_when_sender_info_permits_updates() { + let (builder, signer, sender_info) = + valid_sender_info_setup(encoded_permissions_attribute("updates")); + let verifier = builder.build(); + let request = HttpRequestBuilder::new_update_call() + .with_ingress_expiry_at(CURRENT_TIME) + .with_authentication(Direct(CanisterSignature(signer))) + .with_sender_info(sender_info) + .build(); + assert_eq!(verifier.validate_request(&request), Ok(())); + } + #[test] fn should_reject_sender_info_with_wrong_signer() { let (builder, signer, mut sender_info) = valid_sender_info_setup(vec![1, 2, 3]); diff --git a/rs/validator/src/ingress_validation.rs b/rs/validator/src/ingress_validation.rs index 6bd94232a315..c705be89871f 100644 --- a/rs/validator/src/ingress_validation.rs +++ b/rs/validator/src/ingress_validation.rs @@ -1,6 +1,7 @@ use crate::webauthn::validate_webauthn_sig; use AuthenticationError::*; use RequestValidationError::*; +use candid::{CandidType, DecoderConfig, Deserialize, decode_one_with_config}; use ic_crypto_iccsa::public_key_bytes_from_der; use ic_crypto_interfaces_sig_verification::IngressSigVerifier; use ic_crypto_standalone_sig_verifier::{KeyBytesContentType, user_public_key_from_bytes}; @@ -18,8 +19,9 @@ use ic_types::{ SignedSenderInfo, UserSignature, WebAuthnSignature, }, }; +use serde_bytes::ByteBuf; use std::{ - collections::{BTreeSet, HashSet}, + collections::{BTreeMap, BTreeSet, HashSet}, convert::TryFrom, sync::Arc, }; @@ -149,6 +151,7 @@ where // by simply omitting the sender info. SenderInfoRequirement::EnforceIfRequiredByDelegation, )?; + validate_sender_info_permits_update_calls(request.sender_info())?; validate_request_target(request, &restrictions.targets)?; Ok(restrictions.targets) } @@ -324,6 +327,11 @@ pub enum RequestValidationError { but no sender_info was provided" )] SenderInfoRequiredByDelegation, + #[error( + "Sender info does not permit update calls: \ + the \"implicit:permissions\" attribute is set to \"queries\"." + )] + UpdateCallNotPermittedBySenderInfo, } /// Error in verifying the signature or authentication part of a request. @@ -602,6 +610,78 @@ where Ok(()) } +/// Key of the attribute, in the ICRC-3 map carried in the `info` blob of a +/// request's `sender_info`, whose value restricts which kinds of calls the +/// sender info permits. +const SENDER_INFO_PERMISSIONS_ATTRIBUTE: &str = "implicit:permissions"; + +/// Value of the [`SENDER_INFO_PERMISSIONS_ATTRIBUTE`] attribute indicating +/// that the sender info only permits query calls. +const SENDER_INFO_PERMISSIONS_QUERIES: &str = "queries"; + +/// Upper bound on the work performed when Candid-decoding the `info` blob of +/// a `sender_info`, see [`DecoderConfig::set_decoding_quota`]. +const SENDER_INFO_DECODING_QUOTA: usize = 1_000_000; + +/// Upper bound on the work performed when skipping unneeded data while +/// Candid-decoding the `info` blob of a `sender_info`, +/// see [`DecoderConfig::set_skipping_quota`]. +const SENDER_INFO_SKIPPING_QUOTA: usize = 10_000; + +/// Minimal mirror of the ICRC-3 `Value` type +/// (`https://github.com/dfinity/ICRC-1/blob/main/standards/ICRC-3/README.md#value`). +/// +/// Since Candid variant tags are derived from the variant names, this type +/// decodes any value encoded with an equivalent ICRC-3 value type, e.g. the +/// one used by Internet Identity to encode certified request attributes. +#[derive(CandidType, Deserialize, Debug)] +enum Icrc3Value { + Blob(ByteBuf), + Text(String), + Nat(candid::Nat), + Int(candid::Int), + Array(Vec), + Map(BTreeMap), +} + +/// Checks whether the given sender info (if any) permits update calls. +/// +/// If the `info` blob of the sender info is a Candid-encoded ICRC-3 map whose +/// [`SENDER_INFO_PERMISSIONS_ATTRIBUTE`] attribute has the text value +/// [`SENDER_INFO_PERMISSIONS_QUERIES`], the signer restricted the sender to +/// query calls and so update calls are rejected. In all other cases (no +/// sender info, `info` is not a parseable ICRC-3 map, the attribute is +/// absent, or it has any other value) the call is permitted. +fn validate_sender_info_permits_update_calls( + sender_info: Option<&SignedSenderInfo>, +) -> Result<(), RequestValidationError> { + let Some(sender_info) = sender_info else { + return Ok(()); + }; + match sender_info_permissions(&sender_info.info) { + Some(permissions) if permissions == SENDER_INFO_PERMISSIONS_QUERIES => { + Err(UpdateCallNotPermittedBySenderInfo) + } + _ => Ok(()), + } +} + +/// Extracts the value of the [`SENDER_INFO_PERMISSIONS_ATTRIBUTE`] attribute +/// from the given `info` blob, provided the blob is a Candid-encoded ICRC-3 +/// map containing that attribute with a text value. Returns `None` otherwise. +fn sender_info_permissions(info: &[u8]) -> Option { + let mut config = DecoderConfig::new(); + config.set_decoding_quota(SENDER_INFO_DECODING_QUOTA); + config.set_skipping_quota(SENDER_INFO_SKIPPING_QUOTA); + let Icrc3Value::Map(attributes) = decode_one_with_config(info, &config).ok()? else { + return None; + }; + match attributes.get(SENDER_INFO_PERMISSIONS_ATTRIBUTE) { + Some(Icrc3Value::Text(permissions)) => Some(permissions.clone()), + _ => None, + } +} + // Check if ingress_expiry is within a proper range with respect to the given // time, i.e., it is not expired yet and is not too far in the future. fn validate_ingress_expiry( diff --git a/rs/validator/src/ingress_validation/tests.rs b/rs/validator/src/ingress_validation/tests.rs index 0702ccfeed07..ceaab0baa354 100644 --- a/rs/validator/src/ingress_validation/tests.rs +++ b/rs/validator/src/ingress_validation/tests.rs @@ -723,6 +723,116 @@ mod validate_sender_info { } } +mod validate_sender_info_permissions { + use super::*; + use candid::Encode; + + /// ICRC-3 value type used for encoding test payloads. Deliberately a + /// different mirror of the ICRC-3 `Value` type than the one used for + /// decoding, like the mirrors used by actual signers (e.g. Internet + /// Identity). + #[derive(CandidType)] + enum TestIcrc3Value { + Text(String), + Nat(candid::Nat), + Map(BTreeMap), + } + + fn signed_sender_info_with_info(info: Vec) -> SignedSenderInfo { + SignedSenderInfo { + info, + signer: canister_test_id(42), + sig: vec![4, 5, 6], + } + } + + fn encoded_attributes_map(attributes: Vec<(&str, TestIcrc3Value)>) -> Vec { + let map: BTreeMap = attributes + .into_iter() + .map(|(key, value)| (key.to_string(), value)) + .collect(); + Encode!(&TestIcrc3Value::Map(map)).expect("failed to encode attributes") + } + + #[test] + fn should_permit_update_calls_without_sender_info() { + assert_matches!(validate_sender_info_permits_update_calls(None), Ok(())); + } + + #[test] + fn should_permit_update_calls_when_info_is_not_an_icrc3_map() { + for info in [ + vec![], + vec![1, 2, 3], + Encode!(&TestIcrc3Value::Text("queries".to_string())).expect("failed to encode"), + ] { + let sender_info = signed_sender_info_with_info(info); + assert_matches!( + validate_sender_info_permits_update_calls(Some(&sender_info)), + Ok(()) + ); + } + } + + #[test] + fn should_permit_update_calls_without_permissions_attribute() { + let sender_info = signed_sender_info_with_info(encoded_attributes_map(vec![( + "implicit:origin", + TestIcrc3Value::Text("https://example.com".to_string()), + )])); + assert_matches!( + validate_sender_info_permits_update_calls(Some(&sender_info)), + Ok(()) + ); + } + + #[test] + fn should_permit_update_calls_when_permissions_attribute_is_updates() { + let sender_info = signed_sender_info_with_info(encoded_attributes_map(vec![( + SENDER_INFO_PERMISSIONS_ATTRIBUTE, + TestIcrc3Value::Text("updates".to_string()), + )])); + assert_matches!( + validate_sender_info_permits_update_calls(Some(&sender_info)), + Ok(()) + ); + } + + #[test] + fn should_permit_update_calls_when_permissions_attribute_is_not_text() { + let sender_info = signed_sender_info_with_info(encoded_attributes_map(vec![( + SENDER_INFO_PERMISSIONS_ATTRIBUTE, + TestIcrc3Value::Nat(candid::Nat::from(42_u64)), + )])); + assert_matches!( + validate_sender_info_permits_update_calls(Some(&sender_info)), + Ok(()) + ); + } + + #[test] + fn should_reject_update_calls_when_permissions_attribute_is_queries() { + let sender_info = signed_sender_info_with_info(encoded_attributes_map(vec![ + ( + "implicit:origin", + TestIcrc3Value::Text("https://example.com".to_string()), + ), + ( + "implicit:issued_at_timestamp_ns", + TestIcrc3Value::Nat(candid::Nat::from(1_700_000_000_000_000_000_u64)), + ), + ( + SENDER_INFO_PERMISSIONS_ATTRIBUTE, + TestIcrc3Value::Text(SENDER_INFO_PERMISSIONS_QUERIES.to_string()), + ), + ])); + assert_matches!( + validate_sender_info_permits_update_calls(Some(&sender_info)), + Err(RequestValidationError::UpdateCallNotPermittedBySenderInfo) + ); + } +} + mod canister_id_set { use super::*; use ic_crypto_test_utils_reproducible_rng::reproducible_rng; From dc748dd544cb4ae78a9b76e659a4604d5349aae9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 17:21:18 +0000 Subject: [PATCH 2/4] refactor: address review comments Import BTreeMap and CandidType instead of qualifying them in the sender_info integration tests, and avoid cloning the permissions string in sender_info_permissions by removing the entry from the owned map. https://claude.ai/code/session_01BQNgPJKgxWohrJ6owwJBzz --- rs/validator/ingress_message/tests/validate_request.rs | 9 +++++---- rs/validator/src/ingress_validation.rs | 6 +++--- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/rs/validator/ingress_message/tests/validate_request.rs b/rs/validator/ingress_message/tests/validate_request.rs index 299019fb2c9c..4dbcae0c96ea 100644 --- a/rs/validator/ingress_message/tests/validate_request.rs +++ b/rs/validator/ingress_message/tests/validate_request.rs @@ -1992,10 +1992,11 @@ fn max_ingress_expiry_at(current_time: Time) -> Time { mod sender_info { use super::*; - use candid::Encode; + use candid::{CandidType, Encode}; use ic_types::messages::{RawSignedSenderInfo, SenderInfoContent}; use ic_validator_http_request_test_utils::AuthenticationScheme::Direct; use ic_validator_http_request_test_utils::DirectAuthenticationScheme::CanisterSignature; + use std::collections::BTreeMap; /// Creates a canister signer, signs the given info bytes, and returns the /// verifier builder (with root of trust configured), the signer, and a @@ -2052,16 +2053,16 @@ mod sender_info { /// Deliberately a different mirror of the ICRC-3 `Value` type than the /// one used by the validator for decoding, like the mirrors used by /// actual signers (e.g. Internet Identity). - #[derive(candid::CandidType)] + #[derive(CandidType)] enum TestIcrc3Value { Text(String), - Map(std::collections::BTreeMap), + Map(BTreeMap), } /// Candid-encodes an ICRC-3 map with the `implicit:permissions` attribute /// set to the given value. fn encoded_permissions_attribute(permissions: &str) -> Vec { - let map = std::collections::BTreeMap::from([( + let map = BTreeMap::from([( "implicit:permissions".to_string(), TestIcrc3Value::Text(permissions.to_string()), )]); diff --git a/rs/validator/src/ingress_validation.rs b/rs/validator/src/ingress_validation.rs index c705be89871f..79a329e4ecd1 100644 --- a/rs/validator/src/ingress_validation.rs +++ b/rs/validator/src/ingress_validation.rs @@ -673,11 +673,11 @@ fn sender_info_permissions(info: &[u8]) -> Option { let mut config = DecoderConfig::new(); config.set_decoding_quota(SENDER_INFO_DECODING_QUOTA); config.set_skipping_quota(SENDER_INFO_SKIPPING_QUOTA); - let Icrc3Value::Map(attributes) = decode_one_with_config(info, &config).ok()? else { + let Icrc3Value::Map(mut attributes) = decode_one_with_config(info, &config).ok()? else { return None; }; - match attributes.get(SENDER_INFO_PERMISSIONS_ATTRIBUTE) { - Some(Icrc3Value::Text(permissions)) => Some(permissions.clone()), + match attributes.remove(SENDER_INFO_PERMISSIONS_ATTRIBUTE) { + Some(Icrc3Value::Text(permissions)) => Some(permissions), _ => None, } } From 720125e4432b87c52b4f2fc9c4df1af7a5540bbd Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 17:40:35 +0000 Subject: [PATCH 3/4] test: add system test for sender_info permissions Extends the ingress verification system test suite with a test that demonstrates the implicit:permissions sender_info attribute end-to-end: a signer canister certifies an ICRC-3 attributes map restricting the sender to "queries", after which the protocol rejects the sender's update calls during ingress validation while query calls remain permitted (and the receiving canister can read the certified attributes via the msg_caller_info system API). Attribute maps with permissions "updates", or without the permissions attribute, leave update calls permitted. https://claude.ai/code/session_01BQNgPJKgxWohrJ6owwJBzz --- rs/tests/crypto/ingress_verification_test.rs | 170 ++++++++++++++++++- 1 file changed, 169 insertions(+), 1 deletion(-) diff --git a/rs/tests/crypto/ingress_verification_test.rs b/rs/tests/crypto/ingress_verification_test.rs index c35c0701c7a0..98aff6329376 100644 --- a/rs/tests/crypto/ingress_verification_test.rs +++ b/rs/tests/crypto/ingress_verification_test.rs @@ -1,7 +1,7 @@ /* tag::catalog[] end::catalog[] */ use anyhow::Result; -use candid::Encode; +use candid::{CandidType, Encode}; use ic_agent::Identity; use ic_agent::export::Principal; use ic_crypto_test_utils_reproducible_rng::reproducible_rng; @@ -28,6 +28,7 @@ use ic_universal_canister::wasm; use rand::{CryptoRng, Rng, SeedableRng, rngs::StdRng}; use reqwest::{StatusCode, Url}; use slog::debug; +use std::collections::BTreeMap; const ALL_QUERY_API_VERSIONS: &[usize] = &[2, 3]; const ALL_UPDATE_API_VERSIONS: &[usize] = &[2, 3, 4]; @@ -47,6 +48,7 @@ fn main() -> Result<()> { .add_test(systest!(requests_to_mgmt_canister_with_delegations)) .add_test(systest!(requests_with_sender_info)) .add_test(systest!(requests_with_valid_sender_info)) + .add_test(systest!(requests_with_sender_info_permissions)) .add_test(systest!(requests_with_invalid_expiry)) .add_test(systest!(requests_with_canister_signature)), ) @@ -1353,6 +1355,172 @@ pub fn requests_with_valid_sender_info(env: TestEnv) { }); } +/// Minimal mirror of the ICRC-3 `Value` type used to encode the attributes +/// map carried in `sender_info.info`, like the mirror used by Internet +/// Identity to certify request attributes. +#[derive(CandidType)] +enum Icrc3Value { + Text(String), + Map(BTreeMap), +} + +/// Candid-encodes an ICRC-3 attributes map, optionally containing the +/// `implicit:permissions` attribute with the given value. +fn encoded_attributes_with_permissions(permissions: Option<&str>) -> Vec { + let mut attributes = BTreeMap::from([( + "implicit:origin".to_string(), + Icrc3Value::Text("https://example.com".to_string()), + )]); + if let Some(permissions) = permissions { + attributes.insert( + "implicit:permissions".to_string(), + Icrc3Value::Text(permissions.to_string()), + ); + } + Encode!(&Icrc3Value::Map(attributes)).expect("failed to encode attributes") +} + +/// Demonstrates the `implicit:permissions` sender_info attribute end-to-end: +/// a signer canister (playing the role of Internet Identity) certifies an +/// ICRC-3 attributes map restricting the sender to `"queries"`, and the +/// protocol then rejects the sender's update calls during ingress validation +/// while still permitting query calls (where the receiving canister can also +/// read the certified attributes). Attribute maps with permissions +/// `"updates"`, or without the permissions attribute, leave update calls +/// permitted. +pub fn requests_with_sender_info_permissions(env: TestEnv) { + let logger = env.logger(); + let node = env.get_first_healthy_node_snapshot(); + let agent = node.build_default_agent(); + block_on({ + async move { + let node_url = node.get_public_url(); + debug!(logger, "Selected replica"; "url" => format!("{}", node_url)); + + let canister = + UniversalCanister::new_with_retries(&agent, node.effective_canister_id(), &logger) + .await; + let test_info = TestInformation { + url: node_url, + canister_id: canister_id_from_principal(&canister.canister_id()), + }; + + let seed = b"sender_info_permissions_test_seed".to_vec(); + let signer = CanisterSigner::new(&canister, seed); + let id = GenericIdentity::new_canister(signer.clone()); + + // Helper: canister-sign the given attributes blob as a + // sender_info, like Internet Identity does when certifying + // request attributes. + let signed_sender_info = |info_bytes: Vec| { + let signer = signer.clone(); + async move { + let sig = signer + .sign(&SenderInfoContent(&info_bytes).as_signed_bytes()) + .await; + RawSignedSenderInfo { + info: Blob(info_bytes), + signer: Blob(signer.canister_id().get().as_slice().to_vec()), + sig: Blob(sig), + } + } + }; + + // Helper: build an update call with the given sender_info, compute + // the message ID from ic-types (which includes sender_info, unlike + // ic_agent::EnvelopeContent), sign it, and send. + let send_update = |api_ver, sender_info| { + let content = HttpCallContent::Call { + update: HttpCanisterUpdate { + canister_id: Blob(test_info.canister_id.get().as_slice().to_vec()), + method_name: "update".to_string(), + arg: Blob(wasm().reply_data(b"update_reply").build()), + sender: Blob(id.principal().as_slice().to_vec()), + ingress_expiry: expiry_time().as_nanos() as u64, + nonce: None, + sender_info: Some(sender_info), + }, + }; + let message_id = MessageId::from(content.representation_independent_hash()); + let signature = id.sign_bytes(&message_id.as_signed_bytes()); + let fut = send_request( + api_ver, + &test_info, + "call", + content, + id.public_key_der(), + None, + signature, + ); + async move { (fut.await, message_id) } + }; + + let queries_only_info = encoded_attributes_with_permissions(Some("queries")); + let queries_only_sender_info = signed_sender_info(queries_only_info.clone()).await; + + /////////////////////////////////////////////////////////////////// + // A sender restricted to "queries" must not be able to make + // update calls: the protocol rejects them during ingress + // validation. + for &api_ver in ALL_UPDATE_API_VERSIONS { + let (response, _) = send_update(api_ver, queries_only_sender_info.clone()).await; + assert_eq!(response.status(), 400); + response.expect_text_error("Sender info does not permit update calls"); + } + + /////////////////////////////////////////////////////////////////// + // The same sender_info still permits query calls, where the + // receiving canister can read the certified attributes via the + // msg_caller_info system API. + for &api_ver in ALL_QUERY_API_VERSIONS { + let content = HttpQueryContent::Query { + query: HttpUserQuery { + canister_id: Blob(test_info.canister_id.get().as_slice().to_vec()), + method_name: "query".to_string(), + arg: Blob(wasm().msg_caller_info_data().append_and_reply().build()), + sender: Blob(id.principal().as_slice().to_vec()), + ingress_expiry: expiry_time().as_nanos() as u64, + nonce: None, + sender_info: Some(queries_only_sender_info.clone()), + }, + }; + let message_id = MessageId::from(content.representation_independent_hash()); + let signature = id.sign_bytes(&message_id.as_signed_bytes()); + let response = send_request( + api_ver, + &test_info, + "query", + content, + id.public_key_der(), + None, + signature, + ) + .await; + response.expect_query_ok(api_ver); + response.expect_query_reply_arg(api_ver, &queries_only_info); + } + + /////////////////////////////////////////////////////////////////// + // Attributes with permissions "updates", or without the + // permissions attribute, leave update calls permitted. + for info_bytes in [ + encoded_attributes_with_permissions(Some("updates")), + encoded_attributes_with_permissions(None), + ] { + let sender_info = signed_sender_info(info_bytes).await; + for &api_ver in ALL_UPDATE_API_VERSIONS { + let (response, message_id) = send_update(api_ver, sender_info.clone()).await; + let response = + await_pending_update(api_ver, response, &test_info, &id, &message_id).await; + response + .with_request_id(message_id) + .expect_update_ok(api_ver); + } + } + } + }); +} + pub fn requests_with_canister_signature(env: TestEnv) { let logger = env.logger(); let node = env.get_first_healthy_node_snapshot(); From 0c5ce918da84e162581a7c816c8418c3b6bbe242 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 10:04:22 +0000 Subject: [PATCH 4/4] test: end-to-end tests for protocol-enforced read-only sessions Adds e2e tests for the combined design: a delegation with sender_info_required = true forces update calls to carry the certified session attributes, whose implicit:permissions attribute then determines whether update calls are permitted. Covers rejection with queries-only attributes, rejection when omitting the attributes, and acceptance of queries and of update calls with updates-permissions. https://claude.ai/code/session_01BQNgPJKgxWohrJ6owwJBzz --- .../ingress_message/tests/validate_request.rs | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) diff --git a/rs/validator/ingress_message/tests/validate_request.rs b/rs/validator/ingress_message/tests/validate_request.rs index 4dbcae0c96ea..550e2daebcbe 100644 --- a/rs/validator/ingress_message/tests/validate_request.rs +++ b/rs/validator/ingress_message/tests/validate_request.rs @@ -2373,6 +2373,134 @@ mod delegation_sender_info_required { } } +/// End-to-end tests for the combined read-only-session design: a delegation +/// with `sender_info_required = true` forces update calls to carry certified +/// session attributes (`sender_info`), whose `implicit:permissions` attribute +/// then determines whether update calls are permitted. The bearer of the +/// delegation can neither strip the requirement (it is covered by the +/// delegation signature) nor omit the attributes (the call is rejected +/// without them). +mod read_only_sessions { + use super::*; + use candid::Encode; + use ic_types::messages::{RawSignedSenderInfo, SenderInfoContent}; + use ic_validator_http_request_test_utils::DelegationChain; + use ic_validator_http_request_test_utils::DirectAuthenticationScheme::CanisterSignature; + + /// Deliberately a different mirror of the ICRC-3 `Value` type than the + /// one used by the validator for decoding, like the mirrors used by + /// actual signers (e.g. Internet Identity). + #[derive(candid::CandidType)] + enum TestIcrc3Value { + Text(String), + Map(std::collections::BTreeMap), + } + + /// Creates a canister signer (the issuer, e.g. Internet Identity), a + /// delegation chain rooted at it requiring a signed `sender_info`, and + /// certified attributes restricting the session to the given permissions. + fn read_only_session_setup( + rng: &mut R, + permissions: &str, + ) -> ( + IngressMessageVerifierBuilder, + DelegationChain, + RawSignedSenderInfo, + ) { + let root_of_trust = RootOfTrust::new_random(rng); + let builder = default_verifier().with_root_of_trust(root_of_trust.public_key); + let signer = CanisterSigner { + seed: CANISTER_SIGNATURE_SEED.to_vec(), + canister_id: CANISTER_ID_SIGNER, + root_public_key: root_of_trust.public_key, + root_secret_key: root_of_trust.secret_key, + }; + let attributes = std::collections::BTreeMap::from([( + "implicit:permissions".to_string(), + TestIcrc3Value::Text(permissions.to_string()), + )]); + let info_bytes = + Encode!(&TestIcrc3Value::Map(attributes)).expect("failed to encode attributes"); + let sig = signer.sign(&SenderInfoContent(&info_bytes)); + let sender_info = RawSignedSenderInfo { + info: Blob(info_bytes), + signer: Blob(CANISTER_ID_SIGNER.get().into_vec()), + sig: Blob(sig.0), + }; + let chain = DelegationChain::rooted_at(CanisterSignature(signer)) + .delegate_to_with_sender_info_required(random_user_key_pair(rng), CURRENT_TIME, true) + .build(); + (builder, chain, sender_info) + } + + #[test] + fn should_reject_update_call_with_queries_only_attributes() { + let rng = &mut reproducible_rng(); + let (builder, chain, sender_info) = read_only_session_setup(rng, "queries"); + let verifier = builder.build(); + + let request = HttpRequestBuilder::new_update_call() + .with_ingress_expiry_at(CURRENT_TIME) + .with_authentication(AuthenticationScheme::Delegation(chain)) + .with_sender_info(sender_info) + .build(); + + assert_matches!( + verifier.validate_request(&request), + Err(RequestValidationError::UpdateCallNotPermittedBySenderInfo) + ); + } + + #[test] + fn should_reject_update_call_omitting_the_attributes() { + let rng = &mut reproducible_rng(); + let (builder, chain, _sender_info) = read_only_session_setup(rng, "queries"); + let verifier = builder.build(); + + // A dapp cannot lift the queries-only restriction by omitting the + // attributes: the delegation requires them to be present. + let request = HttpRequestBuilder::new_update_call() + .with_ingress_expiry_at(CURRENT_TIME) + .with_authentication(AuthenticationScheme::Delegation(chain)) + .build(); + + assert_matches!( + verifier.validate_request(&request), + Err(RequestValidationError::SenderInfoRequiredByDelegation) + ); + } + + #[test] + fn should_accept_query_with_queries_only_attributes() { + let rng = &mut reproducible_rng(); + let (builder, chain, sender_info) = read_only_session_setup(rng, "queries"); + let verifier = builder.build(); + + let request = HttpRequestBuilder::new_query() + .with_ingress_expiry_at(CURRENT_TIME) + .with_authentication(AuthenticationScheme::Delegation(chain)) + .with_sender_info(sender_info) + .build(); + + assert_eq!(verifier.validate_request(&request), Ok(())); + } + + #[test] + fn should_accept_update_call_with_updates_attributes() { + let rng = &mut reproducible_rng(); + let (builder, chain, sender_info) = read_only_session_setup(rng, "updates"); + let verifier = builder.build(); + + let request = HttpRequestBuilder::new_update_call() + .with_ingress_expiry_at(CURRENT_TIME) + .with_authentication(AuthenticationScheme::Delegation(chain)) + .with_sender_info(sender_info) + .build(); + + assert_eq!(verifier.validate_request(&request), Ok(())); + } +} + fn default_verifier() -> IngressMessageVerifierBuilder { IngressMessageVerifier::builder().with_time_provider(TimeProvider::Constant(CURRENT_TIME)) }