diff --git a/rs/tests/consensus/request_auth_malicious_replica_test.rs b/rs/tests/consensus/request_auth_malicious_replica_test.rs index 0eb607ecc8da..42f19d9ff8a3 100644 --- a/rs/tests/consensus/request_auth_malicious_replica_test.rs +++ b/rs/tests/consensus/request_auth_malicious_replica_test.rs @@ -549,9 +549,11 @@ async fn test_request_with_delegation( // A delegation from identity to identity2 for the specific canister ID. let delegation = match delegation_targets { Some(targets) => { - Delegation::new_with_targets( + Delegation::new( signature.public_key.clone().unwrap(), // public key of identity2 Time::from_nanos_since_unix_epoch(delegation_expiry), + ) + .with_targets( targets .into_iter() .map(|principal| CanisterId::try_from(principal.as_slice()).unwrap()) diff --git a/rs/tests/crypto/ingress_verification_test.rs b/rs/tests/crypto/ingress_verification_test.rs index c35c0701c7a0..9cae1d80a1e1 100644 --- a/rs/tests/crypto/ingress_verification_test.rs +++ b/rs/tests/crypto/ingress_verification_test.rs @@ -19,9 +19,10 @@ use ic_system_test_driver::util::{ }; use ic_types::crypto::Signable; use ic_types::messages::{ - Blob, Certificate, Delegation, HttpCallContent, HttpCanisterUpdate, HttpQueryContent, - HttpReadState, HttpReadStateContent, HttpReadStateResponse, HttpRequestEnvelope, HttpUserQuery, - MessageId, RawSignedSenderInfo, SenderInfoContent, SignedDelegation, + Blob, Certificate, Delegation, DelegationPermissions, HttpCallContent, HttpCanisterUpdate, + HttpQueryContent, HttpReadState, HttpReadStateContent, HttpReadStateResponse, + HttpRequestEnvelope, HttpUserQuery, MessageId, RawSignedSenderInfo, SenderInfoContent, + SignedDelegation, }; use ic_types::{CanisterId, PrincipalId, Time}; use ic_universal_canister::wasm; @@ -43,6 +44,7 @@ fn main() -> Result<()> { SystemTestSubGroup::new() .add_test(systest!(requests_with_delegations)) .add_test(systest!(requests_with_delegations_with_targets)) + .add_test(systest!(requests_with_delegation_permissions)) .add_test(systest!(requests_with_delegation_loop)) .add_test(systest!(requests_to_mgmt_canister_with_delegations)) .add_test(systest!(requests_with_sender_info)) @@ -677,6 +679,146 @@ pub fn requests_with_delegations_with_targets(env: TestEnv) { }); } +// Tests for the optional `permissions` field on delegations: `"queries"` +// restricts the sender to query calls and read_state requests (update calls +// are rejected), `"all"` (and an absent field) permits everything, and any +// other value invalidates the delegation for all request kinds. +pub fn requests_with_delegation_permissions(env: TestEnv) { + let logger = env.logger(); + let node = env.get_first_healthy_node_snapshot(); + let agent = node.build_default_agent(); + let rng = &mut reproducible_rng(); + 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()), + }; + + // Expected outcome for a delegation chain carrying some + // `permissions` values. + enum Outcome { + /// Query and read_state succeed; update calls are rejected + /// with an error whose text contains the given substring. + QueriesOnly { err: &'static str }, + /// All request kinds succeed. + All, + } + + struct PermissionsTest { + note: &'static str, + // The `permissions` value of each delegation in the chain + // (`None` means the field is omitted for that delegation). + permissions: Vec>, + outcome: Outcome, + } + + let update_not_permitted = "Update calls are not permitted"; + + let scenarios = [ + PermissionsTest { + note: "single delegation restricted to queries", + permissions: vec![Some(DelegationPermissions::Queries)], + outcome: Outcome::QueriesOnly { + err: update_not_permitted, + }, + }, + PermissionsTest { + note: "single delegation permitting all", + permissions: vec![Some(DelegationPermissions::All)], + outcome: Outcome::All, + }, + PermissionsTest { + note: "queries restriction in the first of two delegations", + permissions: vec![Some(DelegationPermissions::Queries), None], + outcome: Outcome::QueriesOnly { + err: update_not_permitted, + }, + }, + PermissionsTest { + note: "queries restriction in the second of two delegations", + permissions: vec![ + Some(DelegationPermissions::All), + Some(DelegationPermissions::Queries), + ], + outcome: Outcome::QueriesOnly { + err: update_not_permitted, + }, + }, + ]; + + for scenario in &scenarios { + let delegation_count = scenario.permissions.len(); + let mut identities = Vec::with_capacity(delegation_count + 1); + for _ in 0..=delegation_count { + let id_type = GenericIdentityType::random_incl_canister(&canister, rng); + identities.push(GenericIdentity::new(id_type, rng)); + } + + let delegations = + create_delegations_with_permissions(&identities, &scenario.permissions); + let sender = &identities[0]; + let signer = &identities[identities.len() - 1]; + + for &api_ver in ALL_QUERY_API_VERSIONS { + let response = perform_query_call_with_delegations( + api_ver, + &test_info, + sender, + signer, + &delegations, + ) + .await; + // Query calls are permitted regardless of the outcome. + response.expect_query_ok(api_ver); + } + + for &api_ver in ALL_READ_STATE_API_VERSIONS { + let response = perform_read_state_call_with_delegations( + api_ver, + &test_info, + sender, + signer, + &delegations, + ) + .await; + // read_state requests are permitted regardless of the outcome. + response.expect_read_state_ok(api_ver); + } + + for &api_ver in ALL_UPDATE_API_VERSIONS { + let response = perform_update_call_with_delegations( + api_ver, + &test_info, + sender, + signer, + &delegations, + ) + .await; + match scenario.outcome { + Outcome::All => response.expect_update_ok(api_ver), + Outcome::QueriesOnly { err } => { + assert_eq!( + response.status(), + 400, + "Scenario {} (update) using v{api_ver} unexpectedly succeeded", + scenario.note + ); + response.expect_text_error(err); + } + } + } + } + } + }); +} + // Tests for handling of delegation loops pub fn requests_with_delegation_loop(env: TestEnv) { let logger = env.logger(); @@ -1563,11 +1705,8 @@ fn create_delegations_with_targets( let delegation = if targets[i - 1].is_empty() { Delegation::new(identities[i].public_key_der(), delegation_expiry) } else { - Delegation::new_with_targets( - identities[i].public_key_der(), - delegation_expiry, - targets[i - 1].clone(), - ) + Delegation::new(identities[i].public_key_der(), delegation_expiry) + .with_targets(targets[i - 1].clone()) }; let signed_delegation = sign_delegation(delegation, &identities[i - 1]); @@ -1579,6 +1718,30 @@ fn create_delegations_with_targets( delegations } +fn create_delegations_with_permissions( + identities: &[GenericIdentity], + permissions: &[Option], +) -> Vec { + let delegation_expiry = Time::from_nanos_since_unix_epoch(expiry_time().as_nanos() as u64); + + let delegation_count = identities.len() - 1; + let mut delegations = Vec::with_capacity(delegation_count); + + for i in 1..=delegation_count { + let delegation = match permissions[i - 1] { + Some(permissions) => Delegation::new(identities[i].public_key_der(), delegation_expiry) + .with_permissions(permissions), + None => Delegation::new(identities[i].public_key_der(), delegation_expiry), + }; + + let signed_delegation = sign_delegation(delegation, &identities[i - 1]); + + delegations.push(signed_delegation); + } + + delegations +} + fn random_canister_id(rng: &mut R) -> CanisterId { CanisterId::from_u64(rng.r#gen::()) } diff --git a/rs/types/types/src/messages.rs b/rs/types/types/src/messages.rs index 8a1522aa317d..0496f747b525 100644 --- a/rs/types/types/src/messages.rs +++ b/rs/types/types/src/messages.rs @@ -10,12 +10,13 @@ mod webauthn; pub use self::http::{ Authentication, Certificate, CertificateDelegation, CertificateDelegationFormat, - CertificateDelegationMetadata, Delegation, HasCanisterId, HttpCallContent, HttpCanisterUpdate, - HttpQueryContent, HttpQueryResponse, HttpQueryResponseReply, HttpReadState, - HttpReadStateContent, HttpReadStateResponse, HttpReply, HttpRequest, HttpRequestContent, - HttpRequestEnvelope, HttpRequestError, HttpSignedQueryResponse, HttpStatusResponse, - HttpUserQuery, NodeSignature, QueryResponseHash, RawHttpRequestVal, RawSignedSenderInfo, - ReplicaHealthStatus, SenderInfoContent, SignedDelegation, SignedSenderInfo, + CertificateDelegationMetadata, Delegation, DelegationPermissions, HasCanisterId, + HttpCallContent, HttpCanisterUpdate, HttpQueryContent, HttpQueryResponse, + HttpQueryResponseReply, HttpReadState, HttpReadStateContent, HttpReadStateResponse, HttpReply, + HttpRequest, HttpRequestContent, HttpRequestEnvelope, HttpRequestError, + HttpSignedQueryResponse, HttpStatusResponse, HttpUserQuery, NodeSignature, QueryResponseHash, + RawHttpRequestVal, RawSignedSenderInfo, ReplicaHealthStatus, SenderInfoContent, + SignedDelegation, SignedSenderInfo, }; use crate::methods::Callback; pub use crate::methods::SystemMethod; diff --git a/rs/types/types/src/messages/http.rs b/rs/types/types/src/messages/http.rs index add56b40d4ff..b7282931704d 100644 --- a/rs/types/types/src/messages/http.rs +++ b/rs/types/types/src/messages/http.rs @@ -536,6 +536,34 @@ impl From for HttpRequestError { } } +/// The kinds of calls a delegation permits, as defined in +/// ``. +/// An absent field on the [`Delegation`] is unrestricted, equivalent to +/// [`DelegationPermissions::All`]. +#[derive(Clone, Copy, Eq, PartialEq, Hash, Debug, Deserialize, Serialize)] +#[cfg_attr(test, derive(Arbitrary))] +pub enum DelegationPermissions { + /// The sender may only perform query calls and `read_state` requests; + /// requests to `/call` endpoints are rejected. + #[serde(rename = "queries")] + Queries, + /// The sender may perform all kinds of calls (queries, replicated + /// queries, and updates). Same as omitting the field. + #[serde(rename = "all")] + All, +} + +impl DelegationPermissions { + /// The textual value as it appears on the wire and in the + /// representation-independent hash. + fn as_str(&self) -> &'static str { + match self { + DelegationPermissions::Queries => "queries", + DelegationPermissions::All => "all", + } + } +} + /// Describes a delegation map as defined in /// ``. #[derive(Clone, Eq, PartialEq, Hash, Debug, Deserialize, Serialize)] @@ -544,6 +572,7 @@ pub struct Delegation { pubkey: Blob, expiration: Time, targets: Option>, + permissions: Option, } impl Delegation { @@ -552,15 +581,20 @@ impl Delegation { pubkey: Blob(pubkey), expiration, targets: None, + permissions: None, } } - pub fn new_with_targets(pubkey: Vec, expiration: Time, targets: Vec) -> Self { - Self { - pubkey: Blob(pubkey), - expiration, - targets: Some(targets.iter().map(|c| Blob(c.get().to_vec())).collect()), - } + /// Restricts the delegation to the given canister targets. + pub fn with_targets(mut self, targets: Vec) -> Self { + self.targets = Some(targets.iter().map(|c| Blob(c.get().to_vec())).collect()); + self + } + + /// Restricts the kinds of calls the delegation permits. + pub fn with_permissions(mut self, permissions: DelegationPermissions) -> Self { + self.permissions = Some(permissions); + self } pub fn pubkey(&self) -> &Vec { @@ -590,6 +624,12 @@ impl Delegation { pub fn number_of_targets(&self) -> Option { self.targets.as_ref().map(Vec::len) } + + /// The kinds of calls the delegation permits, if restricted. + /// `None` means the delegation is unrestricted. + pub fn permissions(&self) -> Option { + self.permissions + } } impl SignedBytesWithoutDomainSeparator for Delegation { @@ -606,6 +646,9 @@ impl SignedBytesWithoutDomainSeparator for Delegation { Array(targets.iter().map(|t| Bytes(t.0.as_slice())).collect()), ); } + if let Some(permissions) = self.permissions { + map.insert("permissions", String(permissions.as_str())); + } bytes.extend_from_slice(&hash_of_map(&map, |key, value| hash_key_val(key, value))); } diff --git a/rs/types/types/src/messages/http/tests.rs b/rs/types/types/src/messages/http/tests.rs index 61ab6f583103..08edf9971448 100644 --- a/rs/types/types/src/messages/http/tests.rs +++ b/rs/types/types/src/messages/http/tests.rs @@ -18,6 +18,7 @@ mod targets { invalid_canister_id, to_blob(CanisterId::from(3)), ]), + permissions: None, }; let targets = delegation.targets(); @@ -33,6 +34,7 @@ mod targets { let delegation = Delegation { pubkey: Blob(vec![]), expiration: CURRENT_TIME, + permissions: None, targets: Some(vec![ to_blob(canister_id_3), to_blob(canister_id_3), @@ -718,8 +720,8 @@ mod cbor_serialization { use crate::{ AmountOf, Time, messages::{ - Blob, Delegation, HttpQueryResponse, HttpQueryResponseReply, HttpStatusResponse, - ReplicaHealthStatus, SignedDelegation, + Blob, Delegation, DelegationPermissions, HttpQueryResponse, HttpQueryResponseReply, + HttpStatusResponse, ReplicaHealthStatus, SignedDelegation, http::{HttpSignedQueryResponse, NodeSignature, btreemap}, }, time::UNIX_EPOCH, @@ -914,11 +916,13 @@ mod cbor_serialization { pubkey: Blob(vec![1, 2, 3]), expiration: UNIX_EPOCH, targets: None, + permissions: None, }, Value::Map(btreemap! { text("pubkey") => bytes(&[1, 2, 3]), text("expiration") => int(0), text("targets") => Value::Null, + text("permissions") => Value::Null, }), ); @@ -927,13 +931,106 @@ mod cbor_serialization { pubkey: Blob(vec![1, 2, 3]), expiration: UNIX_EPOCH, targets: Some(vec![Blob(vec![4, 5, 6])]), + permissions: None, }, Value::Map(btreemap! { text("pubkey") => bytes(&[1, 2, 3]), text("expiration") => int(0), text("targets") => Value::Array(vec![bytes(&[4, 5, 6])]), + text("permissions") => Value::Null, }), ); + + assert_cbor_ser_equal( + &Delegation { + pubkey: Blob(vec![1, 2, 3]), + expiration: UNIX_EPOCH, + targets: None, + permissions: Some(DelegationPermissions::Queries), + }, + Value::Map(btreemap! { + text("pubkey") => bytes(&[1, 2, 3]), + text("expiration") => int(0), + text("targets") => Value::Null, + text("permissions") => text("queries"), + }), + ); + + // A delegation may carry both `targets` and `permissions`. + assert_cbor_ser_equal( + &Delegation { + pubkey: Blob(vec![1, 2, 3]), + expiration: UNIX_EPOCH, + targets: Some(vec![Blob(vec![4, 5, 6])]), + permissions: Some(DelegationPermissions::Queries), + }, + Value::Map(btreemap! { + text("pubkey") => bytes(&[1, 2, 3]), + text("expiration") => int(0), + text("targets") => Value::Array(vec![bytes(&[4, 5, 6])]), + text("permissions") => text("queries"), + }), + ); + } + + /// Verifies that the optional `permissions` field survives a full CBOR + /// wire round-trip (serialize to bytes, deserialize back), since + /// requests carry delegations as CBOR on the wire. An absent field must + /// deserialize to `None` so that delegations predating this field remain + /// parseable. + #[test] + fn delegation_permissions_cbor_round_trip() { + use crate::crypto::Signable; + + for permissions in [ + None, + Some(DelegationPermissions::Queries), + Some(DelegationPermissions::All), + ] { + let delegation = Delegation { + pubkey: Blob(vec![1, 2, 3]), + expiration: UNIX_EPOCH, + targets: None, + permissions, + }; + let bytes = serde_cbor::to_vec(&delegation).unwrap(); + let decoded: Delegation = serde_cbor::from_slice(&bytes).unwrap(); + assert_eq!(decoded.permissions, permissions); + // The signed bytes (which include the permissions field) must + // also match, i.e. the round-trip preserves what gets signed. + assert_eq!(decoded.as_signed_bytes(), delegation.as_signed_bytes()); + } + + // A delegation encoded without the `permissions` key (as produced by + // implementations predating the field) deserializes to `None`. + let without_permissions = Value::Map(btreemap! { + text("pubkey") => bytes(&[1, 2, 3]), + text("expiration") => int(0), + }); + let decoded: Delegation = serde_cbor::value::from_value(without_permissions).unwrap(); + assert_eq!(decoded.permissions, None); + assert_eq!(decoded.targets, None); + } + + /// A delegation whose `permissions` field carries an unsupported value + /// (anything other than `"queries"`/`"all"`, including case and + /// whitespace variants of them) must fail to deserialize, so requests + /// carrying such a delegation are rejected when decoded. + #[test] + fn delegation_permissions_rejects_unsupported_value() { + for unsupported in [ + "writes", "updates", "", "QUERIES", "queries ", " queries", "All", "all ", + ] { + let encoded = Value::Map(btreemap! { + text("pubkey") => bytes(&[1, 2, 3]), + text("expiration") => int(0), + text("permissions") => text(unsupported), + }); + assert!( + serde_cbor::value::from_value::(encoded).is_err(), + "permissions value {unsupported:?} unexpectedly deserialized" + ); + } } #[test] @@ -944,6 +1041,7 @@ mod cbor_serialization { pubkey: Blob(vec![1, 2, 3]), expiration: UNIX_EPOCH, targets: None, + permissions: None, }, signature: Blob(vec![4, 5, 6]), }, @@ -952,6 +1050,7 @@ mod cbor_serialization { text("pubkey") => bytes(&[1, 2, 3]), text("expiration") => int(0), text("targets") => Value::Null, + text("permissions") => Value::Null, }), text("signature") => bytes(&[4, 5, 6]), }), diff --git a/rs/validator/http_request_test_utils/src/lib.rs b/rs/validator/http_request_test_utils/src/lib.rs index b4711573498e..37746eb3fbaf 100644 --- a/rs/validator/http_request_test_utils/src/lib.rs +++ b/rs/validator/http_request_test_utils/src/lib.rs @@ -7,9 +7,9 @@ use ic_crypto_tree_hash::Path; use ic_types::crypto::threshold_sig::ThresholdSigPublicKey; use ic_types::crypto::{CanisterSig, Signable}; use ic_types::messages::{ - Blob, Delegation, HttpCallContent, HttpCanisterUpdate, HttpQueryContent, HttpReadState, - HttpReadStateContent, HttpRequest, HttpRequestEnvelope, HttpUserQuery, MessageId, Query, - RawSignedSenderInfo, ReadState, SignedDelegation, SignedIngressContent, + Blob, Delegation, DelegationPermissions, HttpCallContent, HttpCanisterUpdate, HttpQueryContent, + HttpReadState, HttpReadStateContent, HttpRequest, HttpRequestEnvelope, HttpUserQuery, + MessageId, Query, RawSignedSenderInfo, ReadState, SignedDelegation, SignedIngressContent, }; use ic_types::time::GENESIS; use ic_types::{CanisterId, PrincipalId, Time}; @@ -527,7 +527,21 @@ impl DirectAuthenticationScheme { expiration: Time, targets: Vec, ) -> SignedDelegation { - let delegation = Delegation::new_with_targets(other.public_key_der(), expiration, targets); + let delegation = Delegation::new(other.public_key_der(), expiration).with_targets(targets); + let signature = self.sign(&delegation); + SignedDelegation::new(delegation, signature) + } + + /// Creates a delegation that restricts the kinds of calls the delegate + /// may make. + fn delegate_to_with_permissions( + &self, + other: &DirectAuthenticationScheme, + expiration: Time, + permissions: DelegationPermissions, + ) -> SignedDelegation { + let delegation = + Delegation::new(other.public_key_der(), expiration).with_permissions(permissions); let signature = self.sign(&delegation); SignedDelegation::new(delegation, signature) } @@ -595,6 +609,19 @@ impl DelegationChainBuilder { self } + pub fn delegate_to_with_permissions( + mut self, + new_end: DirectAuthenticationScheme, + expiration: Time, + permissions: DelegationPermissions, + ) -> Self { + let current_end = self.end.unwrap_or_else(|| self.start.clone()); + self.signed_delegations + .push(current_end.delegate_to_with_permissions(&new_end, expiration, permissions)); + self.end = Some(new_end); + self + } + pub fn change_last_delegation SignedDelegationBuilder>( mut self, change: F, @@ -684,7 +711,7 @@ impl SignedDelegationBuilder { pub fn build(self) -> SignedDelegation { let delegation = match self.targets { Some(canister_ids) => { - Delegation::new_with_targets(self.pubkey, self.expiration, canister_ids) + Delegation::new(self.pubkey, self.expiration).with_targets(canister_ids) } None => Delegation::new(self.pubkey, self.expiration), }; diff --git a/rs/validator/ingress_message/src/internal/mod.rs b/rs/validator/ingress_message/src/internal/mod.rs index 5c1286db28e0..8dfdec994b6d 100644 --- a/rs/validator/ingress_message/src/internal/mod.rs +++ b/rs/validator/ingress_message/src/internal/mod.rs @@ -208,6 +208,9 @@ fn to_validation_error(error: ic_validator::RequestValidationError) -> RequestVa ic_validator::RequestValidationError::InvalidSenderInfo(msg) => { RequestValidationError::InvalidSenderInfo(msg) } + ic_validator::RequestValidationError::UpdateCallNotPermittedByDelegation => { + RequestValidationError::UpdateCallNotPermittedByDelegation + } } } 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 e3080a118bed..4bf613f25d37 100644 --- a/rs/validator/ingress_message/src/lib.rs +++ b/rs/validator/ingress_message/src/lib.rs @@ -46,6 +46,8 @@ pub use internal::TimeProvider; /// * [`RequestValidationError::CanisterNotInDelegationTargets`]: if the request targets a canister /// that is not authorized in one of the delegations. /// * [`RequestValidationError::InvalidSenderInfo`]: if sender info is provided but invalid. +/// * [`RequestValidationError::UpdateCallNotPermittedByDelegation`]: if the request is submitted +/// to `/call` (an update call or replicated query) but a delegation restricts the sender to query calls. pub trait HttpRequestVerifier { fn validate_request(&self, request: &HttpRequest) -> Result<(), RequestValidationError>; } @@ -65,6 +67,7 @@ pub enum RequestValidationError { PathTooLongError { length: usize, maximum: usize }, NonceTooBigError { num_bytes: usize, maximum: usize }, InvalidSenderInfo(String), + UpdateCallNotPermittedByDelegation, } impl Display for RequestValidationError { @@ -112,6 +115,13 @@ impl Display for RequestValidationError { RequestValidationError::InvalidSenderInfo(msg) => { write!(f, "Invalid sender info: {msg}") } + RequestValidationError::UpdateCallNotPermittedByDelegation => { + write!( + f, + "Update calls are not permitted: a delegation restricts \ + the sender to query calls (permissions = \"queries\")" + ) + } } } } diff --git a/rs/validator/ingress_message/tests/validate_request.rs b/rs/validator/ingress_message/tests/validate_request.rs index 6c3348be9c01..b0ce57c3fb06 100644 --- a/rs/validator/ingress_message/tests/validate_request.rs +++ b/rs/validator/ingress_message/tests/validate_request.rs @@ -2175,6 +2175,166 @@ mod sender_info { } } +mod delegation_permissions { + use super::*; + use ic_types::messages::DelegationPermissions; + use ic_validator_http_request_test_utils::DelegationChain; + + #[test] + fn should_reject_update_call_when_delegation_restricts_to_queries() { + let rng = &mut reproducible_rng(); + let verifier = verifier_at_time(CURRENT_TIME).build(); + let chain = DelegationChain::rooted_at(random_user_key_pair(rng)) + .delegate_to_with_permissions( + random_user_key_pair(rng), + CURRENT_TIME, + DelegationPermissions::Queries, + ) + .build(); + + 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::UpdateCallNotPermittedByDelegation) + ); + } + + #[test] + fn should_accept_query_and_read_state_when_delegation_restricts_to_queries() { + let rng = &mut reproducible_rng(); + let verifier = verifier_at_time(CURRENT_TIME).build(); + let chain = DelegationChain::rooted_at(random_user_key_pair(rng)) + .delegate_to_with_permissions( + random_user_key_pair(rng), + CURRENT_TIME, + DelegationPermissions::Queries, + ) + .build(); + + let query = HttpRequestBuilder::new_query() + .with_ingress_expiry_at(CURRENT_TIME) + .with_authentication(AuthenticationScheme::Delegation(chain.clone())) + .build(); + assert_eq!(verifier.validate_request(&query), Ok(())); + + let read_state = HttpRequestBuilder::new_read_state() + .with_ingress_expiry_at(CURRENT_TIME) + .with_authentication(AuthenticationScheme::Delegation(chain)) + .build(); + assert_eq!(verifier.validate_request(&read_state), Ok(())); + } + + #[test] + fn should_accept_update_call_when_delegation_permissions_are_all() { + let rng = &mut reproducible_rng(); + let verifier = verifier_at_time(CURRENT_TIME).build(); + let chain = DelegationChain::rooted_at(random_user_key_pair(rng)) + .delegate_to_with_permissions( + random_user_key_pair(rng), + CURRENT_TIME, + DelegationPermissions::All, + ) + .build(); + + let request = HttpRequestBuilder::new_update_call() + .with_ingress_expiry_at(CURRENT_TIME) + .with_authentication(AuthenticationScheme::Delegation(chain)) + .build(); + + assert_eq!(verifier.validate_request(&request), Ok(())); + } + + #[test] + fn should_reject_update_call_when_any_delegation_in_chain_restricts_to_queries() { + let rng = &mut reproducible_rng(); + let verifier = verifier_at_time(CURRENT_TIME).build(); + // The restriction sits in the middle of the chain; subsequent + // unrestricted delegations must not lift it. + let chain = DelegationChain::rooted_at(random_user_key_pair(rng)) + .delegate_to_with_permissions( + random_user_key_pair(rng), + CURRENT_TIME, + DelegationPermissions::Queries, + ) + .delegate_to(random_user_key_pair(rng), CURRENT_TIME) + .build(); + + let update = HttpRequestBuilder::new_update_call() + .with_ingress_expiry_at(CURRENT_TIME) + .with_authentication(AuthenticationScheme::Delegation(chain.clone())) + .build(); + assert_matches!( + verifier.validate_request(&update), + Err(RequestValidationError::UpdateCallNotPermittedByDelegation) + ); + + let query = HttpRequestBuilder::new_query() + .with_ingress_expiry_at(CURRENT_TIME) + .with_authentication(AuthenticationScheme::Delegation(chain)) + .build(); + assert_eq!(verifier.validate_request(&query), Ok(())); + } + + #[test] + fn should_apply_both_targets_and_permissions_restrictions_in_a_chain() { + let rng = &mut reproducible_rng(); + let verifier = verifier_at_time(CURRENT_TIME).build(); + let requested_canister_id = CanisterId::from(42); + // A chain that restricts the targets in one delegation and the + // permissions in another: both restrictions must apply. + let chain = DelegationChain::rooted_at(random_user_key_pair(rng)) + .delegate_to_with_targets( + random_user_key_pair(rng), + CURRENT_TIME, + vec![requested_canister_id, CanisterId::from(43)], + ) + .delegate_to_with_permissions( + random_user_key_pair(rng), + CURRENT_TIME, + DelegationPermissions::Queries, + ) + .build(); + + // Update call to an in-target canister: rejected by the queries-only + // restriction (the target is satisfied, so it is the permissions + // restriction firing, not the target one). + let update = HttpRequestBuilder::new_update_call() + .with_ingress_expiry_at(CURRENT_TIME) + .with_canister_id(Blob(requested_canister_id.get().to_vec())) + .with_authentication(AuthenticationScheme::Delegation(chain.clone())) + .build(); + assert_matches!( + verifier.validate_request(&update), + Err(RequestValidationError::UpdateCallNotPermittedByDelegation) + ); + + // Query to an in-target canister: permitted (queries are allowed and + // the target matches). + let query_in_targets = HttpRequestBuilder::new_query() + .with_ingress_expiry_at(CURRENT_TIME) + .with_canister_id(Blob(requested_canister_id.get().to_vec())) + .with_authentication(AuthenticationScheme::Delegation(chain.clone())) + .build(); + assert_eq!(verifier.validate_request(&query_in_targets), Ok(())); + + // Query to an out-of-target canister: rejected by the targets + // restriction, which applies alongside the permissions restriction. + let query_out_of_targets = HttpRequestBuilder::new_query() + .with_ingress_expiry_at(CURRENT_TIME) + .with_canister_id(Blob(CanisterId::from(44).get().to_vec())) + .with_authentication(AuthenticationScheme::Delegation(chain)) + .build(); + assert_matches!( + verifier.validate_request(&query_out_of_targets), + Err(RequestValidationError::CanisterNotInDelegationTargets(_)) + ); + } +} + 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 337c1672be1b..5df7fefa0417 100644 --- a/rs/validator/src/ingress_validation.rs +++ b/rs/validator/src/ingress_validation.rs @@ -13,9 +13,9 @@ use ic_types::{ threshold_sig::RootOfTrustProvider, }, messages::{ - Authentication, Delegation, HasCanisterId, HttpRequest, HttpRequestContent, MessageId, - Query, ReadState, SenderInfoContent, SignedDelegation, SignedIngressContent, - SignedSenderInfo, UserSignature, WebAuthnSignature, + Authentication, Delegation, DelegationPermissions, HasCanisterId, HttpRequest, + HttpRequestContent, MessageId, Query, ReadState, SenderInfoContent, SignedDelegation, + SignedIngressContent, SignedSenderInfo, UserSignature, WebAuthnSignature, }, }; use std::{ @@ -60,6 +60,25 @@ const MAXIMUM_NUMBER_OF_PATHS: usize = 1_000; /// and so changing this value might be breaking or result in a deviation from the specification. const MAXIMUM_NUMBER_OF_LABELS_PER_PATH: usize = 127; +/// Restrictions that a chain of delegations places on the sender. +#[derive(Debug)] +struct DelegationRestrictions { + /// The set of canister IDs that are common to all delegations' `targets`. + targets: CanisterIdSet, + /// Whether some delegation in the chain restricts the sender to query + /// calls via the `permissions` field. + queries_only: bool, +} + +impl DelegationRestrictions { + fn unrestricted() -> Self { + Self { + targets: CanisterIdSet::all(), + queries_only: false, + } + } +} + /// A trait for validating an `HttpRequest` with content `C`. pub trait HttpRequestVerifier: Send + Sync { /// Validates the given request. @@ -76,6 +95,10 @@ pub trait HttpRequestVerifier: Send + Sync { /// * The request's signature (if any) is correct. /// * If the request specifies a `CanisterId` (see `HasCanisterId`), /// then it must be among the set of canister IDs that are common to all delegations. + /// * Every delegation's `permissions` field (if any) holds a supported value + /// (`"queries"` or `"all"`). If the request is submitted to `/call` (an update + /// call or replicated query), no delegation restricts the sender to query calls + /// (`permissions = "queries"`). /// /// The following signatures (for signing the request or any delegation) are supported /// (see the [IC specification](https://internetcomputer.org/docs/current/references/ic-interface-spec#signatures)): @@ -115,14 +138,20 @@ where root_of_trust_provider: &R, ) -> Result { validate_ingress_expiry(request, current_time)?; - let delegation_targets = validate_request_content( + let restrictions = validate_request_content( request, self.validator.as_ref(), current_time, root_of_trust_provider, )?; - validate_request_target(request, &delegation_targets)?; - Ok(delegation_targets) + // A request to a `/call` endpoint (an update call or a replicated + // query) is rejected if a delegation in the chain restricts the + // sender to query calls. + if restrictions.queries_only { + return Err(UpdateCallNotPermittedByDelegation); + } + validate_request_target(request, &restrictions.targets)?; + Ok(restrictions.targets) } } @@ -140,14 +169,16 @@ where if !request.sender().get().is_anonymous() { validate_ingress_expiry(request, current_time)?; } - let delegation_targets = validate_request_content( + // A delegation with `permissions = "queries"` does not restrict + // query calls. + let restrictions = validate_request_content( request, self.validator.as_ref(), current_time, root_of_trust_provider, )?; - validate_request_target(request, &delegation_targets)?; - Ok(delegation_targets) + validate_request_target(request, &restrictions.targets)?; + Ok(restrictions.targets) } } @@ -166,12 +197,15 @@ where if !request.sender().get().is_anonymous() { validate_ingress_expiry(request, current_time)?; } + // A delegation with `permissions = "queries"` does not restrict + // read_state requests. validate_request_content( request, self.validator.as_ref(), current_time, root_of_trust_provider, ) + .map(|restrictions| restrictions.targets) } } @@ -198,14 +232,14 @@ fn validate_request_content( ingress_signature_verifier: &dyn IngressSigVerifier, current_time: Time, root_of_trust_provider: &R, -) -> Result +) -> Result where R::Error: std::error::Error, { validate_nonce(request)?; // Validate the envelope signature first (cheap check) before performing // expensive canister signature verification in validate_sender_info. - let targets = validate_user_id_and_signature( + let restrictions = validate_user_id_and_signature( ingress_signature_verifier, &request.sender(), &request.id(), @@ -217,7 +251,7 @@ where root_of_trust_provider, )?; validate_sender_info(request, ingress_signature_verifier, root_of_trust_provider)?; - Ok(targets) + Ok(restrictions) } fn validate_request_target( @@ -266,6 +300,11 @@ pub enum RequestValidationError { NonceTooBig { num_bytes: usize, maximum: usize }, #[error("Invalid sender info: {0}")] InvalidSenderInfo(String), + #[error( + "Update calls are not permitted: a delegation restricts the sender \ + to query calls (permissions = \"queries\")" + )] + UpdateCallNotPermittedByDelegation, } /// Error in verifying the signature or authentication part of a request. @@ -638,7 +677,7 @@ fn validate_signature( signature: &UserSignature, current_time: Time, root_of_trust_provider: &R, -) -> Result +) -> Result where R::Error: std::error::Error, { @@ -647,7 +686,7 @@ where let empty_vec = Vec::new(); let signed_delegations = signature.sender_delegation.as_ref().unwrap_or(&empty_vec); - let (pubkey, targets) = validate_delegations( + let (pubkey, restrictions) = validate_delegations( validator, signed_delegations.as_slice(), signature.signer_pubkey.clone(), @@ -666,7 +705,7 @@ where validate_webauthn_sig(validator, &webauthn_sig, message_id, &pk) .map_err(WebAuthnError) .map_err(InvalidSignature)?; - Ok(targets) + Ok(restrictions) } KeyBytesContentType::Ed25519PublicKeyDer | KeyBytesContentType::EcdsaP256PublicKeyDer @@ -674,7 +713,7 @@ where let basic_sig = BasicSigOf::from(BasicSig(signature.signature.clone())); validate_signature_plain(validator, message_id, &basic_sig, &pk) .map_err(InvalidSignature)?; - Ok(targets) + Ok(restrictions) } KeyBytesContentType::IcCanisterSignatureAlgPublicKeyDer => { let canister_sig = CanisterSigOf::from(CanisterSig(signature.signature.clone())); @@ -689,7 +728,7 @@ where e.to_string() )) ); - Ok(targets) + Ok(restrictions) } KeyBytesContentType::RsaSha256PublicKeyDer => { Err(RequestValidationError::InvalidSignature( @@ -717,20 +756,23 @@ fn validate_signature_plain( // See https://internetcomputer.org/docs/current/references/ic-interface-spec#authentication // // If the delegations are valid, returns the public key used to sign the -// request as well as the set of canister IDs that the public key is valid for. +// request as well as the restrictions that the delegations place on the +// sender (the set of canister IDs that the public key is valid for, and +// whether the sender is restricted to query calls). fn validate_delegations( validator: &dyn IngressSigVerifier, signed_delegations: &[SignedDelegation], mut pubkey: Vec, root_of_trust_provider: &R, -) -> Result<(Vec, CanisterIdSet), RequestValidationError> +) -> Result<(Vec, DelegationRestrictions), RequestValidationError> where R::Error: std::error::Error, { ensure_delegations_does_not_contain_cycles(&pubkey, signed_delegations)?; ensure_delegations_does_not_contain_too_many_targets(signed_delegations)?; - // Initially, assume that the delegations target all possible canister IDs. - let mut targets = CanisterIdSet::all(); + // Initially, assume that the delegations place no restrictions on the + // sender. + let mut restrictions = DelegationRestrictions::unrestricted(); for sd in signed_delegations { let delegation = sd.delegation(); @@ -745,11 +787,18 @@ where ) .map_err(InvalidDelegation)?; // Restrict the canister targets to the ones specified in the delegation. - targets = targets.intersect(new_targets); + restrictions.targets = restrictions.targets.intersect(new_targets); + // Restrict the kinds of calls to the ones permitted by the delegation. + // Unsupported `permissions` values are rejected earlier, when the + // request is decoded, since the field is a closed enum. + match delegation.permissions() { + None | Some(DelegationPermissions::All) => {} + Some(DelegationPermissions::Queries) => restrictions.queries_only = true, + } pubkey = delegation.pubkey().to_vec(); } - Ok((pubkey, targets)) + Ok((pubkey, restrictions)) } fn ensure_delegations_does_not_contain_cycles( @@ -846,14 +895,14 @@ fn validate_user_id_and_signature( signature: Option<&UserSignature>, current_time: Time, root_of_trust_provider: &R, -) -> Result +) -> Result where R::Error: std::error::Error, { match signature { None => { if sender.get().is_anonymous() { - return Ok(CanisterIdSet::all()); + return Ok(DelegationRestrictions::unrestricted()); } Err(MissingSignature(*sender)) } diff --git a/rs/validator/src/ingress_validation/tests.rs b/rs/validator/src/ingress_validation/tests.rs index 20371401d30d..8006d0dc5c43 100644 --- a/rs/validator/src/ingress_validation/tests.rs +++ b/rs/validator/src/ingress_validation/tests.rs @@ -5,7 +5,7 @@ use ic_crypto_temp_crypto::temp_crypto_component_with_fake_registry; use ic_crypto_test_utils_root_of_trust::MockRootOfTrustProvider; use ic_test_utilities_types::ids::{canister_test_id, message_test_id, node_test_id}; use ic_types::{ - messages::{Delegation, SignedDelegation, UserSignature}, + messages::{Delegation, DelegationPermissions, SignedDelegation, UserSignature}, time::UNIX_EPOCH, }; use std::time::Duration; @@ -131,7 +131,7 @@ fn plain_authentication_with_one_delegation() { sender_delegation: Some(vec![signed_delegation]), }; - assert_eq!( + assert_matches!( validate_signature( &sig_verifier, &message_id, @@ -139,7 +139,7 @@ fn plain_authentication_with_one_delegation() { UNIX_EPOCH, &MockRootOfTrustProvider::new() ), - Ok(CanisterIdSet::all()) + Ok(restrictions) if restrictions.targets == CanisterIdSet::all() && !restrictions.queries_only ); // Try verifying the signature in the future. It should fail because the @@ -179,7 +179,7 @@ fn plain_authentication_with_one_scoped_delegation() { base64::decode("SyP7C1lwpbsWjwT7ow5CnbiL5JzbyjzQrdDVQQb18yE=").unwrap(), ) .unwrap(); - let delegation = Delegation::new_with_targets(pk2, UNIX_EPOCH, vec![canister_test_id(1)]); + let delegation = Delegation::new(pk2, UNIX_EPOCH).with_targets(vec![canister_test_id(1)]); // Signature of sk1 for the delegation above. let delegation_signature = base64::decode( @@ -207,10 +207,84 @@ fn plain_authentication_with_one_scoped_delegation() { UNIX_EPOCH, &MockRootOfTrustProvider::new() ), - Ok(ids) if ids == CanisterIdSet::try_from_iter(vec![canister_test_id(1)]).unwrap() + Ok(restrictions) if restrictions.targets == CanisterIdSet::try_from_iter(vec![canister_test_id(1)]).unwrap() && !restrictions.queries_only ); } +mod delegation_permissions { + use super::*; + use ic_types::crypto::Signable; + + /// Signs `delegation` with `signer` and the message id with `sender_sk`, + /// then validates the single-delegation chain `signer -> sender_sk`. + /// Uses ECDSA secp256r1 identities (supported by the validator and + /// signable in-process, unlike the hard-coded ed25519 signatures used by + /// the surrounding tests). + fn validate_single_delegation( + permissions: Option, + ) -> Result { + let sig_verifier = temp_crypto_component_with_fake_registry(node_test_id(0)); + let message_id = message_test_id(1); + let rng = &mut ic_crypto_test_utils_reproducible_rng::reproducible_rng(); + + let signer_sk = ic_secp256r1::PrivateKey::generate_using_rng(rng); + let session_sk = ic_secp256r1::PrivateKey::generate_using_rng(rng); + let signer_pk = signer_sk.public_key().serialize_der(); + let session_pk = session_sk.public_key().serialize_der(); + + let delegation = match permissions { + Some(permissions) => { + Delegation::new(session_pk, UNIX_EPOCH).with_permissions(permissions) + } + None => Delegation::new(session_pk, UNIX_EPOCH), + }; + let delegation_sig = signer_sk + .sign_message(&delegation.as_signed_bytes()) + .to_vec(); + let signed_delegation = SignedDelegation::new(delegation, delegation_sig); + + let user_signature = UserSignature { + signature: session_sk + .sign_message(&message_id.as_signed_bytes()) + .to_vec(), + signer_pubkey: signer_pk, + sender_delegation: Some(vec![signed_delegation]), + }; + + validate_signature( + &sig_verifier, + &message_id, + &user_signature, + UNIX_EPOCH, + &MockRootOfTrustProvider::new(), + ) + } + + #[test] + fn queries_permission_sets_queries_only() { + assert_matches!( + validate_single_delegation(Some(DelegationPermissions::Queries)), + Ok(restrictions) if restrictions.queries_only + ); + } + + #[test] + fn all_permission_does_not_set_queries_only() { + assert_matches!( + validate_single_delegation(Some(DelegationPermissions::All)), + Ok(restrictions) if !restrictions.queries_only + ); + } + + #[test] + fn absent_permission_does_not_set_queries_only() { + assert_matches!( + validate_single_delegation(None), + Ok(restrictions) if !restrictions.queries_only + ); + } +} + #[test] fn plain_authentication_with_multiple_delegations() { let sig_verifier = temp_crypto_component_with_fake_registry(node_test_id(0)); @@ -249,11 +323,8 @@ fn plain_authentication_with_multiple_delegations() { .unwrap(); // KP1 delegating to KP2. - let delegation = Delegation::new_with_targets( - pk2, - UNIX_EPOCH + Duration::new(4, 0), - vec![canister_test_id(1), canister_test_id(2)], - ); + let delegation = Delegation::new(pk2, UNIX_EPOCH + Duration::new(4, 0)) + .with_targets(vec![canister_test_id(1), canister_test_id(2)]); // Signature of SK1 for `delegation` above. let delegation_signature = base64::decode( @@ -270,11 +341,8 @@ fn plain_authentication_with_multiple_delegations() { .unwrap(); // KP3 delegating to KP4. - let delegation_3 = Delegation::new_with_targets( - pk4, - UNIX_EPOCH + Duration::new(3, 0), - vec![canister_test_id(1)], - ); + let delegation_3 = Delegation::new(pk4, UNIX_EPOCH + Duration::new(3, 0)) + .with_targets(vec![canister_test_id(1)]); // Signature of SK3 for delegation_3 let delegation_3_signature = base64::decode( "a/hTCL8yOijzFIcHdcE0uvt2dj3WQdTiMLPX+xI8mWC0wRt+CYlMoFTc6JlfBopEJDrDwdEBz1n6/S8R2A/CCQ==", @@ -308,7 +376,7 @@ fn plain_authentication_with_multiple_delegations() { UNIX_EPOCH, &MockRootOfTrustProvider::new() ), - Ok(ids) if ids == CanisterIdSet::try_from_iter(vec![canister_test_id(1)]).unwrap() + Ok(restrictions) if restrictions.targets == CanisterIdSet::try_from_iter(vec![canister_test_id(1)]).unwrap() && !restrictions.queries_only ); assert_matches!( validate_signature( @@ -434,7 +502,7 @@ fn validate_signature_webauthn() { sender_delegation: None, }; - assert_eq!( + assert_matches!( validate_signature( &sig_verifier, &message_id, @@ -442,7 +510,7 @@ fn validate_signature_webauthn() { UNIX_EPOCH, &MockRootOfTrustProvider::new() ), - Ok(CanisterIdSet::all()) + Ok(restrictions) if restrictions.targets == CanisterIdSet::all() && !restrictions.queries_only ); } @@ -467,7 +535,7 @@ fn validate_signature_webauthn_ed25519() { sender_delegation: None, }; - assert_eq!( + assert_matches!( validate_signature( &sig_verifier, &message_id, @@ -475,7 +543,7 @@ fn validate_signature_webauthn_ed25519() { UNIX_EPOCH, &MockRootOfTrustProvider::new() ), - Ok(CanisterIdSet::all()) + Ok(restrictions) if restrictions.targets == CanisterIdSet::all() && !restrictions.queries_only ); } @@ -504,7 +572,7 @@ fn validate_signature_webauthn_with_delegations() { sender_delegation: Some(vec![SignedDelegation::new(delegation, delegation_sig)]), }; - assert_eq!( + assert_matches!( validate_signature( &sig_verifier, &message_id, @@ -512,7 +580,7 @@ fn validate_signature_webauthn_with_delegations() { UNIX_EPOCH, &MockRootOfTrustProvider::new() ), - Ok(CanisterIdSet::all()) + Ok(restrictions) if restrictions.targets == CanisterIdSet::all() && !restrictions.queries_only ); } diff --git a/rs/validator/tests/ingress_validation.rs b/rs/validator/tests/ingress_validation.rs index 78fde532c0de..524754da43e5 100644 --- a/rs/validator/tests/ingress_validation.rs +++ b/rs/validator/tests/ingress_validation.rs @@ -1,6 +1,10 @@ use ic_crypto_sha2::Sha256; use ic_test_utilities_types::ids::canister_test_id; -use ic_types::{crypto::Signable, messages::Delegation, time::UNIX_EPOCH}; +use ic_types::{ + crypto::Signable, + messages::{Delegation, DelegationPermissions}, + time::UNIX_EPOCH, +}; // NOTE: Ideally, this test should be in the types crate where `Delegation` is // defined, but the test is here to avoid circular dependencies between the @@ -37,7 +41,7 @@ fn delegation_signed_bytes() { #[test] fn delegation_with_targets_signed_bytes() { - let d = Delegation::new_with_targets(vec![1, 2, 3], UNIX_EPOCH, vec![canister_test_id(1)]); + let d = Delegation::new(vec![1, 2, 3], UNIX_EPOCH).with_targets(vec![canister_test_id(1)]); let mut expected_signed_bytes = Vec::new(); expected_signed_bytes.extend_from_slice(b"\x1Aic-request-auth-delegation"); @@ -70,3 +74,38 @@ fn delegation_with_targets_signed_bytes() { assert_eq!(d.as_signed_bytes(), expected_signed_bytes); } + +#[test] +fn delegation_with_permissions_signed_bytes() { + let d = + Delegation::new(vec![1, 2, 3], UNIX_EPOCH).with_permissions(DelegationPermissions::Queries); + + let mut expected_signed_bytes = Vec::new(); + expected_signed_bytes.extend_from_slice(b"\x1Aic-request-auth-delegation"); + + // Representation-independent hash of the delegation. + let mut pubkey_hash = Vec::new(); + pubkey_hash.extend_from_slice(&Sha256::hash(b"pubkey")); + pubkey_hash.extend_from_slice(&Sha256::hash(&[1, 2, 3])); + + let mut expiration_hash = Vec::new(); + expiration_hash.extend_from_slice(&Sha256::hash(b"expiration")); + expiration_hash.extend_from_slice(&Sha256::hash(&[0])); + + let mut permissions_hash = Vec::new(); + permissions_hash.extend_from_slice(&Sha256::hash(b"permissions")); + permissions_hash.extend_from_slice(&Sha256::hash(b"queries")); + + let mut hashes: Vec> = vec![pubkey_hash, expiration_hash, permissions_hash]; + hashes.sort(); + + let mut hasher = Sha256::new(); + for hash in hashes { + hasher.write(&hash); + } + + // Concatenate domain with representation-independent hash. + expected_signed_bytes.extend_from_slice(&hasher.finish()); + + assert_eq!(d.as_signed_bytes(), expected_signed_bytes); +}