Skip to content

feat(fe): hold a browser key and rotate it on every sign-in - #4271

Open
sea-snake wants to merge 13 commits into
fe/describe-browserfrom
fe/browser-key-store
Open

feat(fe): hold a browser key and rotate it on every sign-in#4271
sea-snake wants to merge 13 commits into
fe/describe-browserfrom
fe/browser-key-store

Conversation

@sea-snake

@sea-snake sea-snake commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Design: #4224. Overview: #4230. Nothing imports this until the sign-in in #4273.

The canister identifies a browser by a key it proves possession of (#4264), so the frontend has to hold one, and rotate it, so a key captured from one sign-in cannot claim that browser at the next.

A non-extractable P-256 keypair in IndexedDB, per identity, plus a successor generated alongside it. A sign-in signs with the current key and announces the successor; the successor is promoted only once the canister has confirmed the sign-in.

Promoting after confirmation rather than before is what survives a lost response. If the reply never arrives, the browser still holds the key the canister has, so its next attempt presents the same one rather than a successor the canister never saw — which the canister would treat as an unknown browser and enrol as a second entry for the same machine.

Sign-ins are serialised with a web lock, because two at once would leave whichever wrote last holding a key the canister never accepted. Where the Web Locks API is missing the calls run unserialised: not blocking sign-in on it is the deliberate trade, and the window is one concurrent sign-in on the same device.

The private key never leaves IndexedDB and never appears in a delegation chain.

Tests: browser-key.store.test.ts (14), including the successor promoted only after the call succeeds, a lost response leaving the current key in place, concurrent sign-ins serialised, and the store working with the lock API absent.

@zeropath-ai

zeropath-ai Bot commented Aug 22, 2026

Copy link
Copy Markdown

No security or compliance issues detected. Reviewed everything up to 563d5ea.

Security Overview
Detected Code Changes
Change Type Relevant files
Enhancement ► src/frontend/src/lib/stores/browser-key.store.ts
    Introduce browser-key store with browser-based signing and rotation logic
Enhancement ► src/frontend/src/lib/stores/browser-key.store.test.ts
    Add tests for browser-key store functionality (sign-in, rotation, identity separation, storage, concurrency, and device tracking)

@sea-snake
sea-snake force-pushed the fe/browser-key-store branch from 928d1be to 836aef3 Compare August 22, 2026 18:45
@sea-snake
sea-snake force-pushed the fe/browser-key-store branch from 836aef3 to 0938c39 Compare August 22, 2026 19:07
@sea-snake
sea-snake force-pushed the fe/browser-key-store branch from 0938c39 to 2e2b753 Compare August 22, 2026 19:25
The canister identifies a browser by a key it proves possession of, so the
frontend has to hold one. A non-extractable P-256 keypair in IndexedDB, per
identity, and a successor generated alongside it: the sign-in signs with the
current key and announces the successor, and the successor is promoted only once
the canister confirms the sign-in.

Promoting after confirmation rather than before is what survives a lost
response: the browser still holds the key the canister has, so its next attempt
presents the same one rather than a successor the canister never saw.

Sign-ins are serialised with a web lock, because two at once would leave
whichever wrote last holding a key the canister never accepted. Where the Web
Locks API is missing the calls run unserialised, which is the accepted cost of
not blocking sign-in on it.

Nothing imports this yet; the sign-in that uses it lands two PRs up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sea-snake
sea-snake force-pushed the fe/browser-key-store branch from 2e2b753 to f7b8adf Compare August 22, 2026 19:49
"verify",
]) as Promise<CryptoKeyPair>;

const read = async (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we shouldn't mix errors with a missing key here. I would do undefined for no key found and return something else in case we wanna handle this with a retry..

}
};

const write = async (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should the initial key write be allowed to fail silently? If the key isn’t persisted, this sign-in can still register a browser, but the next sign-in has no key and registers another one. Repeating that can fill the 20-browser limit and eventually evict other browsers’ sessions. Maybe we could have the first write be required before calling the canister, while the successor write after acceptance can stay best-effort.

/** By the successor itself, so a key the browser does not hold cannot be announced. */
nextSignature: Uint8Array;
/** Rotates to the successor. Called once the canister has accepted the sign-in. */
accept: (deviceId: number) => Promise<void>;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a specific reason for having the caller confirming that the canister accepted the proof? If not maybe we could have this in withBrowserProof and it owns the whole thing?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature:revocable-app-sessions Design: revocable app sessions

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants