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/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(); 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..550e2daebcbe 100644 --- a/rs/validator/ingress_message/tests/validate_request.rs +++ b/rs/validator/ingress_message/tests/validate_request.rs @@ -1992,9 +1992,11 @@ fn max_ingress_expiry_at(current_time: Time) -> Time { mod sender_info { use super::*; + 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 @@ -2048,6 +2050,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(CandidType)] + enum TestIcrc3Value { + Text(String), + 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 = 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]); @@ -2310,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)) } diff --git a/rs/validator/src/ingress_validation.rs b/rs/validator/src/ingress_validation.rs index 6bd94232a315..79a329e4ecd1 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(mut attributes) = decode_one_with_config(info, &config).ok()? else { + return None; + }; + match attributes.remove(SENDER_INFO_PERMISSIONS_ATTRIBUTE) { + Some(Icrc3Value::Text(permissions)) => Some(permissions), + _ => 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;