Skip to content

Commit d61fa44

Browse files
sea-snakeclaude
andauthored
feat(be): sign a session to the II frontend at sign-in (#4268)
Design: #4224. Overview: #4230. This is the caller #4266 and #4264 were waiting for, so it removes their `#[allow(dead_code)]` annotations. **`prepare_account_session` / `get_account_session`.** The first creates a session and signs its delegation to the II frontend's key; the second witnesses it. A separate pair from `prepare_account_delegation`, not an option on it: both mint, but one proves a live session and identifies the account by its principal while the other proves an access method and names the anchor outright. Merging them would mean one method with two authorizers and two argument shapes, and would drag the frontend's internal surface into the public API. **Everything that can refuse does so before anything is written**, cheapest first. The account check leads — an account the identity does not hold is the one failure a caller can provoke, and returning it after the writes would leave a browser registered for a sign-in that never happened — so a request that was never going to succeed does not pay for two P-256 verifications on the way to being told so. Nothing is revealed by that order: `check_authz_and_record_activity` above is the auth guard. The browser proof follows, using the verifier from #4264, and `create_session` takes the `VerifiedBrowserKeys` it produces rather than two byte strings. **The session credential is scoped to Internet Identity.** It exists to mint app delegations, which is an update call on this canister and nothing else, so it is signed with `targets = [id()]` and can be presented nowhere else. Leaving that to the II frontend would have left it to the party holding the session key, who can decline to add it. `permissions` stays absent for the same reason it is set on app delegations: minting is an update call, so a read-only session that could not make one could not sign in to an app at all — read-only travels on the app delegation instead. **`get_account_session` tells three failures apart.** No stored session for that id is `NoSuchSession`; a session that is present with no signature for the asked-for key and expiration is `NoSuchDelegation`, because the remedy is to ask with the parameters that were signed rather than to sign in again; a seed that will not derive is the salt being unset, which is `InternalCanisterError`. An over-long origin is that too, rather than a rejected message the caller cannot read as a response. **The request carries what the browser is, not a label for it.** `browser_description` holds the tokens the registry stores (#4242), and each token a client writes for itself — an unrecognised brand, an unrecognised system, and the hardware model — is bounded at 64 bytes. The named variants carry no text, so a description of nothing but those is within the limit whatever it says. Refused rather than truncated: a cut-off token would put a value in the record that no parser ever produced. **Every later failure traps rather than returning.** On the IC, returning an error commits state and only a trap rolls the message back, so once the browser registration is written a failure has to trap or a caller could be told "no" and still have a browser enrolled. **`valid_for`** is the lifetime the user chose at consent, clamped by the canister to between 10 minutes and 30 days. Every ceremony creates, so it always applies: the replacement's expiry is measured from the ceremony that made it, and no session is renewed in place. Tests: `integration/sessions.rs` (18) drives the real ceremony — creating and verifying a session, a request for another identity refused, the registry cap dropping the least recently used and ending its sessions, the key proof's rejections at the endpoint, rotation keeping the entry, a retired key returning as a new browser, and two browsers each keeping the description it registered with. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 0c929f8 commit d61fa44

19 files changed

Lines changed: 1695 additions & 35 deletions

File tree

src/canister_tests/src/api/internet_identity/api_v2.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -746,3 +746,29 @@ pub fn get_account_delegation_with_read_only(
746746
)
747747
.map(|(x,)| x)
748748
}
749+
750+
pub fn prepare_account_session(
751+
env: &PocketIc,
752+
canister_id: CanisterId,
753+
sender: Principal,
754+
request: PrepareAccountSessionRequest,
755+
) -> Result<Result<PrepareAccountSessionResponse, AccountSessionError>, RejectResponse> {
756+
call_candid_as(
757+
env,
758+
canister_id,
759+
RawEffectivePrincipal::None,
760+
sender,
761+
"prepare_account_session",
762+
(request,),
763+
)
764+
.map(|(x,)| x)
765+
}
766+
767+
pub fn get_account_session(
768+
env: &PocketIc,
769+
canister_id: CanisterId,
770+
sender: Principal,
771+
request: GetAccountSessionRequest,
772+
) -> Result<Result<GetAccountSessionResponse, AccountSessionError>, RejectResponse> {
773+
query_candid_as(env, canister_id, sender, "get_account_session", (request,)).map(|(x,)| x)
774+
}

src/canister_tests/src/framework.rs

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,70 @@ pub fn restore_compressed_stable_memory(env: &PocketIc, canister_id: CanisterId,
375375
env.set_stable_memory(canister_id, buffer, BlobCompression::Gzip);
376376
}
377377

