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)]