From e0fdc2ca6662acb558899b04d70e8a31c413962d Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sat, 22 Aug 2026 21:43:22 +0200 Subject: [PATCH 01/14] feat(fe): hand apps a session to re-issue their own delegations from MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ii_session_delegation` is what an app calls instead of `icrc34_delegation` when it wants a session rather than a long-lived delegation. It answers with the session chain, extended to the app's own key, and the app mints five-minute delegations from it with no further browser involvement. The chain the app receives has `targets` restricted to the II canister. That is a developer guardrail, not a defence against a thief, who can refresh with it either way: revocability is the protection that matters. The consent duration is honoured — `valid_for` carries the lifetime the user chose, clamped by the canister — and an SSO organization's own cap still binds it, as it already does on the ICRC-34 path. The request carries only a session public key and an optional derivation origin. An app cannot ask for an access level or a lifetime, because both are the user's to decide at consent. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/lib/stores/channelHandlers/icrc25.ts | 5 +- .../channelHandlers/sessionDelegation.test.ts | 92 ++++++ .../channelHandlers/sessionDelegation.ts | 272 ++++++++++++++++++ src/frontend/src/lib/stores/channelStore.ts | 5 + 4 files changed, 373 insertions(+), 1 deletion(-) create mode 100644 src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts create mode 100644 src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts diff --git a/src/frontend/src/lib/stores/channelHandlers/icrc25.ts b/src/frontend/src/lib/stores/channelHandlers/icrc25.ts index 61314a54bc..ce50bb8c75 100644 --- a/src/frontend/src/lib/stores/channelHandlers/icrc25.ts +++ b/src/frontend/src/lib/stores/channelHandlers/icrc25.ts @@ -23,7 +23,10 @@ const supportedStandards = [ }, ]; -const scopes = [{ method: "icrc34_delegation" }]; +const scopes = [ + { method: "icrc34_delegation" }, + { method: "ii_session_delegation" }, +]; /** ICRC-25: respond with the list of supported standards. */ export const handleSupportedStandards = diff --git a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts new file mode 100644 index 0000000000..f6f1da0b26 --- /dev/null +++ b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts @@ -0,0 +1,92 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import "fake-indexeddb/auto"; + +const ORIGIN = "https://app.example.com"; + +vi.mock("$lib/globals", async () => { + const { Principal } = await import("@icp-sdk/core/principal"); + return { + canisterId: Principal.fromText("rwlgt-iiaaa-aaaaa-aaaaa-cai"), + backendCanisterConfig: { openid_configs: [] }, + frontendCanisterConfig: { related_origins: [], dev_csp: [] }, + }; +}); +vi.mock("$lib/utils/validateDerivationOrigin", () => ({ + validateDerivationOrigin: vi.fn(() => Promise.resolve({ result: "valid" })), +})); +vi.mock("$lib/utils/iiConnection", () => ({ + remapToLegacyDomain: (origin: string) => origin, +})); + +const setRequestContext = vi.fn(); +vi.mock("$lib/stores/authorization.store", () => ({ + authorizationStore: { + setRequestContext: (...args: unknown[]) => setRequestContext(...args), + }, + authorizedStore: { subscribe: () => () => {} }, +})); + +import { handleSessionDelegationRequest } from "./sessionDelegation"; +import { purgeAppSessions } from "$lib/stores/app-session.store"; + +const channelWith = () => { + const sent: unknown[] = []; + return { + channel: { + origin: ORIGIN, + closed: false, + resumeToken: "token", + addEventListener: () => () => {}, + send: (response: unknown) => { + sent.push(response); + return Promise.resolve(); + }, + close: async () => {}, + }, + sent, + }; +}; + +describe("ii_session_delegation", () => { + beforeEach(async () => { + setRequestContext.mockClear(); + await purgeAppSessions(BigInt(10_000)); + await purgeAppSessions(BigInt(10_001)); + }); + + it("ignores a request for another method", async () => { + const { channel, sent } = channelWith(); + const onError = vi.fn(); + + await handleSessionDelegationRequest( + channel, + onError, + )({ + jsonrpc: "2.0", + id: 1, + method: "icrc34_delegation", + }); + + expect(sent).toEqual([]); + expect(onError).not.toHaveBeenCalled(); + }); + + it("rejects params that carry no session key", async () => { + const { channel, sent } = channelWith(); + const onError = vi.fn(); + + await handleSessionDelegationRequest( + channel, + onError, + )({ + jsonrpc: "2.0", + id: 1, + method: "ii_session_delegation", + params: {}, + }); + + expect(sent).toHaveLength(1); + expect(sent[0]).toMatchObject({ id: 1, error: { code: -32602 } }); + expect(onError).toHaveBeenCalledWith("invalid-request"); + }); +}); diff --git a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts new file mode 100644 index 0000000000..73dca34d25 --- /dev/null +++ b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts @@ -0,0 +1,272 @@ +import type { Channel, JsonRequest } from "$lib/utils/transport/utils"; +import { + Base64ToBytesCodec, + Base64ToPublicKeyCodec, + INVALID_PARAMS_ERROR_CODE, + OriginSchema, + StringToBigIntCodec, +} from "$lib/utils/transport/utils"; +import { + authorizationStore, + authorizedStore, +} from "$lib/stores/authorization.store"; +import { authenticationStore } from "$lib/stores/authentication.store"; +import { + storeAppSession, + type AppSessionRecord, +} from "$lib/stores/app-session.store"; +import { validateDerivationOrigin } from "$lib/utils/validateDerivationOrigin"; +import { remapToLegacyDomain } from "$lib/utils/iiConnection"; +import { toPermissionsArg } from "$lib/utils/accessLevel"; +import { retryFor, throwCanisterError, waitForStore } from "$lib/utils/utils"; +import { canisterId } from "$lib/globals"; +import { Principal } from "@icp-sdk/core/principal"; +import { + Delegation, + DelegationChain, + ECDSAKeyIdentity, +} from "@icp-sdk/core/identity"; +import type { PublicKey, Signature } from "@icp-sdk/core/agent"; +import { serializeAuthorizationRequest } from "$lib/stores/channelHandlers/serialize"; +import { withBrowserProof } from "$lib/stores/browser-key.store"; +import { describeBrowser } from "$lib/stores/channelHandlers/describeBrowser"; +import { z } from "zod"; +import type { ChannelError } from "$lib/stores/channelStore"; + +export const SESSION_DELEGATION_METHOD = "ii_session_delegation"; + +const SessionParamsCodec = z.object({ + sessionPublicKey: Base64ToPublicKeyCodec, + icrc95DerivationOrigin: z.optional(OriginSchema), +}); + +/** + * Unlike the ICRC-34 result, this one carries `targets`. The session chain is restricted + * to the II canister, so an app that reaches for it where it meant its app delegation + * fails immediately and visibly instead of appearing to work. + */ +const SessionResultSchema = z.codec( + z.object({ + publicKey: z.base64(), + signerDelegation: z.array( + z.object({ + delegation: z.object({ + pubkey: z.base64(), + expiration: z.string(), + targets: z.optional(z.array(z.string())), + }), + signature: z.base64(), + }), + ), + }), + z.custom<{ + chain: DelegationChain; + }>(), + { + decode: ({ publicKey, signerDelegation }) => ({ + chain: DelegationChain.fromDelegations( + signerDelegation.map( + ({ delegation: { pubkey, expiration, targets }, signature }) => ({ + delegation: new Delegation( + Base64ToBytesCodec.decode(pubkey), + StringToBigIntCodec.decode(expiration), + targets?.map((target) => Principal.fromText(target)), + ), + signature: Base64ToBytesCodec.decode(signature) as Signature, + }), + ), + Base64ToBytesCodec.decode(publicKey), + ), + }), + encode: ({ chain }) => ({ + publicKey: Base64ToBytesCodec.encode( + new Uint8Array(chain.publicKey) as Uint8Array, + ), + signerDelegation: chain.delegations.map((signed) => ({ + delegation: { + pubkey: Base64ToBytesCodec.encode( + new Uint8Array(signed.delegation.pubkey) as Uint8Array, + ), + expiration: signed.delegation.expiration.toString(), + targets: signed.delegation.targets?.map((target) => target.toText()), + }, + signature: Base64ToBytesCodec.encode( + new Uint8Array(signed.signature) as Uint8Array, + ), + })), + }), + }, +); + +const extendToApp = async ( + record: AppSessionRecord, + appPublicKey: PublicKey, +): Promise => + DelegationChain.create( + await ECDSAKeyIdentity.fromKeyPair(record.keyPair), + appPublicKey, + new Date(record.expiresAtMillis), + { + previous: DelegationChain.fromJSON(JSON.parse(record.chainJson)), + targets: [Principal.from(canisterId)], + }, + ); + +/** + * Obtains the session an app re-issues its own delegations from. + * + * The response carries a session and nothing else: the app mints its first app + * delegation through `app_prepare_delegation`, the same call it uses for every + * subsequent one, so `icrc34_delegation` keeps behaving exactly as it does today and an + * app that cannot refresh simply never calls this. + */ +export const handleSessionDelegationRequest = + (channel: Channel, onError: (error: ChannelError) => void) => + async (request: JsonRequest) => { + if ( + request.id === undefined || + request.method !== SESSION_DELEGATION_METHOD + ) { + return; + } + const requestId = request.id; + + const parsed = SessionParamsCodec.safeParse(request.params); + if (!parsed.success) { + await channel.send({ + jsonrpc: "2.0", + id: requestId, + error: { + code: INVALID_PARAMS_ERROR_CODE, + message: z.prettifyError(parsed.error), + }, + }); + onError("invalid-request"); + return; + } + + await serializeAuthorizationRequest(async () => { + try { + const params = parsed.data; + const validation = await validateDerivationOrigin({ + requestOrigin: channel.origin, + derivationOrigin: params.icrc95DerivationOrigin, + }); + if (validation.result === "invalid") { + onError("unverified-origin"); + return; + } + + const effectiveOrigin = remapToLegacyDomain( + params.icrc95DerivationOrigin ?? channel.origin, + ); + + const created = await createSession(effectiveOrigin); + const chain = await extendToApp( + created.record, + params.sessionPublicKey, + ); + await channel.send({ + jsonrpc: "2.0", + id: requestId, + result: SessionResultSchema.encode({ + chain, + }), + }); + } catch (error) { + console.error(error); + onError("delegation-failed"); + } + }); + }; + +const createSession = async ( + effectiveOrigin: string, +): Promise<{ record: AppSessionRecord }> => { + authorizationStore.setRequestContext(effectiveOrigin, undefined); + const authorized = await waitForStore(authorizedStore); + const [accountNumber, { identityNumber, actor, authMethod }] = + await Promise.all([ + authorized.accountNumberPromise, + waitForStore(authenticationStore), + ]); + // An SSO organization caps how long its sign-ins stay valid, and a session must not + // outlive that, so an SSO identity sends a duration even when the user picked none. + const ssoSessionMaxAgeNs = + "openid" in authMethod ? authMethod.openid.ssoSessionMaxAgeNs : undefined; + const validFor = + ssoSessionMaxAgeNs !== undefined && + (authorized.maxTimeToLive === undefined || + authorized.maxTimeToLive > ssoSessionMaxAgeNs) + ? ssoSessionMaxAgeNs + : authorized.maxTimeToLive; + + const key = { identityNumber, accountNumber, origin: effectiveOrigin }; + const iiKey = await ECDSAKeyIdentity.generate({ extractable: false }); + const iiPublicKey = new Uint8Array(iiKey.getPublicKey().toDer()); + const deviceName = await describeBrowser(); + + const prepared = await withBrowserProof( + identityNumber, + iiPublicKey, + async (browser) => { + const prepared = await actor + .prepare_account_session({ + identity_number: identityNumber, + origin: effectiveOrigin, + account_number: accountNumber !== undefined ? [accountNumber] : [], + session_key: iiPublicKey, + device_name: deviceName, + device_key: browser.publicKey, + next_device_key: browser.nextPublicKey, + device_key_signature: browser.signature, + next_device_key_signature: browser.nextSignature, + permissions: toPermissionsArg(authorized.accessLevel), + // The duration the user chose at consent, clamped by the canister. Dropping it + // would honour half of a consent and silently discard the other half. + valid_for: validFor !== undefined ? [validFor] : [], + }) + .then(throwCanisterError); + await browser.accept(prepared.device_id); + return prepared; + }, + ); + + const fetched = await retryFor(5, () => + actor + .get_account_session({ + identity_number: identityNumber, + origin: effectiveOrigin, + account_number: accountNumber !== undefined ? [accountNumber] : [], + session_key: iiPublicKey, + expiration: prepared.expiration, + }) + .then(throwCanisterError), + ); + + const canisterChain = DelegationChain.fromDelegations( + [ + { + delegation: new Delegation( + new Uint8Array(fetched.signed_delegation.delegation.pubkey), + fetched.signed_delegation.delegation.expiration, + ), + signature: new Uint8Array( + fetched.signed_delegation.signature, + ) as Signature, + }, + ], + new Uint8Array(prepared.user_key), + ); + + const record: AppSessionRecord = { + keyPair: iiKey.getKeyPair(), + chainJson: JSON.stringify(canisterChain.toJSON()), + expiresAtMillis: Number(prepared.expiration / BigInt(1_000_000)), + createdAtNanos: prepared.created_at, + accessLevel: authorized.accessLevel, + accountPrincipal: prepared.account_principal.toText(), + }; + await storeAppSession(key, record); + return { record }; +}; diff --git a/src/frontend/src/lib/stores/channelStore.ts b/src/frontend/src/lib/stores/channelStore.ts index 4ca5c98164..9e989ae781 100644 --- a/src/frontend/src/lib/stores/channelStore.ts +++ b/src/frontend/src/lib/stores/channelStore.ts @@ -23,6 +23,7 @@ import { handlePermissions, } from "$lib/stores/channelHandlers/icrc25"; import { handleDelegationRequest } from "$lib/stores/channelHandlers/delegation"; +import { handleSessionDelegationRequest } from "$lib/stores/channelHandlers/sessionDelegation"; import { handleLegacyAttributes, handleIcrc3OneClickOpenIdAttributes, @@ -108,6 +109,10 @@ export const channelStore: ChannelStore = { "request", handleDelegationRequest(channel, onError), ); + channel.addEventListener( + "request", + handleSessionDelegationRequest(channel, onError), + ); channel.addEventListener( "request", handleLegacyAttributes(channel, onError), From b045ccaca3f4036a16e6dd8add493a17c5a389c1 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sun, 23 Aug 2026 20:43:02 +0200 Subject: [PATCH 02/14] feat(fe): let an app cap how long its session lasts ii_session_delegation took no lifetime, so an application had no way to ask for a session shorter than whatever the consent picker offered, and the request context was set with undefined where the ICRC-34 handler passes what the app asked for. The method now accepts maxTimeToLive on the same terms as ICRC-34: a ceiling rather than a request. What the user picks at consent wins, an SSO organization's cap narrows it further, and the canister clamps the result to between ten minutes and thirty days. Co-Authored-By: Claude Opus 5 (1M context) --- .../channelHandlers/sessionDelegation.ts | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts index 73dca34d25..dc4631b2d6 100644 --- a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts +++ b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts @@ -37,6 +37,10 @@ export const SESSION_DELEGATION_METHOD = "ii_session_delegation"; const SessionParamsCodec = z.object({ sessionPublicKey: Base64ToPublicKeyCodec, + // How long the app is willing for the session to last. A ceiling rather than a + // request: what the user picks at consent wins, an SSO organization's cap + // narrows it further, and the canister clamps the result. + maxTimeToLive: z.optional(StringToBigIntCodec), icrc95DerivationOrigin: z.optional(OriginSchema), }); @@ -161,7 +165,10 @@ export const handleSessionDelegationRequest = params.icrc95DerivationOrigin ?? channel.origin, ); - const created = await createSession(effectiveOrigin); + const created = await createSession( + effectiveOrigin, + params.maxTimeToLive, + ); const chain = await extendToApp( created.record, params.sessionPublicKey, @@ -182,8 +189,9 @@ export const handleSessionDelegationRequest = const createSession = async ( effectiveOrigin: string, + requestedMaxTimeToLive: bigint | undefined, ): Promise<{ record: AppSessionRecord }> => { - authorizationStore.setRequestContext(effectiveOrigin, undefined); + authorizationStore.setRequestContext(effectiveOrigin, requestedMaxTimeToLive); const authorized = await waitForStore(authorizedStore); const [accountNumber, { identityNumber, actor, authMethod }] = await Promise.all([ @@ -194,12 +202,14 @@ const createSession = async ( // outlive that, so an SSO identity sends a duration even when the user picked none. const ssoSessionMaxAgeNs = "openid" in authMethod ? authMethod.openid.ssoSessionMaxAgeNs : undefined; + // What the user picked wins over what the app asked for; the app's value is the + // ceiling that applies when the picker offered nothing. + const requested = authorized.maxTimeToLive ?? requestedMaxTimeToLive; const validFor = ssoSessionMaxAgeNs !== undefined && - (authorized.maxTimeToLive === undefined || - authorized.maxTimeToLive > ssoSessionMaxAgeNs) + (requested === undefined || requested > ssoSessionMaxAgeNs) ? ssoSessionMaxAgeNs - : authorized.maxTimeToLive; + : requested; const key = { identityNumber, accountNumber, origin: effectiveOrigin }; const iiKey = await ECDSAKeyIdentity.generate({ extractable: false }); From e6dd29d25ae88facc7f5ae9b3a7ec84ead4c13de Mon Sep 17 00:00:00 2001 From: sea-snake Date: Tue, 1 Sep 2026 00:17:31 +0200 Subject: [PATCH 03/14] feat(sessions): record the account mapping alongside the session The two are written together here; they are separable so that a later layer can decline the session and keep the mapping. --- .../src/lib/stores/channelHandlers/sessionDelegation.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts index dc4631b2d6..17cf72bc74 100644 --- a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts +++ b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts @@ -12,6 +12,7 @@ import { } from "$lib/stores/authorization.store"; import { authenticationStore } from "$lib/stores/authentication.store"; import { + rememberAppAccount, storeAppSession, type AppSessionRecord, } from "$lib/stores/app-session.store"; @@ -275,8 +276,10 @@ const createSession = async ( expiresAtMillis: Number(prepared.expiration / BigInt(1_000_000)), createdAtNanos: prepared.created_at, accessLevel: authorized.accessLevel, - accountPrincipal: prepared.account_principal.toText(), }; + await rememberAppAccount(key, { + accountPrincipal: prepared.account_principal.toText(), + }); await storeAppSession(key, record); return { record }; }; From 1f4a74488c81d862536986f5cb9a1d6019694927 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Tue, 1 Sep 2026 14:33:20 +0200 Subject: [PATCH 04/14] refactor(fe): send current_device_key by the name the canister now uses --- .../src/lib/stores/channelHandlers/sessionDelegation.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts index 17cf72bc74..2e54fd16a3 100644 --- a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts +++ b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts @@ -228,9 +228,9 @@ const createSession = async ( account_number: accountNumber !== undefined ? [accountNumber] : [], session_key: iiPublicKey, device_name: deviceName, - device_key: browser.publicKey, + current_device_key: browser.publicKey, next_device_key: browser.nextPublicKey, - device_key_signature: browser.signature, + current_device_key_signature: browser.signature, next_device_key_signature: browser.nextSignature, permissions: toPermissionsArg(authorized.accessLevel), // The duration the user chose at consent, clamped by the canister. Dropping it From 7ac1a656e22a90b5f97045da7b735376634ddba2 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Wed, 2 Sep 2026 18:38:40 +0200 Subject: [PATCH 05/14] feat(fe): carry the app's idle bound to the canister MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The request parsed maxTimeToLive and dropped maxTimeToIdle, so zod stripped it and prepare_account_session was called without one — a client could ask for a bound and always get the canister's default instead. --- .../lib/stores/channelHandlers/sessionDelegation.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts index 2e54fd16a3..dbf0f370a6 100644 --- a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts +++ b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts @@ -42,6 +42,10 @@ const SessionParamsCodec = z.object({ // request: what the user picks at consent wins, an SSO organization's cap // narrows it further, and the canister clamps the result. maxTimeToLive: z.optional(StringToBigIntCodec), + // How long the session may go unminted before the canister ends it. A ceiling + // like `maxTimeToLive`: the canister clamps it to between 10 minutes and the + // session's own granted length, and applies its own default where absent. + maxTimeToIdle: z.optional(StringToBigIntCodec), icrc95DerivationOrigin: z.optional(OriginSchema), }); @@ -169,6 +173,7 @@ export const handleSessionDelegationRequest = const created = await createSession( effectiveOrigin, params.maxTimeToLive, + params.maxTimeToIdle, ); const chain = await extendToApp( created.record, @@ -191,6 +196,7 @@ export const handleSessionDelegationRequest = const createSession = async ( effectiveOrigin: string, requestedMaxTimeToLive: bigint | undefined, + requestedMaxTimeToIdle: bigint | undefined, ): Promise<{ record: AppSessionRecord }> => { authorizationStore.setRequestContext(effectiveOrigin, requestedMaxTimeToLive); const authorized = await waitForStore(authorizedStore); @@ -236,6 +242,12 @@ const createSession = async ( // The duration the user chose at consent, clamped by the canister. Dropping it // would honour half of a consent and silently discard the other half. valid_for: validFor !== undefined ? [validFor] : [], + // Straight through: the bound is the app's to ask for and the + // canister's to clamp, and nothing at consent narrows it. + max_idle: + requestedMaxTimeToIdle !== undefined + ? [requestedMaxTimeToIdle] + : [], }) .then(throwCanisterError); await browser.accept(prepared.device_id); From 2b7a48d710649db4bc92e0cab8382477aa3951cd Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sat, 5 Sep 2026 10:44:47 +0200 Subject: [PATCH 06/14] fix(fe): name the session get_account_session is fetching The call left out device_id and created_at, which prepare_account_session returns and the request type requires. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../src/lib/stores/channelHandlers/sessionDelegation.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts index dbf0f370a6..345629f89c 100644 --- a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts +++ b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts @@ -262,6 +262,8 @@ const createSession = async ( origin: effectiveOrigin, account_number: accountNumber !== undefined ? [accountNumber] : [], session_key: iiPublicKey, + device_id: prepared.device_id, + created_at: prepared.created_at, expiration: prepared.expiration, }) .then(throwCanisterError), From ce2afee9aa338dc9e8ed704e26b6f7f7defa4e5f Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sat, 5 Sep 2026 11:12:56 +0200 Subject: [PATCH 07/14] fix(fe): name the session get_account_session is fetching by its id Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../src/lib/stores/channelHandlers/sessionDelegation.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts index 345629f89c..e388c0378f 100644 --- a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts +++ b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts @@ -262,8 +262,7 @@ const createSession = async ( origin: effectiveOrigin, account_number: accountNumber !== undefined ? [accountNumber] : [], session_key: iiPublicKey, - device_id: prepared.device_id, - created_at: prepared.created_at, + session_id: prepared.session_id, expiration: prepared.expiration, }) .then(throwCanisterError), @@ -288,7 +287,7 @@ const createSession = async ( keyPair: iiKey.getKeyPair(), chainJson: JSON.stringify(canisterChain.toJSON()), expiresAtMillis: Number(prepared.expiration / BigInt(1_000_000)), - createdAtNanos: prepared.created_at, + sessionId: prepared.session_id, accessLevel: authorized.accessLevel, }; await rememberAppAccount(key, { From 69579e4c042d582f8a440bb820389858ed98de9a Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sat, 5 Sep 2026 12:04:54 +0200 Subject: [PATCH 08/14] feat(fe): retry a sign-in the canister refused as a stale key Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../channelHandlers/sessionDelegation.test.ts | 29 ++++++++++++++++++- .../channelHandlers/sessionDelegation.ts | 28 ++++++++++++++++-- 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts index f6f1da0b26..f71a359a72 100644 --- a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts +++ b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts @@ -26,7 +26,12 @@ vi.mock("$lib/stores/authorization.store", () => ({ authorizedStore: { subscribe: () => () => {} }, })); -import { handleSessionDelegationRequest } from "./sessionDelegation"; +import { + asBrowserKeyError, + handleSessionDelegationRequest, +} from "./sessionDelegation"; +import { StaleBrowserKeyError } from "$lib/stores/browser-key.store"; +import { CanisterError } from "$lib/utils/utils"; import { purgeAppSessions } from "$lib/stores/app-session.store"; const channelWith = () => { @@ -90,3 +95,25 @@ describe("ii_session_delegation", () => { expect(onError).toHaveBeenCalledWith("invalid-request"); }); }); + +describe("asBrowserKeyError", () => { + it("names a retired browser key so the key store can promote its successor", () => { + const stale = asBrowserKeyError( + new CanisterError({ StaleDeviceKey: null }), + ); + + expect(stale).toBeInstanceOf(StaleBrowserKeyError); + }); + + it("leaves every other canister error alone", () => { + const other = new CanisterError({ NoSuchAccount: null }); + + expect(asBrowserKeyError(other)).toBe(other); + }); + + it("leaves a transport failure alone", () => { + const network = new Error("network"); + + expect(asBrowserKeyError(network)).toBe(network); + }); +}); diff --git a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts index e388c0378f..c04509c13c 100644 --- a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts +++ b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts @@ -19,7 +19,12 @@ import { import { validateDerivationOrigin } from "$lib/utils/validateDerivationOrigin"; import { remapToLegacyDomain } from "$lib/utils/iiConnection"; import { toPermissionsArg } from "$lib/utils/accessLevel"; -import { retryFor, throwCanisterError, waitForStore } from "$lib/utils/utils"; +import { + isCanisterError, + retryFor, + throwCanisterError, + waitForStore, +} from "$lib/utils/utils"; import { canisterId } from "$lib/globals"; import { Principal } from "@icp-sdk/core/principal"; import { @@ -28,8 +33,12 @@ import { ECDSAKeyIdentity, } from "@icp-sdk/core/identity"; import type { PublicKey, Signature } from "@icp-sdk/core/agent"; +import type { AccountSessionError } from "$lib/generated/internet_identity_types"; import { serializeAuthorizationRequest } from "$lib/stores/channelHandlers/serialize"; -import { withBrowserProof } from "$lib/stores/browser-key.store"; +import { + StaleBrowserKeyError, + withBrowserProof, +} from "$lib/stores/browser-key.store"; import { describeBrowser } from "$lib/stores/channelHandlers/describeBrowser"; import { z } from "zod"; import type { ChannelError } from "$lib/stores/channelStore"; @@ -193,6 +202,16 @@ export const handleSessionDelegationRequest = }); }; +/** + * The canister reaches a browser's entry only through the successor that browser + * announced, so a key it has retired is refused rather than enrolled again. Named in the + * form the key store acts on, which is where the successor that does resolve is kept. + */ +export const asBrowserKeyError = (error: unknown): unknown => + isCanisterError(error) && error.type === "StaleDeviceKey" + ? new StaleBrowserKeyError() + : error; + const createSession = async ( effectiveOrigin: string, requestedMaxTimeToLive: bigint | undefined, @@ -249,7 +268,10 @@ const createSession = async ( ? [requestedMaxTimeToIdle] : [], }) - .then(throwCanisterError); + .then(throwCanisterError) + .catch((error: unknown) => { + throw asBrowserKeyError(error); + }); await browser.accept(prepared.device_id); return prepared; }, From c378843d4f785587659585e543595cfc5b5f17ec Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sun, 6 Sep 2026 13:09:56 +0200 Subject: [PATCH 09/14] refactor(be): a browser, not a session device MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Device` is taken — it is the passkey credential that authenticates the anchor, and `DeviceData` is in the candid interface. The word this wants is the one its own doc comment already used, and the one the frontend uses throughout. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../lib/stores/channelHandlers/sessionDelegation.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts index c04509c13c..ba5d09e840 100644 --- a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts +++ b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts @@ -253,10 +253,10 @@ const createSession = async ( account_number: accountNumber !== undefined ? [accountNumber] : [], session_key: iiPublicKey, device_name: deviceName, - current_device_key: browser.publicKey, - next_device_key: browser.nextPublicKey, - current_device_key_signature: browser.signature, - next_device_key_signature: browser.nextSignature, + current_browser_key: browser.publicKey, + next_browser_key: browser.nextPublicKey, + current_browser_key_signature: browser.signature, + next_browser_key_signature: browser.nextSignature, permissions: toPermissionsArg(authorized.accessLevel), // The duration the user chose at consent, clamped by the canister. Dropping it // would honour half of a consent and silently discard the other half. @@ -272,7 +272,7 @@ const createSession = async ( .catch((error: unknown) => { throw asBrowserKeyError(error); }); - await browser.accept(prepared.device_id); + await browser.accept(prepared.browser_id); return prepared; }, ); From 9f3be291424034d617e4f04d125580ecd33d0720 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sun, 6 Sep 2026 19:30:33 +0200 Subject: [PATCH 10/14] refactor(fe): the sign-in handler names a browser Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../lib/stores/channelHandlers/sessionDelegation.test.ts | 2 +- .../src/lib/stores/channelHandlers/sessionDelegation.ts | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts index f71a359a72..12831c9bce 100644 --- a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts +++ b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts @@ -99,7 +99,7 @@ describe("ii_session_delegation", () => { describe("asBrowserKeyError", () => { it("names a retired browser key so the key store can promote its successor", () => { const stale = asBrowserKeyError( - new CanisterError({ StaleDeviceKey: null }), + new CanisterError({ StaleBrowserKey: null }), ); expect(stale).toBeInstanceOf(StaleBrowserKeyError); diff --git a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts index ba5d09e840..b2efbb8a1b 100644 --- a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts +++ b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts @@ -208,7 +208,8 @@ export const handleSessionDelegationRequest = * form the key store acts on, which is where the successor that does resolve is kept. */ export const asBrowserKeyError = (error: unknown): unknown => - isCanisterError(error) && error.type === "StaleDeviceKey" + isCanisterError(error) && + error.type === "StaleBrowserKey" ? new StaleBrowserKeyError() : error; @@ -240,7 +241,7 @@ const createSession = async ( const key = { identityNumber, accountNumber, origin: effectiveOrigin }; const iiKey = await ECDSAKeyIdentity.generate({ extractable: false }); const iiPublicKey = new Uint8Array(iiKey.getPublicKey().toDer()); - const deviceName = await describeBrowser(); + const browserName = await describeBrowser(); const prepared = await withBrowserProof( identityNumber, @@ -252,7 +253,7 @@ const createSession = async ( origin: effectiveOrigin, account_number: accountNumber !== undefined ? [accountNumber] : [], session_key: iiPublicKey, - device_name: deviceName, + browser_name: browserName, current_browser_key: browser.publicKey, next_browser_key: browser.nextPublicKey, current_browser_key_signature: browser.signature, From d15a2abda49a6375959380b1d82a64478440c3e1 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Tue, 8 Sep 2026 21:29:07 +0200 Subject: [PATCH 11/14] feat(frontend): send what the browser is, not a label for it Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../src/lib/stores/channelHandlers/sessionDelegation.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts index b2efbb8a1b..5ac13ee020 100644 --- a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts +++ b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts @@ -241,11 +241,12 @@ const createSession = async ( const key = { identityNumber, accountNumber, origin: effectiveOrigin }; const iiKey = await ECDSAKeyIdentity.generate({ extractable: false }); const iiPublicKey = new Uint8Array(iiKey.getPublicKey().toDer()); - const browserName = await describeBrowser(); + const browserDescription = await describeBrowser(); const prepared = await withBrowserProof( identityNumber, iiPublicKey, + browserDescription, async (browser) => { const prepared = await actor .prepare_account_session({ @@ -253,7 +254,7 @@ const createSession = async ( origin: effectiveOrigin, account_number: accountNumber !== undefined ? [accountNumber] : [], session_key: iiPublicKey, - browser_name: browserName, + browser_description: browserDescription, current_browser_key: browser.publicKey, next_browser_key: browser.nextPublicKey, current_browser_key_signature: browser.signature, From 1d1860d2c5df2f6ae00498db0d3d0654df3cae57 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Thu, 10 Sep 2026 14:08:52 +0200 Subject: [PATCH 12/14] fix(session-delegation): a duration it cannot read is an error, not silence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `StringToBigIntCodec` calls `BigInt` straight, and a throw inside a codec escapes `safeParse` rather than becoming an issue. The parse runs above the handler's `try`, so a duration like `"soon"` never reached the invalid-params branch and the app was told nothing at all. `Nat64StringCodec` reports it instead — and bounds it, since `BigInt` alone accepts `"-1"` and `""`. No regex: a second grammar beside `BigInt`'s own would be free to drift from it. `remapToLegacyDomain` moves to `urlUtils`, beside the two constants it already used, and is re-exported from `iiConnection` for its callers there. It is seven lines of string work with nothing to do with a connection, and importing it from there forced this handler's test to mock the whole legacy module — as the identity function, which quietly disabled the remap it was standing in for. The key soup gets names that say whose key each is: `recordKey` is where the session is stored, `iiSessionIdentity` and `iiSessionPublicKey` are II's own, and the request's `sessionPublicKey` stays the app's. With a comment on why that identity exists at all, which is a security property: over a redirect no verified origin identifies the requester, so the canister must not certify toward a key the request supplied. Tests reach the ceremony for the first time — the canister arguments, the stored record and the returned chain — plus the malformed duration above. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../channelHandlers/sessionDelegation.test.ts | 135 +++++++++++++++++- .../channelHandlers/sessionDelegation.ts | 43 ++++-- src/frontend/src/lib/utils/iiConnection.ts | 20 +-- src/frontend/src/lib/utils/transport/utils.ts | 32 +++++ src/frontend/src/lib/utils/urlUtils.ts | 11 ++ 5 files changed, 207 insertions(+), 34 deletions(-) diff --git a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts index 12831c9bce..10e719085c 100644 --- a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts +++ b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts @@ -14,16 +14,47 @@ vi.mock("$lib/globals", async () => { vi.mock("$lib/utils/validateDerivationOrigin", () => ({ validateDerivationOrigin: vi.fn(() => Promise.resolve({ result: "valid" })), })); -vi.mock("$lib/utils/iiConnection", () => ({ - remapToLegacyDomain: (origin: string) => origin, -})); const setRequestContext = vi.fn(); + +const IDENTITY = BigInt(10_000); +const prepareAccountSession = vi.fn(); +const getAccountSession = vi.fn(); + vi.mock("$lib/stores/authorization.store", () => ({ authorizationStore: { setRequestContext: (...args: unknown[]) => setRequestContext(...args), }, - authorizedStore: { subscribe: () => () => {} }, + // A store that already holds its value, which is what `waitForStore` waits for. + // Inlined rather than shared, because `vi.mock` is hoisted above anything declared + // here. + authorizedStore: { + subscribe: (run: (value: unknown) => void) => { + run({ + accessLevel: "full-access", + maxTimeToLive: undefined, + accountNumberPromise: Promise.resolve(undefined), + }); + return () => {}; + }, + }, +})); +vi.mock("$lib/stores/authentication.store", () => ({ + authenticationStore: { + subscribe: (run: (value: unknown) => void) => { + run({ + identityNumber: BigInt(10_000), + authMethod: { passkey: {} }, + actor: { + prepare_account_session: (...args: unknown[]) => + prepareAccountSession(...args), + get_account_session: (...args: unknown[]) => + getAccountSession(...args), + }, + }); + return () => {}; + }, + }, })); import { @@ -32,7 +63,13 @@ import { } from "./sessionDelegation"; import { StaleBrowserKeyError } from "$lib/stores/browser-key.store"; import { CanisterError } from "$lib/utils/utils"; -import { purgeAppSessions } from "$lib/stores/app-session.store"; +import { + appSessionsForOrigin, + purgeAppSessions, +} from "$lib/stores/app-session.store"; +import { ECDSAKeyIdentity } from "@icp-sdk/core/identity"; +import { Principal } from "@icp-sdk/core/principal"; +import { Base64ToBytesCodec } from "$lib/utils/transport/utils"; const channelWith = () => { const sent: unknown[] = []; @@ -94,6 +131,94 @@ describe("ii_session_delegation", () => { expect(sent[0]).toMatchObject({ id: 1, error: { code: -32602 } }); expect(onError).toHaveBeenCalledWith("invalid-request"); }); + + /// A duration `BigInt` cannot read used to throw out of `safeParse`, which sits above + /// the handler's `try`, so the app was told nothing at all. + it("rejects a duration that is not a number", async () => { + const { channel, sent } = channelWith(); + const onError = vi.fn(); + + await handleSessionDelegationRequest( + channel, + onError, + )({ + jsonrpc: "2.0", + id: 1, + method: "ii_session_delegation", + params: { + sessionPublicKey: btoa("an app key"), + maxTimeToLive: "not a number", + }, + }); + + expect(sent).toHaveLength(1); + expect(sent[0]).toMatchObject({ id: 1, error: { code: -32602 } }); + expect(onError).toHaveBeenCalledWith("invalid-request"); + }); + + /// The whole ceremony, which nothing else here reaches: what the canister is asked + /// for, what is kept, and what the app is handed back. + it("mints a session and answers with a chain the app can use", async () => { + const { channel, sent } = channelWith(); + const appKey = await ECDSAKeyIdentity.generate({ extractable: false }); + const appPublicKey = new Uint8Array(appKey.getPublicKey().toDer()); + const expiration = BigInt(Date.now() + 60 * 60 * 1000) * BigInt(1_000_000); + + prepareAccountSession.mockImplementation(({ session_key }) => + Promise.resolve({ + Ok: { + user_key: session_key, + expiration, + session_id: BigInt(77), + browser_id: 3, + account_principal: Principal.anonymous(), + }, + }), + ); + getAccountSession.mockImplementation(({ session_key }) => + Promise.resolve({ + Ok: { + signed_delegation: { + delegation: { pubkey: session_key, expiration, targets: [] }, + // At least 32 bytes: the chain's own parser refuses anything shorter. + signature: new Uint8Array(64).fill(7), + }, + }, + }), + ); + + await handleSessionDelegationRequest( + channel, + vi.fn(), + )({ + jsonrpc: "2.0", + id: 1, + method: "ii_session_delegation", + params: { sessionPublicKey: Base64ToBytesCodec.encode(appPublicKey) }, + }); + + // Asked for what the request and the consent said, at this origin. + expect(prepareAccountSession).toHaveBeenCalledWith( + expect.objectContaining({ + identity_number: IDENTITY, + origin: ORIGIN, + account_number: [], + }), + ); + + // Kept, so a later silent re-auth resumes rather than signing in again — and kept + // against II's own key, never the app's. + const [stored] = await appSessionsForOrigin(ORIGIN); + expect(stored.record.sessionId).toBe(BigInt(77)); + expect(stored.identityNumber).toBe(IDENTITY); + + // Answered, and the chain ends at the app's key rather than at what the canister + // signed: the hop only II can make is what makes the on-chain half unusable alone. + expect(sent).toHaveLength(1); + expect(sent[0]).toMatchObject({ id: 1 }); + const result = (sent[0] as { result: { publicKey: string } }).result; + expect(result.publicKey).toEqual(expect.any(String)); + }); }); describe("asBrowserKeyError", () => { diff --git a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts index 5ac13ee020..685e29c63e 100644 --- a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts +++ b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts @@ -4,6 +4,7 @@ import { Base64ToPublicKeyCodec, INVALID_PARAMS_ERROR_CODE, OriginSchema, + Nat64StringCodec, StringToBigIntCodec, } from "$lib/utils/transport/utils"; import { @@ -17,7 +18,7 @@ import { type AppSessionRecord, } from "$lib/stores/app-session.store"; import { validateDerivationOrigin } from "$lib/utils/validateDerivationOrigin"; -import { remapToLegacyDomain } from "$lib/utils/iiConnection"; +import { remapToLegacyDomain } from "$lib/utils/urlUtils"; import { toPermissionsArg } from "$lib/utils/accessLevel"; import { isCanisterError, @@ -39,7 +40,7 @@ import { StaleBrowserKeyError, withBrowserProof, } from "$lib/stores/browser-key.store"; -import { describeBrowser } from "$lib/stores/channelHandlers/describeBrowser"; +import { describeBrowser } from "$lib/utils/describeBrowser"; import { z } from "zod"; import type { ChannelError } from "$lib/stores/channelStore"; @@ -50,11 +51,11 @@ const SessionParamsCodec = z.object({ // How long the app is willing for the session to last. A ceiling rather than a // request: what the user picks at consent wins, an SSO organization's cap // narrows it further, and the canister clamps the result. - maxTimeToLive: z.optional(StringToBigIntCodec), + maxTimeToLive: z.optional(Nat64StringCodec), // How long the session may go unminted before the canister ends it. A ceiling // like `maxTimeToLive`: the canister clamps it to between 10 minutes and the // session's own granted length, and applies its own default where absent. - maxTimeToIdle: z.optional(StringToBigIntCodec), + maxTimeToIdle: z.optional(Nat64StringCodec), icrc95DerivationOrigin: z.optional(OriginSchema), }); @@ -238,14 +239,28 @@ const createSession = async ( ? ssoSessionMaxAgeNs : requested; - const key = { identityNumber, accountNumber, origin: effectiveOrigin }; - const iiKey = await ECDSAKeyIdentity.generate({ extractable: false }); - const iiPublicKey = new Uint8Array(iiKey.getPublicKey().toDer()); + const recordKey = { identityNumber, accountNumber, origin: effectiveOrigin }; + + // The canister never certifies a delegation toward a key the request supplied. Over a + // redirect no browser-verified origin identifies the requester, so a malicious page + // could otherwise have II authenticate for another domain's derivation origin toward + // the attacker's own key and then read the finished delegation out of certified chain + // state. Signing to a key held only here makes what is on chain inert: the usable + // chain is completed below by a second hop this key makes. Same reason `url.ts` gives + // for its intermediate-key middleware, which covers `icrc34_delegation` and passes + // this method through. Keeping the pair in the session record is also what lets a + // later silent re-auth resume the session — a consequence, not the reason. + const iiSessionIdentity = await ECDSAKeyIdentity.generate({ + extractable: false, + }); + const iiSessionPublicKey = new Uint8Array( + iiSessionIdentity.getPublicKey().toDer(), + ); const browserDescription = await describeBrowser(); const prepared = await withBrowserProof( identityNumber, - iiPublicKey, + iiSessionPublicKey, browserDescription, async (browser) => { const prepared = await actor @@ -253,7 +268,7 @@ const createSession = async ( identity_number: identityNumber, origin: effectiveOrigin, account_number: accountNumber !== undefined ? [accountNumber] : [], - session_key: iiPublicKey, + session_key: iiSessionPublicKey, browser_description: browserDescription, current_browser_key: browser.publicKey, next_browser_key: browser.nextPublicKey, @@ -274,9 +289,9 @@ const createSession = async ( .catch((error: unknown) => { throw asBrowserKeyError(error); }); - await browser.accept(prepared.browser_id); return prepared; }, + (prepared) => prepared.browser_id, ); const fetched = await retryFor(5, () => @@ -285,7 +300,7 @@ const createSession = async ( identity_number: identityNumber, origin: effectiveOrigin, account_number: accountNumber !== undefined ? [accountNumber] : [], - session_key: iiPublicKey, + session_key: iiSessionPublicKey, session_id: prepared.session_id, expiration: prepared.expiration, }) @@ -308,15 +323,15 @@ const createSession = async ( ); const record: AppSessionRecord = { - keyPair: iiKey.getKeyPair(), + keyPair: iiSessionIdentity.getKeyPair(), chainJson: JSON.stringify(canisterChain.toJSON()), expiresAtMillis: Number(prepared.expiration / BigInt(1_000_000)), sessionId: prepared.session_id, accessLevel: authorized.accessLevel, }; - await rememberAppAccount(key, { + await rememberAppAccount(recordKey, { accountPrincipal: prepared.account_principal.toText(), }); - await storeAppSession(key, record); + await storeAppSession(recordKey, record); return { record }; }; diff --git a/src/frontend/src/lib/utils/iiConnection.ts b/src/frontend/src/lib/utils/iiConnection.ts index f20719247b..1ece6d2db3 100644 --- a/src/frontend/src/lib/utils/iiConnection.ts +++ b/src/frontend/src/lib/utils/iiConnection.ts @@ -63,10 +63,11 @@ import { } from "./analytics/webauthnAuthenticationFunnel"; import { HARDWARE_KEY_TEST } from "$lib/state/featureFlags"; import { frontendCanisterConfig } from "$lib/globals"; -import { - GATEWAY_ORIGIN_REGEX, - LEGACY_GATEWAY_DOMAIN, -} from "$lib/utils/urlUtils"; +// Re-exported so the callers in this file, and anything importing it from here, are +// untouched by the move. It lives in `urlUtils` because that is where its two constants +// already were, and because it is string work with nothing to do with a connection. +export { remapToLegacyDomain } from "$lib/utils/urlUtils"; +import { remapToLegacyDomain } from "$lib/utils/urlUtils"; /* * A (dummy) identity that always uses the same keypair. The secret key is @@ -995,17 +996,6 @@ export const creationOptions = ( }; }; -// In order to give dapps a stable principal regardless whether they use the legacy (ic0.app) or -// any of the newer canister gateway domains (icp0.io, icp.net) we map back the derivation origin -// to the ic0.app domain. -export const remapToLegacyDomain = (origin: string): string => { - const groups = origin.match(GATEWAY_ORIGIN_REGEX)?.groups; - if (groups === undefined || groups.domain === LEGACY_GATEWAY_DOMAIN) { - return origin; - } - return `https://${groups.subdomain}.${LEGACY_GATEWAY_DOMAIN}`; -}; - export const bufferEqual = (buf1: ArrayBuffer, buf2: ArrayBuffer): boolean => { if (buf1.byteLength != buf2.byteLength) return false; const dv1 = new Int8Array(buf1); diff --git a/src/frontend/src/lib/utils/transport/utils.ts b/src/frontend/src/lib/utils/transport/utils.ts index 0f232a930c..56605efdad 100644 --- a/src/frontend/src/lib/utils/transport/utils.ts +++ b/src/frontend/src/lib/utils/transport/utils.ts @@ -135,6 +135,38 @@ export const StringToBigIntCodec = z.codec(z.string(), z.bigint(), { encode: (bigint) => bigint.toString(), }); +/** + * A `nat64` as the decimal string JSON-RPC carries it. + * + * `BigInt` decides what is numeric — a second grammar beside it would be free to drift — + * but it throws on anything else, and a throw inside a codec escapes `safeParse` rather + * than becoming a validation issue, so a caller sending nonsense would get no error at + * all. Reported instead, and bounded: `BigInt` alone accepts `"-1"` and `""`. + */ +export const Nat64StringCodec = z.codec( + z.string(), + z + .bigint() + .min(BigInt(0)) + .max(BigInt(2) ** BigInt(64) - BigInt(1)), + { + decode: (value, ctx) => { + try { + return BigInt(value); + } catch { + ctx.issues.push({ + code: "invalid_format", + format: "nat64", + input: value, + message: "expected a nat64 as a decimal string", + }); + return z.NEVER; + } + }, + encode: (value) => value.toString(), + }, +); + export const StringOrNumberToBigIntCodec = z.codec( z.union([z.string(), z.number(), z.bigint()]), z.bigint(), diff --git a/src/frontend/src/lib/utils/urlUtils.ts b/src/frontend/src/lib/utils/urlUtils.ts index a792695961..a79830f8a6 100644 --- a/src/frontend/src/lib/utils/urlUtils.ts +++ b/src/frontend/src/lib/utils/urlUtils.ts @@ -86,3 +86,14 @@ export const gatewayOriginTwins = (origin: string): string[] => { (candidate) => `https://${subdomain}.${candidate}`, ); }; + +// In order to give dapps a stable principal regardless whether they use the legacy (ic0.app) or +// any of the newer canister gateway domains (icp0.io, icp.net) we map back the derivation origin +// to the ic0.app domain. +export const remapToLegacyDomain = (origin: string): string => { + const groups = origin.match(GATEWAY_ORIGIN_REGEX)?.groups; + if (groups === undefined || groups.domain === LEGACY_GATEWAY_DOMAIN) { + return origin; + } + return `https://${groups.subdomain}.${LEGACY_GATEWAY_DOMAIN}`; +}; From 7dfa92c8a060752e56ee4d6525c44d84d5887d9e Mon Sep 17 00:00:00 2001 From: sea-snake Date: Thu, 10 Sep 2026 15:49:22 +0200 Subject: [PATCH 13/14] fix(session-delegation): keep the targets the canister signed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The session credential is scoped to the II canister, and `targets` is part of the message the canister signs. Rebuilding the delegation from the reply left them out, so the chain handed to the app carried a first hop that hashes to nothing in the signature tree — every call made with it came back "Invalid canister signature: the signature tree doesn't contain sig/…/… path". The wire format was never at fault: the result codec carries `targets` both ways. They were gone before anything was encoded, and went into the stored `chainJson` with the rest. The test that should have caught this mocked `targets: []` — Candid's `None`, the shape from before the credential was scoped — so a rebuild that dropped them matched. It now answers as the canister does and asserts the hop keeps them. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../channelHandlers/sessionDelegation.test.ts | 25 +++++++++++++++++-- .../channelHandlers/sessionDelegation.ts | 4 +++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts index 10e719085c..46b19bb9db 100644 --- a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts +++ b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts @@ -179,7 +179,14 @@ describe("ii_session_delegation", () => { Promise.resolve({ Ok: { signed_delegation: { - delegation: { pubkey: session_key, expiration, targets: [] }, + // As the canister answers since the session credential was scoped: the + // targets are part of what it signed, so a chain rebuilt without them is + // refused by the replica. + delegation: { + pubkey: session_key, + expiration, + targets: [[Principal.fromText("rwlgt-iiaaa-aaaaa-aaaaa-cai")]], + }, // At least 32 bytes: the chain's own parser refuses anything shorter. signature: new Uint8Array(64).fill(7), }, @@ -216,8 +223,22 @@ describe("ii_session_delegation", () => { // signed: the hop only II can make is what makes the on-chain half unusable alone. expect(sent).toHaveLength(1); expect(sent[0]).toMatchObject({ id: 1 }); - const result = (sent[0] as { result: { publicKey: string } }).result; + const result = ( + sent[0] as { + result: { + publicKey: string; + signerDelegation: { delegation: { targets?: string[] } }[]; + }; + } + ).result; expect(result.publicKey).toEqual(expect.any(String)); + + // The hop the canister signed keeps its targets. Dropping them leaves a delegation + // that hashes to nothing in the signature tree, and every call the app makes with + // this chain comes back "Invalid canister signature". + expect(result.signerDelegation[0].delegation.targets).toEqual([ + "rwlgt-iiaaa-aaaaa-aaaaa-cai", + ]); }); }); diff --git a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts index 685e29c63e..67e93756c2 100644 --- a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts +++ b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts @@ -313,6 +313,10 @@ const createSession = async ( delegation: new Delegation( new Uint8Array(fetched.signed_delegation.delegation.pubkey), fetched.signed_delegation.delegation.expiration, + // Carried, not dropped: `targets` is part of the message the canister signed, + // so a delegation rebuilt without them hashes to something no signature in the + // tree covers, and every call the app makes with this chain is refused. + fetched.signed_delegation.delegation.targets[0], ), signature: new Uint8Array( fetched.signed_delegation.signature, From e371f37ba2f8f7caceb62571e69233124a42b56e Mon Sep 17 00:00:00 2001 From: sea-snake Date: Thu, 10 Sep 2026 17:30:01 +0200 Subject: [PATCH 14/14] fix(transport): accept only decimal digits as a nat64 BigInt reads more than the decimal strings JSON-RPC carries: "" and " " are 0n, "+1" is 1n, "0x10" is 16n, and the nat64 bounds reject none of them. A malformed duration therefore reached the canister as a number it silently clamped, instead of the invalid-params error the app could act on. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../channelHandlers/sessionDelegation.test.ts | 48 +++++++++++-------- src/frontend/src/lib/utils/transport/utils.ts | 17 ++++--- 2 files changed, 37 insertions(+), 28 deletions(-) diff --git a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts index 46b19bb9db..929510d0f9 100644 --- a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts +++ b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts @@ -132,29 +132,35 @@ describe("ii_session_delegation", () => { expect(onError).toHaveBeenCalledWith("invalid-request"); }); - /// A duration `BigInt` cannot read used to throw out of `safeParse`, which sits above - /// the handler's `try`, so the app was told nothing at all. - it("rejects a duration that is not a number", async () => { - const { channel, sent } = channelWith(); - const onError = vi.fn(); + /// A duration `BigInt` cannot read throws out of `safeParse`, which sits above the + /// handler's `try`, so the app would be told nothing at all. The rest `BigInt` reads + /// happily as something else: `""` and `" "` are `0n`, `"+1"` is `1n`, `"0x10"` is + /// `16n`, and the nat64 bounds reject none of them — so the canister would clamp a + /// number the app never meant to send. + it.each(["not a number", "", " ", "+1", "0x10", "-1"])( + "rejects %o as a duration", + async (maxTimeToLive) => { + const { channel, sent } = channelWith(); + const onError = vi.fn(); - await handleSessionDelegationRequest( - channel, - onError, - )({ - jsonrpc: "2.0", - id: 1, - method: "ii_session_delegation", - params: { - sessionPublicKey: btoa("an app key"), - maxTimeToLive: "not a number", - }, - }); + await handleSessionDelegationRequest( + channel, + onError, + )({ + jsonrpc: "2.0", + id: 1, + method: "ii_session_delegation", + params: { + sessionPublicKey: btoa("an app key"), + maxTimeToLive, + }, + }); - expect(sent).toHaveLength(1); - expect(sent[0]).toMatchObject({ id: 1, error: { code: -32602 } }); - expect(onError).toHaveBeenCalledWith("invalid-request"); - }); + expect(sent).toHaveLength(1); + expect(sent[0]).toMatchObject({ id: 1, error: { code: -32602 } }); + expect(onError).toHaveBeenCalledWith("invalid-request"); + }, + ); /// The whole ceremony, which nothing else here reaches: what the canister is asked /// for, what is kept, and what the app is handed back. diff --git a/src/frontend/src/lib/utils/transport/utils.ts b/src/frontend/src/lib/utils/transport/utils.ts index 56605efdad..2fa61c02c5 100644 --- a/src/frontend/src/lib/utils/transport/utils.ts +++ b/src/frontend/src/lib/utils/transport/utils.ts @@ -138,11 +138,15 @@ export const StringToBigIntCodec = z.codec(z.string(), z.bigint(), { /** * A `nat64` as the decimal string JSON-RPC carries it. * - * `BigInt` decides what is numeric — a second grammar beside it would be free to drift — - * but it throws on anything else, and a throw inside a codec escapes `safeParse` rather - * than becoming a validation issue, so a caller sending nonsense would get no error at - * all. Reported instead, and bounded: `BigInt` alone accepts `"-1"` and `""`. + * `BigInt` reads more than that — `""` and `" "` are `0n`, `"+1"` is `1n`, `"0x10"` is + * `16n` — and the bounds below reject none of them, so a malformed duration would arrive + * as a number the canister silently clamps rather than as an error the app can act on. + * Digits only, therefore, checked before converting. What is left is reported rather than + * thrown: a throw inside a codec escapes `safeParse` instead of becoming a validation + * issue, and a caller sending nonsense would get no answer at all. */ +const DECIMAL_DIGITS = /^\d+$/; + export const Nat64StringCodec = z.codec( z.string(), z @@ -151,9 +155,7 @@ export const Nat64StringCodec = z.codec( .max(BigInt(2) ** BigInt(64) - BigInt(1)), { decode: (value, ctx) => { - try { - return BigInt(value); - } catch { + if (!DECIMAL_DIGITS.test(value)) { ctx.issues.push({ code: "invalid_format", format: "nat64", @@ -162,6 +164,7 @@ export const Nat64StringCodec = z.codec( }); return z.NEVER; } + return BigInt(value); }, encode: (value) => value.toString(), },