378+
/// A browser key of the kind `prepare_account_session` demands a proof from.
379+
///
380+
/// The DER encoding and the domain prefix have to match what the canister verifies,
381+
/// so both are spelled out here rather than derived.
382+
pub struct BrowserKey {
383+
signing_key: p256::ecdsa::SigningKey,
384+
}
385+
386+
/// The SPKI header WebCrypto emits for an `ECDSA` P-256 public key, ahead of the 65-byte
387+
/// uncompressed point.
388+
const P256_SPKI_HEADER: [u8; 26] = [
389+
0x30, 0x59, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a,
390+
0x86, 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07, 0x03, 0x42, 0x00,
391+
];
392+
393+
const BROWSER_KEY_SIGNATURE_DOMAIN: &[u8] = b"ii-session-browser-key";
394+
const SUCCESSOR_KEY_SIGNATURE_DOMAIN: &[u8] = b"ii-session-browser-successor";
395+
396+
impl BrowserKey {
397+
pub fn new(seed: u8) -> Self {
398+
Self {
399+
signing_key: p256::ecdsa::SigningKey::from_bytes(&[seed; 32].into())
400+
.expect("failed to build a browser key"),
401+
}
402+
}
403+
404+
/// The key a browser rotates to after `self`, so a test can walk the chain.
405+
pub fn successor(&self) -> Self {
406+
let mut seed = [0u8; 32];
407+
seed.copy_from_slice(&self.signing_key.to_bytes());
408+
seed[0] = seed[0].wrapping_add(1);
409+
Self {
410+
signing_key: p256::ecdsa::SigningKey::from_bytes(&seed.into())
411+
.expect("failed to build a browser key"),
412+
}
413+
}
414+
415+
pub fn public_key(&self) -> PublicKey {
416+
let point = p256::ecdsa::VerifyingKey::from(&self.signing_key).to_encoded_point(false);
417+
let mut der = P256_SPKI_HEADER.to_vec();
418+
der.extend_from_slice(point.as_bytes());
419+
ByteBuf::from(der)
420+
}
421+
422+
pub fn sign(&self, session_key: &SessionKey, next_browser_key: &PublicKey) -> ByteBuf {
423+
self.sign_with(BROWSER_KEY_SIGNATURE_DOMAIN, session_key, next_browser_key)
424+
}
425+
426+
/// The successor's own signature, proving the browser holds the key it announces.
427+
pub fn sign_as_successor(&self, session_key: &SessionKey, device_key: &PublicKey) -> ByteBuf {
428+
self.sign_with(SUCCESSOR_KEY_SIGNATURE_DOMAIN, session_key, device_key)
429+
}
430+
431+
fn sign_with(&self, domain: &[u8], session_key: &SessionKey, other: &PublicKey) -> ByteBuf {
432+
use p256::ecdsa::signature::Signer;
433+
434+
let mut message = domain.to_vec();
435+
message.extend_from_slice(session_key);
436+
message.extend_from_slice(other);
437+
let signature: p256::ecdsa::Signature = self.signing_key.sign(&message);
438+
ByteBuf::from(signature.to_bytes().to_vec())
439+
}
440+
}
441+
378442
pub const PUBKEY_1: &str = "test";
379443
pub const PUBKEY_2: &str = "some other key";
380444
pub const RECOVERY_PUBKEY_1: &str = "recovery 1";

