From 7b7a3c3ebca4a0587e65852a87c8ffc71ebb5093 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 16:28:03 +0000 Subject: [PATCH] feat: read-only mode via delegations with queries-only permissions Adds a "Read-only mode" checkbox to the authorize continue screen. When enabled, account delegations prepared for the session carry the new permissions field set to "queries" (covered by the delegation signature), which makes the IC reject update calls authenticated through them (see dfinity/ic#10449) while query calls remain permitted. - BE: trailing read_only opt arg on prepare_account_delegation and get_account_delegation; local delegation_signature_msg helper supporting the permissions field; Delegation candid record gains permissions : opt text. - FE: checkbox wired through authorizationStore into the ICRC-34 delegation channel handler and passed to both endpoints. - Tests: integration test asserting the permissions field, signature validity, and that the signature binds to the permissions (lookup without read_only yields NoSuchDelegation). Note: returning restricted delegations to relying parties requires @icp-sdk/core to round-trip the permissions field; until then, restricted delegations fail closed on the dapp side. --- .../src/api/internet_identity/api_v2.rs | 45 ++++++++++++++++ src/canister_tests/src/framework.rs | 8 ++- .../lib/generated/internet_identity_idl.js | 3 ++ .../generated/internet_identity_types.d.ts | 16 +++++- .../src/lib/stores/authorization.store.ts | 15 ++++-- .../lib/stores/channelHandlers/delegation.ts | 13 +++++ .../(new-styling)/authorize/+page.svelte | 7 ++- .../authorize/views/ContinueView.svelte | 40 ++++++++++++-- .../src/routes/(new-styling)/cli/utils.ts | 2 + src/internet_identity/internet_identity.did | 14 ++++- .../src/account_management.rs | 28 +++++++--- src/internet_identity/src/delegation.rs | 51 ++++++++++++++++++ src/internet_identity/src/main.rs | 7 +++ src/internet_identity/src/openid.rs | 1 + .../tests/integration/accounts.rs | 53 ++++++++++++++++++- .../src/internet_identity/types.rs | 4 ++ 16 files changed, 286 insertions(+), 21 deletions(-) diff --git a/src/canister_tests/src/api/internet_identity/api_v2.rs b/src/canister_tests/src/api/internet_identity/api_v2.rs index 3693d678d7..60490aa33a 100644 --- a/src/canister_tests/src/api/internet_identity/api_v2.rs +++ b/src/canister_tests/src/api/internet_identity/api_v2.rs @@ -465,3 +465,48 @@ pub fn get_account_delegation( ) .map(|(x,)| x) } + +pub fn prepare_account_delegation_with_read_only( + params: &AccountDelegationParams, + max_ttl: Option, + read_only: Option, +) -> Result, RejectResponse> { + call_candid_as( + params.env, + params.canister_id, + RawEffectivePrincipal::None, + params.sender, + "prepare_account_delegation", + ( + params.identity_number, + params.origin.clone(), + params.account_number, + params.session_key.clone(), + max_ttl, + read_only, + ), + ) + .map(|(x,)| x) +} + +pub fn get_account_delegation_with_read_only( + params: &AccountDelegationParams, + expiration: u64, + read_only: Option, +) -> Result, RejectResponse> { + query_candid_as( + params.env, + params.canister_id, + params.sender, + "get_account_delegation", + ( + params.identity_number, + params.origin.clone(), + params.account_number, + params.session_key.clone(), + expiration, + read_only, + ), + ) + .map(|(x,)| x) +} diff --git a/src/canister_tests/src/framework.rs b/src/canister_tests/src/framework.rs index 9659b972f7..e4ed2c9973 100644 --- a/src/canister_tests/src/framework.rs +++ b/src/canister_tests/src/framework.rs @@ -651,7 +651,7 @@ pub fn verify_delegation( // followed by the representation independent hash of a map with entries // pubkey, expiration and targets (if any), using the respective values from the delegation. // See https://internetcomputer.org/docs/current/references/ic-interface-spec#authentication for details - let key_value_pairs = vec![ + let mut key_value_pairs = vec![ ( "pubkey".to_string(), Value::Bytes(signed_delegation.delegation.pubkey.clone().into_vec()), @@ -661,6 +661,12 @@ pub fn verify_delegation( Value::Number(signed_delegation.delegation.expiration), ), ]; + if let Some(permissions) = &signed_delegation.delegation.permissions { + key_value_pairs.push(( + "permissions".to_string(), + Value::String(permissions.clone()), + )); + } let mut msg: Vec = Vec::from([(DOMAIN_SEPARATOR.len() as u8)]); msg.extend_from_slice(DOMAIN_SEPARATOR); msg.extend_from_slice( diff --git a/src/frontend/src/lib/generated/internet_identity_idl.js b/src/frontend/src/lib/generated/internet_identity_idl.js index f5b0e629b5..ac1245465a 100644 --- a/src/frontend/src/lib/generated/internet_identity_idl.js +++ b/src/frontend/src/lib/generated/internet_identity_idl.js @@ -355,6 +355,7 @@ export const idlFactory = ({ IDL }) => { 'nonce' : IDL.Text, }); const Delegation = IDL.Record({ + 'permissions' : IDL.Opt(IDL.Text), 'pubkey' : PublicKey, 'targets' : IDL.Opt(IDL.Vec(IDL.Principal)), 'expiration' : Timestamp, @@ -941,6 +942,7 @@ export const idlFactory = ({ IDL }) => { IDL.Opt(AccountNumber), SessionKey, Timestamp, + IDL.Opt(IDL.Bool), ], [ IDL.Variant({ @@ -1116,6 +1118,7 @@ export const idlFactory = ({ IDL }) => { IDL.Opt(AccountNumber), SessionKey, IDL.Opt(IDL.Nat64), + IDL.Opt(IDL.Bool), ], [ IDL.Variant({ diff --git a/src/frontend/src/lib/generated/internet_identity_types.d.ts b/src/frontend/src/lib/generated/internet_identity_types.d.ts index bbfedade2d..360c536949 100644 --- a/src/frontend/src/lib/generated/internet_identity_types.d.ts +++ b/src/frontend/src/lib/generated/internet_identity_types.d.ts @@ -375,6 +375,12 @@ export type CreateAccountError = { 'AccountLimitReached' : null } | { 'NameTooLong' : null }; export type CredentialId = Uint8Array | number[]; export interface Delegation { + /** + * Restricts the kinds of calls the delegation permits: `"queries"` + * restricts the sender to query calls (the IC rejects update calls + * authenticated through such a delegation). Absent means unrestricted. + */ + 'permissions' : [] | [string], 'pubkey' : PublicKey, 'targets' : [] | [Array], 'expiration' : Timestamp, @@ -1783,7 +1789,14 @@ export interface _SERVICE { */ 'fetch_entries' : ActorMethod<[], Array>, 'get_account_delegation' : ActorMethod< - [UserNumber, FrontendHostname, [] | [AccountNumber], SessionKey, Timestamp], + [ + UserNumber, + FrontendHostname, + [] | [AccountNumber], + SessionKey, + Timestamp, + [] | [boolean], + ], { 'Ok' : SignedDelegation } | { 'Err' : AccountDelegationError } >, @@ -1954,6 +1967,7 @@ export interface _SERVICE { [] | [AccountNumber], SessionKey, [] | [bigint], + [] | [boolean], ], { 'Ok' : PrepareAccountDelegation } | { 'Err' : AccountDelegationError } diff --git a/src/frontend/src/lib/stores/authorization.store.ts b/src/frontend/src/lib/stores/authorization.store.ts index 559d7b63f2..bdd99f81a1 100644 --- a/src/frontend/src/lib/stores/authorization.store.ts +++ b/src/frontend/src/lib/stores/authorization.store.ts @@ -12,6 +12,11 @@ export type AuthorizationContext = { export type Authorized = { accountNumberPromise: Promise; + /** Whether the user restricted this authorization to read-only access: + * attributes certified for this session will carry + * `implicit:permissions = "queries"`, which makes the IC reject update + * calls that present them as `sender_info`. */ + readOnly: boolean; }; const contextInternal = writable(); @@ -30,9 +35,13 @@ export const authorizationStore = { }, /** Called by the UI when the user authorizes with a specific account. * Accepts a promise so the animation can start immediately while the - * account number resolves asynchronously. */ - authorize: (accountNumberPromise: Promise): void => { - authorizedInternal.set({ accountNumberPromise }); + * account number resolves asynchronously. `readOnly` restricts the + * session to read-only access (see {@link Authorized.readOnly}). */ + authorize: ( + accountNumberPromise: Promise, + readOnly = false, + ): void => { + authorizedInternal.set({ accountNumberPromise, readOnly }); }, subscribe: contextInternal.subscribe, }; diff --git a/src/frontend/src/lib/stores/channelHandlers/delegation.ts b/src/frontend/src/lib/stores/channelHandlers/delegation.ts index f3d7bcf90b..23d00e75c6 100644 --- a/src/frontend/src/lib/stores/channelHandlers/delegation.ts +++ b/src/frontend/src/lib/stores/channelHandlers/delegation.ts @@ -95,6 +95,17 @@ export const handleDelegationRequest = const sessionPublicKey = new Uint8Array(params.publicKey.toDer()); + // When the user enabled "Read-only mode" during authorization, the + // delegation is restricted to query calls via its `permissions` + // field, which the IC enforces (update calls are rejected). + // + // NOTE: carrying the restricted delegation back to the relying + // party requires the agent library to round-trip the `permissions` + // field (@icp-sdk/core); until then, restricted delegations fail + // closed on the dapp side (the signature does not verify without + // the field). + const readOnly: [] | [boolean] = authorized.readOnly ? [true] : []; + const { user_key, expiration } = await actor .prepare_account_delegation( identityNumber, @@ -102,6 +113,7 @@ export const handleDelegationRequest = accountNumber !== undefined ? [accountNumber] : [], sessionPublicKey, params.maxTimeToLive !== undefined ? [params.maxTimeToLive] : [], + readOnly, ) .then(throwCanisterError); @@ -113,6 +125,7 @@ export const handleDelegationRequest = accountNumber !== undefined ? [accountNumber] : [], sessionPublicKey, expiration, + readOnly, ) .then(throwCanisterError) .then(transformSignedDelegation) diff --git a/src/frontend/src/routes/(new-styling)/authorize/+page.svelte b/src/frontend/src/routes/(new-styling)/authorize/+page.svelte index ec43ed1238..f89b256992 100644 --- a/src/frontend/src/routes/(new-styling)/authorize/+page.svelte +++ b/src/frontend/src/routes/(new-styling)/authorize/+page.svelte @@ -88,8 +88,11 @@ lastUsedIdentitiesStore.selectIdentity(identityNumber); return Promise.resolve(); }; - const handleAuthorize = (accountNumber: Promise) => { - authorizationStore.authorize(accountNumber); + const handleAuthorize = ( + accountNumber: Promise, + readOnly = false, + ) => { + authorizationStore.authorize(accountNumber, readOnly); }; const handleAttributeConsent = (consent: AttributeConsent) => { diff --git a/src/frontend/src/routes/(new-styling)/authorize/views/ContinueView.svelte b/src/frontend/src/routes/(new-styling)/authorize/views/ContinueView.svelte index 0c2fdef9ec..61123d65f2 100644 --- a/src/frontend/src/routes/(new-styling)/authorize/views/ContinueView.svelte +++ b/src/frontend/src/routes/(new-styling)/authorize/views/ContinueView.svelte @@ -25,14 +25,22 @@ } from "$lib/generated/internet_identity_types"; import Badge from "$lib/components/ui/Badge.svelte"; import { slide, fade, scale } from "svelte/transition"; + import Checkbox from "$lib/components/ui/Checkbox.svelte"; import Dialog from "$lib/components/ui/Dialog.svelte"; import EditAccount from "$lib/components/views/EditAccount.svelte"; import ProgressRing from "$lib/components/ui/ProgressRing.svelte"; interface Props { effectiveOrigin: string; - /** Called when the user confirms with the default account or selects a specific account. */ - onAuthorize: (accountNumber: Promise) => void; + /** Called when the user confirms with the default account or selects a + * specific account. `readOnly` reflects the "Read-only mode" checkbox: + * when `true`, attributes certified for this session restrict the app + * to query calls (it can read on the user's behalf but cannot change + * state). */ + onAuthorize: ( + accountNumber: Promise, + readOnly?: boolean, + ) => void; } const { effectiveOrigin, onAuthorize }: Props = $props(); @@ -53,6 +61,7 @@ } }); + let isReadOnlyMode = $state(false); let isCreateAccountDialogVisible = $state(false); let isEditAccountDialogVisibleForNumber = $state< AccountNumber | PRIMARY_ACCOUNT_NUMBER | null @@ -106,7 +115,7 @@ .then(throwCanisterError) .then((account) => account.account_number[0]) : Promise.resolve(defaultAccountNumber); - onAuthorize(accountNumberPromise); + onAuthorize(accountNumberPromise, isReadOnlyMode); } catch (error) { handleError(error); } finally { @@ -116,7 +125,7 @@ const handleContinueAs = ( accountNumber: AccountNumber | PRIMARY_ACCOUNT_NUMBER, ) => { - onAuthorize(Promise.resolve(accountNumber)); + onAuthorize(Promise.resolve(accountNumber), isReadOnlyMode); }; const handleEnableMultipleAccounts = async () => { try { @@ -403,6 +412,29 @@ +
+ + + + +
{#if authLastUsedFlow.systemOverlay} diff --git a/src/frontend/src/routes/(new-styling)/cli/utils.ts b/src/frontend/src/routes/(new-styling)/cli/utils.ts index 7c6e328ff3..a0710c1b10 100644 --- a/src/frontend/src/routes/(new-styling)/cli/utils.ts +++ b/src/frontend/src/routes/(new-styling)/cli/utils.ts @@ -76,6 +76,7 @@ export const cliAuthorize = async ({ [], ephemeralPublicKey, [maxTimeToLiveNanos], + [], ) .then(throwCanisterError); @@ -87,6 +88,7 @@ export const cliAuthorize = async ({ [], ephemeralPublicKey, expiration, + [], ) .then(throwCanisterError) .then(transformSignedDelegation) diff --git a/src/internet_identity/internet_identity.did b/src/internet_identity/internet_identity.did index 2131006c1a..da57087539 100644 --- a/src/internet_identity/internet_identity.did +++ b/src/internet_identity/internet_identity.did @@ -157,6 +157,10 @@ type Delegation = record { pubkey : PublicKey; expiration : Timestamp; targets : opt vec principal; + // Restricts the kinds of calls the delegation permits: `"queries"` + // restricts the sender to query calls (the IC rejects update calls + // authenticated through such a delegation). Absent means unrestricted. + permissions : opt text; }; type SignedDelegation = record { @@ -1644,7 +1648,11 @@ service : (opt InternetIdentityInit) -> { origin : FrontendHostname, account_number : opt AccountNumber, // Null is unreserved default account session_key : SessionKey, - max_ttl : opt nat64 + max_ttl : opt nat64, + // When `opt true`, the prepared delegation restricts the session to + // query calls: it carries `permissions = "queries"`, which makes the + // IC reject update calls authenticated through it. + read_only : opt bool ) -> (variant { Ok : PrepareAccountDelegation; Err : AccountDelegationError }); get_account_delegation : ( @@ -1652,7 +1660,9 @@ service : (opt InternetIdentityInit) -> { origin : FrontendHostname, account_number : opt AccountNumber, // Null is unreserved default account session_key : SessionKey, - expiration : Timestamp + expiration : Timestamp, + // Must match the value passed to `prepare_account_delegation`. + read_only : opt bool ) -> (variant { Ok : SignedDelegation; Err : AccountDelegationError }) query; get_default_account : ( diff --git a/src/internet_identity/src/account_management.rs b/src/internet_identity/src/account_management.rs index ff673e4cff..7dd305b57f 100644 --- a/src/internet_identity/src/account_management.rs +++ b/src/internet_identity/src/account_management.rs @@ -2,8 +2,9 @@ use crate::anchor_management::post_operation_bookkeeping; use crate::{ delegation::{ - add_delegation_signature, check_frontend_length, delegation_bookkeeping, - der_encode_canister_sig_key, + add_delegation_signature_with_permissions, check_frontend_length, delegation_bookkeeping, + delegation_signature_msg_with_permissions, der_encode_canister_sig_key, + DELEGATION_PERMISSIONS_QUERIES, }, ii_domain::IIDomain, state::{self, storage_borrow, storage_borrow_mut}, @@ -17,9 +18,7 @@ use crate::{ }, update_root_hash, }; -use ic_canister_sig_creation::{ - delegation_signature_msg, signature_map::CanisterSigInputs, DELEGATION_SIG_DOMAIN, -}; +use ic_canister_sig_creation::{signature_map::CanisterSigInputs, DELEGATION_SIG_DOMAIN}; use ic_cdk::{api::time, caller}; use ic_stable_structures::DefaultMemoryImpl; use internet_identity_interface::{ @@ -302,6 +301,7 @@ pub async fn prepare_account_delegation( account_number: Option, session_key: SessionKey, max_ttl: Option, + read_only: bool, ii_domain: &Option, ) -> Result { state::ensure_salt_set().await; @@ -326,7 +326,13 @@ pub async fn prepare_account_delegation( let seed = account.calculate_seed(); state::signature_map_mut(|sigs| { - add_delegation_signature(sigs, session_key, seed.as_ref(), expiration); + add_delegation_signature_with_permissions( + sigs, + session_key, + seed.as_ref(), + expiration, + read_only, + ); }); update_root_hash(); @@ -349,6 +355,7 @@ pub fn get_account_delegation( account_number: Option, session_key: SessionKey, expiration: Timestamp, + read_only: bool, ) -> Result { check_frontend_length(origin); @@ -363,10 +370,16 @@ pub fn get_account_delegation( .ok_or(AccountDelegationError::Unauthorized(caller()))?; state::assets_and_signatures(|certified_assets, sigs| { + let permissions = read_only.then_some(DELEGATION_PERMISSIONS_QUERIES); let inputs = CanisterSigInputs { domain: DELEGATION_SIG_DOMAIN, seed: &account.calculate_seed(), - message: &delegation_signature_msg(&session_key, expiration, None), + message: &delegation_signature_msg_with_permissions( + &session_key, + expiration, + None, + permissions, + ), }; match sigs.get_signature_as_cbor(&inputs, Some(certified_assets.root_hash())) { Ok(signature) => Ok(SignedDelegation { @@ -374,6 +387,7 @@ pub fn get_account_delegation( pubkey: session_key, expiration, targets: None, + permissions: permissions.map(str::to_string), }, signature: ByteBuf::from(signature), }), diff --git a/src/internet_identity/src/delegation.rs b/src/internet_identity/src/delegation.rs index 7d12bfe8ee..1c302ca125 100644 --- a/src/internet_identity/src/delegation.rs +++ b/src/internet_identity/src/delegation.rs @@ -143,6 +143,57 @@ pub fn add_delegation_signature( sigs.add_signature(&inputs); } +/// The value of a delegation's `permissions` field that restricts the +/// sender to query calls: the IC rejects update calls authenticated +/// through such a delegation. +pub const DELEGATION_PERMISSIONS_QUERIES: &str = "queries"; + +/// Like `ic_canister_sig_creation::delegation_signature_msg`, but +/// additionally supports the delegation's optional `permissions` field +/// (see the IC interface specification). Produces the identical message +/// when `permissions` is `None`. +pub fn delegation_signature_msg_with_permissions( + pubkey: &[u8], + expiration: Timestamp, + targets: Option<&Vec>>, + permissions: Option<&str>, +) -> Vec { + use ic_representation_independent_hash::{representation_independent_hash, Value}; + + let mut m: Vec<(String, Value)> = vec![ + ("pubkey".into(), Value::Bytes(pubkey.to_vec())), + ("expiration".into(), Value::Number(expiration)), + ]; + if let Some(targets) = targets { + m.push(( + "targets".into(), + Value::Array(targets.iter().map(|t| Value::Bytes(t.to_vec())).collect()), + )); + } + if let Some(permissions) = permissions { + m.push(("permissions".into(), Value::String(permissions.to_string()))); + } + representation_independent_hash(m.as_slice()).to_vec() +} + +/// Like [`add_delegation_signature`], but restricts the delegation to the +/// given permissions when `read_only` is `true`. +pub fn add_delegation_signature_with_permissions( + sigs: &mut SignatureMap, + pk: PublicKey, + seed: &[u8], + expiration: Timestamp, + read_only: bool, +) { + let permissions = read_only.then_some(DELEGATION_PERMISSIONS_QUERIES); + let inputs = CanisterSigInputs { + domain: DELEGATION_SIG_DOMAIN, + seed, + message: &delegation_signature_msg_with_permissions(&pk, expiration, None, permissions), + }; + sigs.add_signature(&inputs); +} + pub(crate) fn check_frontend_length(frontend: &FrontendHostname) { const FRONTEND_HOSTNAME_LIMIT: usize = 255; diff --git a/src/internet_identity/src/main.rs b/src/internet_identity/src/main.rs index c75d656c31..b75c989548 100644 --- a/src/internet_identity/src/main.rs +++ b/src/internet_identity/src/main.rs @@ -445,6 +445,7 @@ async fn prepare_delegation( None, session_key, max_time_to_live, + false, &ii_domain, ) .await @@ -473,6 +474,7 @@ fn get_delegation( None, session_key, expiration, + false, ) .map(GetDelegationResponse::SignedDelegation) .unwrap_or(GetDelegationResponse::NoSuchDelegation) @@ -580,6 +582,7 @@ async fn prepare_account_delegation( account_number: Option, session_key: SessionKey, max_ttl: Option, + read_only: Option, ) -> Result { match check_authz_and_record_activity(anchor_number) { Ok(ii_domain) => { @@ -589,6 +592,7 @@ async fn prepare_account_delegation( account_number, session_key, max_ttl, + read_only.unwrap_or(false), &ii_domain, ) .await @@ -604,6 +608,7 @@ fn get_account_delegation( account_number: Option, session_key: SessionKey, expiration: Timestamp, + read_only: Option, ) -> Result { match check_authorization(anchor_number) { Ok(_) => account_management::get_account_delegation( @@ -612,6 +617,7 @@ fn get_account_delegation( account_number, session_key, expiration, + read_only.unwrap_or(false), ), Err(err) => Err(err.into()), } @@ -1696,6 +1702,7 @@ mod email_recovery_api { pubkey: args.session_key, expiration: args.expiration, targets: None, + permissions: None, }, signature: serde_bytes::ByteBuf::from(signature), }) diff --git a/src/internet_identity/src/openid.rs b/src/internet_identity/src/openid.rs index 56b1812dd7..532b6841c0 100644 --- a/src/internet_identity/src/openid.rs +++ b/src/internet_identity/src/openid.rs @@ -149,6 +149,7 @@ impl OpenIdCredential { pubkey: session_key, expiration, targets: None, + permissions: None, }, signature: ByteBuf::from(signature), }), diff --git a/src/internet_identity/tests/integration/accounts.rs b/src/internet_identity/tests/integration/accounts.rs index 62cb7aaf36..32e69b3739 100644 --- a/src/internet_identity/tests/integration/accounts.rs +++ b/src/internet_identity/tests/integration/accounts.rs @@ -1,7 +1,8 @@ use canister_tests::{ api::internet_identity::{ api_v2::{ - create_account, get_account_delegation, get_accounts, prepare_account_delegation, + create_account, get_account_delegation, get_account_delegation_with_read_only, + get_accounts, prepare_account_delegation, prepare_account_delegation_with_read_only, update_account, AccountDelegationParams, }, get_delegation, prepare_delegation, @@ -507,6 +508,56 @@ fn should_only_update_owned_account() { .unwrap(); } +/// Verifies that read-only account delegations carry the `permissions` +/// field restricting the session to query calls, and that the signature +/// binds to it (the same session key without `read_only` has no signature). +#[test] +fn should_get_read_only_account_delegation_with_queries_permissions() -> Result<(), RejectResponse> +{ + let env = env(); + let canister_id = install_ii_with_archive(&env, None, None); + let user_number = flows::register_anchor(&env, canister_id); + let frontend_hostname = "https://some-dapp.com".to_string(); + let pub_session_key = ByteBuf::from("session public key"); + + let params = AccountDelegationParams::new( + &env, + canister_id, + principal_1(), + user_number, + frontend_hostname.clone(), + None, + pub_session_key.clone(), + ); + + let PrepareAccountDelegation { + user_key, + expiration, + } = prepare_account_delegation_with_read_only(¶ms, None, Some(true)) + .unwrap() + .unwrap(); + + let signed_delegation = get_account_delegation_with_read_only(¶ms, expiration, Some(true)) + .unwrap() + .unwrap(); + + assert_eq!( + signed_delegation.delegation.permissions, + Some("queries".to_string()) + ); + assert_eq!(signed_delegation.delegation.pubkey, pub_session_key); + verify_delegation(&env, user_key, &signed_delegation, &env.root_key().unwrap()); + + // The signature binds to the permissions: looking up an unrestricted + // delegation for the same session key must fail. + let result = get_account_delegation_with_read_only(¶ms, expiration, None) + .unwrap() + .unwrap_err(); + assert!(matches!(result, AccountDelegationError::NoSuchDelegation)); + + Ok(()) +} + /// Verifies that valid account delegations are issued. #[test] fn should_get_valid_account_delegation() -> Result<(), RejectResponse> { diff --git a/src/internet_identity_interface/src/internet_identity/types.rs b/src/internet_identity_interface/src/internet_identity/types.rs index 20340a2589..0485fb731b 100644 --- a/src/internet_identity_interface/src/internet_identity/types.rs +++ b/src/internet_identity_interface/src/internet_identity/types.rs @@ -140,6 +140,10 @@ pub struct Delegation { pub pubkey: PublicKey, pub expiration: Timestamp, pub targets: Option>, + /// Restricts the kinds of calls the delegation permits: `"queries"` + /// restricts the sender to query calls (the IC rejects update calls + /// authenticated through such a delegation). `None` means unrestricted. + pub permissions: Option, } #[derive(Clone, Debug, CandidType, Deserialize)]