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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions rs/types/types/src/messages/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -544,6 +544,7 @@ pub struct Delegation {
pubkey: Blob,
expiration: Time,
targets: Option<Vec<Blob>>,
permissions: Option<String>,
}

impl Delegation {
Expand All @@ -552,6 +553,7 @@ impl Delegation {
pubkey: Blob(pubkey),
expiration,
targets: None,
permissions: None,
}
}

Expand All @@ -560,6 +562,16 @@ impl Delegation {
pubkey: Blob(pubkey),
expiration,
targets: Some(targets.iter().map(|c| Blob(c.get().to_vec())).collect()),
permissions: None,
}
}

pub fn new_with_permissions(pubkey: Vec<u8>, expiration: Time, permissions: String) -> Self {
Self {
pubkey: Blob(pubkey),
expiration,
targets: None,
permissions: Some(permissions),
}
}

Expand Down Expand Up @@ -590,6 +602,12 @@ impl Delegation {
pub fn number_of_targets(&self) -> Option<usize> {
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<&str> {
self.permissions.as_deref()
}
}

impl SignedBytesWithoutDomainSeparator for Delegation {
Expand All @@ -606,6 +624,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));
}

bytes.extend_from_slice(&hash_of_map(&map, |key, value| hash_key_val(key, value)));
}
Expand Down
23 changes: 23 additions & 0 deletions rs/types/types/src/messages/http/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ mod targets {
invalid_canister_id,
to_blob(CanisterId::from(3)),
]),
permissions: None,
};

let targets = delegation.targets();
Expand All @@ -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),
Expand Down Expand Up @@ -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,
}),
);

Expand All @@ -927,11 +931,28 @@ 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("queries".to_string()),
},
Value::Map(btreemap! {
text("pubkey") => bytes(&[1, 2, 3]),
text("expiration") => int(0),
text("targets") => Value::Null,
text("permissions") => text("queries"),
}),
);
}
Expand All @@ -944,6 +965,7 @@ mod cbor_serialization {
pubkey: Blob(vec![1, 2, 3]),
expiration: UNIX_EPOCH,
targets: None,
permissions: None,
},
signature: Blob(vec![4, 5, 6]),
},
Expand All @@ -952,6 +974,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]),
}),
Expand Down
30 changes: 30 additions & 0 deletions rs/validator/http_request_test_utils/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,23 @@ impl DirectAuthenticationScheme {
let signature = self.sign(&delegation);
SignedDelegation::new(delegation, signature)
}

/// Creates a delegation that restricts the kinds of calls the delegate
/// may make (e.g., `"queries"`).
fn delegate_to_with_permissions(
&self,
other: &DirectAuthenticationScheme,
expiration: Time,
permissions: &str,
) -> SignedDelegation {
let delegation = Delegation::new_with_permissions(
other.public_key_der(),
expiration,
permissions.to_string(),
);
let signature = self.sign(&delegation);
SignedDelegation::new(delegation, signature)
}
}

#[derive(Clone, Eq, PartialEq, Debug)]
Expand Down Expand Up @@ -595,6 +612,19 @@ impl DelegationChainBuilder {
self
}

pub fn delegate_to_with_permissions(
mut self,
new_end: DirectAuthenticationScheme,
expiration: Time,
permissions: &str,
) -> 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<F: FnOnce(SignedDelegationBuilder) -> SignedDelegationBuilder>(
mut self,
change: F,
Expand Down
6 changes: 6 additions & 0 deletions rs/validator/ingress_message/src/internal/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -233,6 +236,9 @@ fn to_authentication_lib_error(error: ic_validator::AuthenticationError) -> Auth
ic_validator::AuthenticationError::DelegationContainsCyclesError { public_key } => {
AuthenticationError::DelegationContainsCyclesError { public_key }
}
ic_validator::AuthenticationError::UnsupportedDelegationPermissions(permissions) => {
AuthenticationError::UnsupportedDelegationPermissions(permissions)
}
}
}

Expand Down
16 changes: 16 additions & 0 deletions rs/validator/ingress_message/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 an
/// update call but a delegation restricts the sender to query calls.
pub trait HttpRequestVerifier<C> {
fn validate_request(&self, request: &HttpRequest<C>) -> Result<(), RequestValidationError>;
}
Expand All @@ -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 {
Expand Down Expand Up @@ -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\")"
)
}
Comment thread
aterga marked this conversation as resolved.
}
}
}
Expand Down Expand Up @@ -142,6 +152,9 @@ pub enum AuthenticationError {
/// which was already encountered before in the chain of delegations.
/// Note that if both keys are equal, then this delegation is self-signed, which is also forbidden.
DelegationContainsCyclesError { public_key: Vec<u8> },

/// A delegation's `permissions` field holds an unsupported value.
UnsupportedDelegationPermissions(String),
}

impl Display for AuthenticationError {
Expand All @@ -165,6 +178,9 @@ impl Display for AuthenticationError {
"Chain of delegations contains at least one cycle: first repeating public key encountered {}",
hex::encode(public_key)
),
AuthenticationError::UnsupportedDelegationPermissions(permissions) => {
write!(f, "Unsupported delegation permissions: {permissions}")
}
}
}
}
131 changes: 131 additions & 0 deletions rs/validator/ingress_message/tests/validate_request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2175,6 +2175,137 @@ mod sender_info {
}
}

mod delegation_permissions {
use super::*;
use ic_validator_http_request_test_utils::DelegationChain;
use ic_validator_ingress_message::AuthenticationError::UnsupportedDelegationPermissions;

#[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, "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, "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_updates() {
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, "updates")
.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_all_request_types_when_delegation_permissions_unsupported() {
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, "writes")
.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::InvalidDelegation(
UnsupportedDelegationPermissions(permissions)
)) if permissions == "writes"
);

let query = HttpRequestBuilder::new_query()
.with_ingress_expiry_at(CURRENT_TIME)
.with_authentication(AuthenticationScheme::Delegation(chain.clone()))
.build();
assert_matches!(
verifier.validate_request(&query),
Err(RequestValidationError::InvalidDelegation(
UnsupportedDelegationPermissions(_)
))
);

let read_state = HttpRequestBuilder::new_read_state()
.with_ingress_expiry_at(CURRENT_TIME)
.with_authentication(AuthenticationScheme::Delegation(chain))
.build();
assert_matches!(
verifier.validate_request(&read_state),
Err(RequestValidationError::InvalidDelegation(
UnsupportedDelegationPermissions(_)
))
);
}

#[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, "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(()));
}
}

fn default_verifier() -> IngressMessageVerifierBuilder {
IngressMessageVerifier::builder().with_time_provider(TimeProvider::Constant(CURRENT_TIME))
}
Expand Down
Loading
Loading