src/frontend/src/lib/generated/internet_identity_idl.js

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,26 @@ export const idlFactory = ({ IDL }) => {
386386
'InternalCanisterError' : IDL.Text,
387387
'Unauthorized' : IDL.Principal,
388388
});
389+
const GetAccountSessionRequest = IDL.Record({
390+
'session_id' : IDL.Nat64,
391+
'session_key' : SessionKey,
392+
'origin' : FrontendHostname,
393+
'account_number' : IDL.Opt(AccountNumber),
394+
'expiration' : Timestamp,
395+
'identity_number' : UserNumber,
396+
});
397+
const GetAccountSessionResponse = IDL.Record({
398+
'signed_delegation' : SignedDelegation,
399+
});
400+
const AccountSessionError = IDL.Variant({
401+
'InternalCanisterError' : IDL.Text,
402+
'Unauthorized' : IDL.Principal,
403+
'NoSuchSession' : IDL.Null,
404+
'NoSuchDelegation' : IDL.Null,
405+
'NoSuchAccount' : IDL.Null,
406+
'InvalidBrowserKey' : IDL.Null,
407+
'StaleBrowserKey' : IDL.Null,
408+
});
389409
const GetAccountsError = IDL.Variant({
390410
'InternalCanisterError' : IDL.Text,
391411
'Unauthorized' : IDL.Principal,
@@ -718,6 +738,27 @@ export const idlFactory = ({ IDL }) => {
718738
'user_key' : UserKey,
719739
'expiration' : Timestamp,
720740
});
741+
const PrepareAccountSessionRequest = IDL.Record({
742+
'permissions' : IDL.Opt(Permissions),
743+
'max_idle' : IDL.Opt(IDL.Nat64),
744+
'current_browser_key' : PublicKey,
745+
'session_key' : SessionKey,
746+
'valid_for' : IDL.Opt(IDL.Nat64),
747+
'origin' : FrontendHostname,
748+
'current_browser_key_signature' : IDL.Vec(IDL.Nat8),
749+
'browser_description' : BrowserDescription,
750+
'account_number' : IDL.Opt(AccountNumber),
751+
'identity_number' : UserNumber,
752+
'next_browser_key' : PublicKey,
753+
'next_browser_key_signature' : IDL.Vec(IDL.Nat8),
754+
});
755+
const PrepareAccountSessionResponse = IDL.Record({
756+
'user_key' : PublicKey,
757+
'session_id' : IDL.Nat64,
758+
'browser_id' : IDL.Nat32,
759+
'expiration' : Timestamp,
760+
'account_principal' : IDL.Principal,
761+
});
721762
const PrepareAttributeRequest = IDL.Record({
722763
'origin' : FrontendHostname,
723764
'attribute_keys' : IDL.Vec(IDL.Text),
@@ -1071,6 +1112,16 @@ export const idlFactory = ({ IDL }) => {
10711112
],
10721113
['query'],
10731114
),
1115+
'get_account_session' : IDL.Func(
1116+
[GetAccountSessionRequest],
1117+
[
1118+
IDL.Variant({
1119+
'Ok' : GetAccountSessionResponse,
1120+
'Err' : AccountSessionError,
1121+
}),
1122+
],
1123+
['query'],
1124+
),
10741125
'get_accounts' : IDL.Func(
10751126
[UserNumber, FrontendHostname],
10761127
[
@@ -1327,6 +1378,16 @@ export const idlFactory = ({ IDL }) => {
13271378
],
13281379
[],
13291380
),
1381+
'prepare_account_session' : IDL.Func(
1382+
[PrepareAccountSessionRequest],
1383+
[
1384+
IDL.Variant({
1385+
'Ok' : PrepareAccountSessionResponse,
1386+
'Err' : AccountSessionError,
1387+
}),
1388+
],
1389+
[],
1390+
),
13301391
'prepare_attributes' : IDL.Func(
13311392
[PrepareAttributeRequest],
13321393
[

src/frontend/src/lib/generated/internet_identity_types.d.ts

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,32 @@ export interface AccountInfo {
1919
'last_used' : [] | [Timestamp],
2020
}
2121
export type AccountNumber = bigint;
22+
export type AccountSessionError = { 'InternalCanisterError' : string } |
23+
{ 'Unauthorized' : Principal } |
24+
{ 'NoSuchSession' : null } |
25+
{
26+
/**
27+
* The session is there, but no delegation was signed for the session_key and
28+
* expiration asked for. Ask again with the ones prepare_account_session returned;
29+
* signing in afresh is not the remedy.
30+
*/
31+
'NoSuchDelegation' : null
32+
} |
33+
{ 'NoSuchAccount' : null } |
34+
{
35+
/**
36+
* The browser's key is unusable, or its signature does not verify against it.
37+
*/
38+
'InvalidBrowserKey' : null
39+
} |
40+
{
41+
/**
42+
* The browser presented a key it has already rotated away from, which happens when it
43+
* never learned that its last sign-in succeeded. It holds the successor that does
44+
* resolve, so the answer is to promote that one and present it.
45+
*/
46+
'StaleBrowserKey' : null
47+
};
2248
export interface AccountUpdate { 'name' : [] | [string] }
2349
export type AddTentativeDeviceResponse = {
2450
/**
@@ -696,6 +722,20 @@ export type GetAccountError = {
696722
'anchor_number' : UserNumber,
697723
}
698724
};
725+
export interface GetAccountSessionRequest {
726+
/**
727+
* The session prepare_account_session created.
728+
*/
729+
'session_id' : bigint,
730+
'session_key' : SessionKey,
731+
'origin' : FrontendHostname,
732+
'account_number' : [] | [AccountNumber],
733+
'expiration' : Timestamp,
734+
'identity_number' : UserNumber,
735+
}
736+
export interface GetAccountSessionResponse {
737+
'signed_delegation' : SignedDelegation,
738+
}
699739
export type GetAccountsError = { 'InternalCanisterError' : string } |
700740
{ 'Unauthorized' : Principal };
701741
export type GetAttributesError = { 'AuthorizationError' : Principal } |
@@ -1334,6 +1374,76 @@ export interface PrepareAccountDelegation {
13341374
'user_key' : UserKey,
13351375
'expiration' : Timestamp,
13361376
}
1377+
export interface PrepareAccountSessionRequest {
1378+
/**
1379+
* The consented access level, fixed for the session's life.
1380+
*/
1381+
'permissions' : [] | [Permissions],
1382+
/**
1383+
* How long the session may go unminted before it is over, clamped to between
1384+
* 10 minutes and the session's own granted length. Absent leaves the
1385+
* canister's own default.
1386+
*/
1387+
'max_idle' : [] | [bigint],
1388+
/**
1389+
* The browser's own public key, DER-encoded, as the registry currently holds it. A
1390+
* key this anchor has not seen registers a browser under it.
1391+
*/
1392+
'current_browser_key' : PublicKey,
1393+
/**
1394+
* The II frontend's own public key.
1395+
*/
1396+
'session_key' : SessionKey,
1397+
/**
1398+
* Clamped to the session maximum.
1399+
*/
1400+
'valid_for' : [] | [bigint],
1401+
'origin' : FrontendHostname,
1402+
/**
1403+
* Signature over session_key and next_browser_key, verified with current_browser_key.
1404+
*/
1405+
'current_browser_key_signature' : Uint8Array | number[],
1406+
/**
1407+
* What this browser is, for the user's session list.
1408+
*/
1409+
'browser_description' : BrowserDescription,
1410+
'account_number' : [] | [AccountNumber],
1411+
'identity_number' : UserNumber,
1412+
/**
1413+
* What the browser rotates to once this sign-in succeeds. Must differ from
1414+
* current_browser_key: a browser that never rotates keeps a leaked key useful.
1415+
*/
1416+
'next_browser_key' : PublicKey,
1417+
/**
1418+
* Signature by next_browser_key over session_key and current_browser_key, proving the
1419+
* browser holds the key it is announcing.
1420+
*/
1421+
'next_browser_key_signature' : Uint8Array | number[],
1422+
}
1423+
export interface PrepareAccountSessionResponse {
1424+
'user_key' : PublicKey,
1425+
/**
1426+
* Names the session this ceremony created, and is what get_account_session is given
1427+
* to collect the delegation signed for it. Not a credential: it names a session, it
1428+
* does not authorise one.
1429+
*/
1430+
'session_id' : bigint,
1431+
/**
1432+
* Which browser this sign-in was attributed to, so the settings list can mark the one
1433+
* the user is looking at, and so the browser knows which registration its key now
1434+
* belongs to. Not a credential: a caller never presents it.
1435+
*/
1436+
'browser_id' : number,
1437+
/**
1438+
* The session's valid_till.
1439+
*/
1440+
'expiration' : Timestamp,
1441+
/**
1442+
* The principal apps see for this account, so the frontend can tell its own
1443+
* sessions apart without minting a delegation to learn it.
1444+
*/
1445+
'account_principal' : Principal,
1446+
}
13371447
export type PrepareAttributeError = { 'AuthorizationError' : Principal } |
13381448
{ 'ValidationError' : { 'problems' : Array<string> } } |
13391449
{ 'GetAccountError' : GetAccountError };
@@ -2103,6 +2213,11 @@ export interface _SERVICE {
21032213
{ 'Ok' : SignedDelegation } |
21042214
{ 'Err' : AccountDelegationError }
21052215
>,
2216+
'get_account_session' : ActorMethod<
2217+
[GetAccountSessionRequest],
2218+
{ 'Ok' : GetAccountSessionResponse } |
2219+
{ 'Err' : AccountSessionError }
2220+
>,
21062221
/**
21072222
* Multiple accounts
21082223
*/
@@ -2395,6 +2510,17 @@ export interface _SERVICE {
23952510
{ 'Ok' : PrepareAccountDelegation } |
23962511
{ 'Err' : AccountDelegationError }
23972512
>,
2513+
/**
2514+
* Creates or reuses a revocable session at one account and signs its identity to
2515+
* the II frontend's own key. Called only by the II frontend, which ships with the
2516+
* canister; requires an anchor access method, so a session can neither spawn nor
2517+
* extend itself.
2518+
*/
2519+
'prepare_account_session' : ActorMethod<
2520+
[PrepareAccountSessionRequest],
2521+
{ 'Ok' : PrepareAccountSessionResponse } |
2522+
{ 'Err' : AccountSessionError }
2523+
>,
23982524
/**
23992525
* Attribute sharing protocol
24002526
* ==========================

0 commit comments

Comments
 (0)