feat(fe): hold a browser key and rotate it on every sign-in - #4271
feat(fe): hold a browser key and rotate it on every sign-in#4271sea-snake wants to merge 13 commits into
Conversation
|
✅ No security or compliance issues detected. Reviewed everything up to 563d5ea. Security Overview
Detected Code Changes
|
928d1be to
836aef3
Compare
836aef3 to
0938c39
Compare
0938c39 to
2e2b753
Compare
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>
2e2b753 to
f7b8adf
Compare
| "verify", | ||
| ]) as Promise<CryptoKeyPair>; | ||
|
|
||
| const read = async ( |
There was a problem hiding this comment.
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 ( |
There was a problem hiding this comment.
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>; |
There was a problem hiding this comment.
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?
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.