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
45 changes: 45 additions & 0 deletions src/canister_tests/src/api/internet_identity/api_v2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u64>,
read_only: Option<bool>,
) -> Result<Result<PrepareAccountDelegation, AccountDelegationError>, 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<bool>,
) -> Result<Result<SignedDelegation, AccountDelegationError>, 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)
}
8 changes: 7 additions & 1 deletion src/canister_tests/src/framework.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
Expand All @@ -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<u8> = Vec::from([(DOMAIN_SEPARATOR.len() as u8)]);
msg.extend_from_slice(DOMAIN_SEPARATOR);
msg.extend_from_slice(
Expand Down
3 changes: 3 additions & 0 deletions src/frontend/src/lib/generated/internet_identity_idl.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -941,6 +942,7 @@ export const idlFactory = ({ IDL }) => {
IDL.Opt(AccountNumber),
SessionKey,
Timestamp,
IDL.Opt(IDL.Bool),
],
[
IDL.Variant({
Expand Down Expand Up @@ -1116,6 +1118,7 @@ export const idlFactory = ({ IDL }) => {
IDL.Opt(AccountNumber),
SessionKey,
IDL.Opt(IDL.Nat64),
IDL.Opt(IDL.Bool),
],
[
IDL.Variant({
Expand Down
16 changes: 15 additions & 1 deletion src/frontend/src/lib/generated/internet_identity_types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Principal>],
'expiration' : Timestamp,
Expand Down Expand Up @@ -1783,7 +1789,14 @@ export interface _SERVICE {
*/
'fetch_entries' : ActorMethod<[], Array<BufferedArchiveEntry>>,
'get_account_delegation' : ActorMethod<
[UserNumber, FrontendHostname, [] | [AccountNumber], SessionKey, Timestamp],
[
UserNumber,
FrontendHostname,
[] | [AccountNumber],
SessionKey,
Timestamp,
[] | [boolean],
],
{ 'Ok' : SignedDelegation } |
{ 'Err' : AccountDelegationError }
>,
Expand Down Expand Up @@ -1954,6 +1967,7 @@ export interface _SERVICE {
[] | [AccountNumber],
SessionKey,
[] | [bigint],
[] | [boolean],
],
{ 'Ok' : PrepareAccountDelegation } |
{ 'Err' : AccountDelegationError }
Expand Down
15 changes: 12 additions & 3 deletions src/frontend/src/lib/stores/authorization.store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ export type AuthorizationContext = {

export type Authorized = {
accountNumberPromise: Promise<bigint | undefined>;
/** 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<AuthorizationContext | undefined>();
Expand All @@ -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<bigint | undefined>): void => {
authorizedInternal.set({ accountNumberPromise });
* account number resolves asynchronously. `readOnly` restricts the
* session to read-only access (see {@link Authorized.readOnly}). */
authorize: (
accountNumberPromise: Promise<bigint | undefined>,
readOnly = false,
): void => {
authorizedInternal.set({ accountNumberPromise, readOnly });
},
subscribe: contextInternal.subscribe,
};
Expand Down
13 changes: 13 additions & 0 deletions src/frontend/src/lib/stores/channelHandlers/delegation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,13 +95,25 @@ 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,
effectiveOrigin,
accountNumber !== undefined ? [accountNumber] : [],
sessionPublicKey,
params.maxTimeToLive !== undefined ? [params.maxTimeToLive] : [],
readOnly,
)
.then(throwCanisterError);

Expand All @@ -113,6 +125,7 @@ export const handleDelegationRequest =
accountNumber !== undefined ? [accountNumber] : [],
sessionPublicKey,
expiration,
readOnly,
)
.then(throwCanisterError)
.then(transformSignedDelegation)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,11 @@
lastUsedIdentitiesStore.selectIdentity(identityNumber);
return Promise.resolve();
};
const handleAuthorize = (accountNumber: Promise<bigint | undefined>) => {
authorizationStore.authorize(accountNumber);
const handleAuthorize = (
accountNumber: Promise<bigint | undefined>,
readOnly = false,
) => {
authorizationStore.authorize(accountNumber, readOnly);
};

const handleAttributeConsent = (consent: AttributeConsent) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<bigint | undefined>) => 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<bigint | undefined>,
readOnly?: boolean,
) => void;
}

const { effectiveOrigin, onAuthorize }: Props = $props();
Expand All @@ -53,6 +61,7 @@
}
});

let isReadOnlyMode = $state(false);
let isCreateAccountDialogVisible = $state(false);
let isEditAccountDialogVisibleForNumber = $state<
AccountNumber | PRIMARY_ACCOUNT_NUMBER | null
Expand Down Expand Up @@ -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 {
Expand All @@ -116,7 +125,7 @@
const handleContinueAs = (
accountNumber: AccountNumber | PRIMARY_ACCOUNT_NUMBER,
) => {
onAuthorize(Promise.resolve(accountNumber));
onAuthorize(Promise.resolve(accountNumber), isReadOnlyMode);
};
const handleEnableMultipleAccounts = async () => {
try {
Expand Down Expand Up @@ -403,6 +412,29 @@
</button>
</Tooltip>
</div>
<div class="mt-4 flex flex-row items-center">
<Checkbox
bind:checked={isReadOnlyMode}
label={$t`Read-only mode`}
size="sm"
disabled={isAuthenticatingDefault}
/>
<Tooltip
label={$t`Read-only mode`}
description={$t`When enabled, the app can read data on your behalf but cannot make any changes: calls that would change state are rejected. Only apps that support certified session attributes enforce this restriction.`}
direction="up"
align="end"
offset="0rem"
class="max-w-80"
>
<button
class="btn btn-tertiary btn-sm btn-icon ms-auto !cursor-default !rounded-full"
aria-label={$t`More information about read-only mode`}
>
<HelpCircleIcon class="size-5" />
</button>
</Tooltip>
</div>
</div>

{#if authLastUsedFlow.systemOverlay}
Expand Down
2 changes: 2 additions & 0 deletions src/frontend/src/routes/(new-styling)/cli/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ export const cliAuthorize = async ({
[],
ephemeralPublicKey,
[maxTimeToLiveNanos],
[],
)
.then(throwCanisterError);

Expand All @@ -87,6 +88,7 @@ export const cliAuthorize = async ({
[],
ephemeralPublicKey,
expiration,
[],
)
.then(throwCanisterError)
.then(transformSignedDelegation)
Expand Down
14 changes: 12 additions & 2 deletions src/internet_identity/internet_identity.did
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -1644,15 +1648,21 @@ 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 : (
anchor_number : UserNumber,
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 : (
Expand Down
Loading
Loading