diff --git a/src/frontend/src/lib/stores/browser-key.store.test.ts b/src/frontend/src/lib/stores/browser-key.store.test.ts new file mode 100644 index 0000000000..1906434871 --- /dev/null +++ b/src/frontend/src/lib/stores/browser-key.store.test.ts @@ -0,0 +1,402 @@ +import "fake-indexeddb/auto"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { clear, createStore, set as idbSet } from "idb-keyval"; + +/** Lets one test refuse a write, which is the only way the store ends up holding a key + * without the successor it announced. */ +const storage = vi.hoisted(() => ({ writesFail: false })); + +vi.mock("idb-keyval", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + set: (...args: Parameters) => + storage.writesFail + ? Promise.reject(new Error("quota exceeded")) + : actual.set(...args), + }; +}); +import { + type BrowserProof, + currentBrowserId, + StaleBrowserKeyError, + withBrowserProof, +} from "./browser-key.store"; +import type { BrowserDescription } from "$lib/generated/internet_identity_types"; + +/// Names the same store the module under test writes to, so a test can wipe it. +const BROWSER_KEY_STORE = createStore("ii-browser-keys", "keys"); + +const SIGNATURE_DOMAIN = new TextEncoder().encode("ii-session-browser-key"); +const SUCCESSOR_SIGNATURE_DOMAIN = new TextEncoder().encode( + "ii-session-browser-successor", +); + +const signedMessage = ( + domain: Uint8Array, + sessionKey: Uint8Array, + otherKey: Uint8Array, +): Uint8Array => { + const message = new Uint8Array( + domain.length + sessionKey.length + otherKey.length, + ); + message.set(domain); + message.set(sessionKey, domain.length); + message.set(otherKey, domain.length + sessionKey.length); + return message; +}; + +const verify = async ( + publicKey: Uint8Array, + signature: Uint8Array, + message: Uint8Array, +): Promise => { + const key = await crypto.subtle.importKey( + "spki", + new Uint8Array(publicKey), + { name: "ECDSA", namedCurve: "P-256" }, + true, + ["verify"], + ); + return crypto.subtle.verify( + { name: "ECDSA", hash: "SHA-256" }, + key, + new Uint8Array(signature), + new Uint8Array(message), + ); +}; + +const sessionKey = (seed: number) => new Uint8Array(62).fill(seed); + +const IDENTITY = BigInt(10_000); + +const CHROME_ON_A_MAC: BrowserDescription = { + brand: { Chrome: null }, + os: { Macos: null }, + form_factor: { Desktop: null }, + model: [], +}; + +const FIREFOX_ON_A_MAC: BrowserDescription = { + ...CHROME_ON_A_MAC, + brand: { Firefox: null }, +}; + +/** Signs in and rotates, the way a successful ceremony does. */ +const signIn = ( + identityNumber: bigint, + seed: number, + browserId = 1, + description: BrowserDescription = CHROME_ON_A_MAC, +) => + withBrowserProof( + identityNumber, + sessionKey(seed), + description, + (proof) => Promise.resolve(proof), + () => browserId, + ); + +/** Signs in without the canister answering, the way a call that fails leaves it: the + * rotation belongs to a sign-in that came back, so nothing advances here. The proof is + * captured on the way past, since there is no value to return. */ +const attempt = async ( + identityNumber: bigint, + seed: number, + description: BrowserDescription = CHROME_ON_A_MAC, +): Promise => { + let attempted: BrowserProof | undefined; + await withBrowserProof( + identityNumber, + sessionKey(seed), + description, + (proof) => { + attempted = proof; + return Promise.reject(new Error("no answer")); + }, + () => 1, + ).catch(() => undefined); + if (attempted === undefined) { + throw new Error("the proof was never built"); + } + return attempted; +}; + +/** jsdom has no Web Locks, so this is what serialisation is tested against. */ +const stubLockApi = (): void => { + let tail: Promise = Promise.resolve(); + Object.defineProperty(navigator, "locks", { + configurable: true, + value: { + request: (_name: string, run: () => Promise) => { + const next = tail.then(run); + tail = next.then( + () => undefined, + () => undefined, + ); + return next; + }, + }, + }); +}; + +const withoutLockApi = (): void => { + Object.defineProperty(navigator, "locks", { + configurable: true, + value: undefined, + }); +}; + +describe("browser key", () => { + beforeEach(async () => { + storage.writesFail = false; + await clear(BROWSER_KEY_STORE); + withoutLockApi(); + }); + + it("signs the session key and the successor under the domain the canister verifies", async () => { + const key = sessionKey(1); + + const proof = await attempt(IDENTITY, 1); + + await expect( + verify( + proof.publicKey, + proof.signature, + signedMessage(SIGNATURE_DOMAIN, key, proof.nextPublicKey), + ), + ).resolves.toBe(true); + }); + + it("does not sign the session key alone", async () => { + const proof = await attempt(IDENTITY, 1); + + await expect( + verify(proof.publicKey, proof.signature, sessionKey(1)), + ).resolves.toBe(false); + }); + + it("announces a successor it does not yet use", async () => { + const proof = await attempt(IDENTITY, 1); + + expect(proof.nextPublicKey).not.toEqual(proof.publicKey); + }); + + it("rotates to the successor once a sign-in is accepted", async () => { + const first = await signIn(IDENTITY, 1); + + const second = await attempt(IDENTITY, 2); + + expect(second.publicKey).toEqual(first.nextPublicKey); + }); + + it("keeps the current key when a sign-in is not accepted", async () => { + const first = await attempt(IDENTITY, 1); + + const second = await attempt(IDENTITY, 2); + + expect(second.publicKey).toEqual(first.publicKey); + }); + + /// The canister may have accepted the sign-in and never told us, and from then on the + /// announced successor is the only key that reaches our entry. Announcing a fresh one + /// instead would leave the entry waiting for a key nobody holds. + it("re-announces the successor it already announced", async () => { + const first = await attempt(IDENTITY, 1); + + const second = await attempt(IDENTITY, 2); + + expect(second.nextPublicKey).toEqual(first.nextPublicKey); + }); + + it("promotes the announced successor when the canister calls the key stale", async () => { + const first = await attempt(IDENTITY, 1); + + let seen = 0; + const proof = await withBrowserProof( + IDENTITY, + sessionKey(2), + CHROME_ON_A_MAC, + (attempted) => { + seen += 1; + if (seen === 1) { + return Promise.reject(new StaleBrowserKeyError()); + } + return Promise.resolve(attempted); + }, + () => 1, + ); + + expect(seen).toBe(2); + expect(proof.publicKey).toEqual(first.nextPublicKey); + }); + + /// Reachable because a write is allowed to fail silently: a browser that could not keep + /// the successor it announced holds nothing the canister's entry is waiting for. A second + /// row in the user's list beats a browser that can never sign in again. + it("starts over when a stale key has no successor to promote", async () => { + const orphaned = await crypto.subtle.generateKey( + { name: "ECDSA", namedCurve: "P-256" }, + false, + ["sign", "verify"], + ); + await idbSet(IDENTITY.toString(), { keyPair: orphaned }, BROWSER_KEY_STORE); + const stranded = new Uint8Array( + await crypto.subtle.exportKey("spki", orphaned.publicKey), + ); + storage.writesFail = true; + + let seen = 0; + const proof = await withBrowserProof( + IDENTITY, + sessionKey(1), + CHROME_ON_A_MAC, + (attempted) => { + seen += 1; + return seen === 1 + ? Promise.reject(new StaleBrowserKeyError()) + : Promise.resolve(attempted); + }, + () => 1, + ); + + expect(seen).toBe(2); + expect(proof.publicKey).not.toEqual(stranded); + }); + + it("does not retry a failure that is not a stale key", async () => { + let seen = 0; + + await expect( + withBrowserProof( + IDENTITY, + sessionKey(1), + CHROME_ON_A_MAC, + () => { + seen += 1; + return Promise.reject(new Error("network")); + }, + () => 1, + ), + ).rejects.toThrow("network"); + expect(seen).toBe(1); + }); + + /// A registered entry keeps the description it was created with, so a browser that + /// reports something else is one the canister has not seen. Presenting a key pair no + /// entry holds is what registers it under its own, and it is the client that decides + /// so — the canister ignores the description on a sign-in that advances an entry. + it("signs in with a new key pair once the description changes", async () => { + const first = await signIn(IDENTITY, 1, 1, CHROME_ON_A_MAC); + + const second = await signIn(IDENTITY, 2, 2, FIREFOX_ON_A_MAC); + + expect(second.publicKey).not.toEqual(first.publicKey); + expect(second.publicKey).not.toEqual(first.nextPublicKey); + }); + + it("keeps rotating while the description is the one it registered with", async () => { + const first = await signIn(IDENTITY, 1, 1, CHROME_ON_A_MAC); + + const second = await signIn(IDENTITY, 2, 1, CHROME_ON_A_MAC); + + expect(second.publicKey).toEqual(first.nextPublicKey); + }); + + /// The description is stored with the key pair, so the comparison is always against + /// what the canister was actually sent. A browser that changed twice starts over once + /// per change rather than once per sign-in. + it("settles on the new description after it has changed", async () => { + await signIn(IDENTITY, 1, 1, CHROME_ON_A_MAC); + const forked = await signIn(IDENTITY, 2, 2, FIREFOX_ON_A_MAC); + + const after = await signIn(IDENTITY, 3, 2, FIREFOX_ON_A_MAC); + + expect(after.publicKey).toEqual(forked.nextPublicKey); + }); + + it("holds a separate key per identity", async () => { + const first = await attempt(IDENTITY, 1); + + const second = await attempt(BigInt(10_001), 1); + + expect(second.publicKey).not.toEqual(first.publicKey); + }); + + it("registers a fresh key once storage is cleared", async () => { + const before = await signIn(IDENTITY, 1); + await clear(BROWSER_KEY_STORE); + + const after = await attempt(IDENTITY, 1); + + expect(after.publicKey).not.toEqual(before.publicKey); + expect(after.publicKey).not.toEqual(before.nextPublicKey); + }); + + it("serialises concurrent sign-ins, so the second builds on the first", async () => { + stubLockApi(); + + const [first, second] = await Promise.all([ + signIn(IDENTITY, 1), + signIn(IDENTITY, 2), + ]); + + expect(second.publicKey).toEqual(first.nextPublicKey); + }); + + it("still signs in on a browser without the lock API", async () => { + const proof = await attempt(IDENTITY, 1); + + expect(proof.publicKey.length).toBe(91); + }); + + it("exports the keys in the encoding the canister parses", async () => { + const proof = await attempt(IDENTITY, 1); + + expect(proof.publicKey.length).toBe(91); + expect(proof.nextPublicKey.length).toBe(91); + expect(proof.signature.length).toBe(64); + }); + + it("remembers which browser the canister said this is", async () => { + await signIn(IDENTITY, 1, 7); + + await expect(currentBrowserId(IDENTITY)).resolves.toBe(7); + }); + + it("knows of no browser before a sign-in is accepted", async () => { + await attempt(IDENTITY, 1); + + await expect(currentBrowserId(IDENTITY)).resolves.toBeUndefined(); + }); + + it("has the successor sign for itself, so an unheld key cannot be announced", async () => { + const key = sessionKey(1); + + const proof = await attempt(IDENTITY, 1); + + await expect( + verify( + proof.nextPublicKey, + proof.nextSignature, + signedMessage(SUCCESSOR_SIGNATURE_DOMAIN, key, proof.publicKey), + ), + ).resolves.toBe(true); + }); + + it("keeps the two signatures in their own roles", async () => { + const key = sessionKey(1); + + const proof = await attempt(IDENTITY, 1); + + // The successor's signature must not verify as the current key's, or one could be + // replayed as the other. + await expect( + verify( + proof.publicKey, + proof.nextSignature, + signedMessage(SIGNATURE_DOMAIN, key, proof.nextPublicKey), + ), + ).resolves.toBe(false); + }); +}); diff --git a/src/frontend/src/lib/stores/browser-key.store.ts b/src/frontend/src/lib/stores/browser-key.store.ts new file mode 100644 index 0000000000..53bd5ea8a1 --- /dev/null +++ b/src/frontend/src/lib/stores/browser-key.store.ts @@ -0,0 +1,307 @@ +import { createStore, get as idbGet, set as idbSet } from "idb-keyval"; +import type { BrowserDescription } from "$lib/generated/internet_identity_types"; + +/** + * The key this browser proves itself with when it creates a session, and the id the + * canister attributed it to. + * + * The key never leaves this origin: it appears in no delegation chain and in nothing an app + * receives, which is what lets it identify the browser without letting two apps recognise + * it. It is replaced at every sign-in, so a copy of it taken off disk stops working as soon + * as this browser signs in again. + */ +interface BrowserKeyRecord { + keyPair: CryptoKeyPair; + /** The successor announced at the last sign-in, kept from before the call until that + * sign-in is known to have been accepted. The canister reaches this browser's entry + * only through the successor it announced, so losing this key while the canister kept + * it would leave the browser unable to prove it is itself ever again. */ + announcedSuccessor?: CryptoKeyPair; + /** Absent until a sign-in has told us which browser we are. */ + browserId?: number; + /** What was reported when this browser registered, so a change can be noticed. + * Written with the key pair and never on its own: compared against what the canister + * stored, it has to be what we actually sent when the entry was created. */ + description?: BrowserDescription; +} + +/** + * Thrown by a sign-in the canister refused because this browser's key is one it has + * already retired, which is what a lost response leaves behind. + * + * Raised by the caller that can read the canister's answer; handled here, because this is + * where the successor that does resolve is kept. + */ +export class StaleBrowserKeyError extends Error { + constructor() { + super("the canister has already retired this browser's key"); + this.name = "StaleBrowserKeyError"; + } +} + +const BROWSER_KEY_STORE = createStore("ii-browser-keys", "keys"); + +/** Must match the domains the canister verifies the two signatures under. */ +const SIGNATURE_DOMAIN = new TextEncoder().encode("ii-session-browser-key"); +const SUCCESSOR_SIGNATURE_DOMAIN = new TextEncoder().encode( + "ii-session-browser-successor", +); + +/** + * One key per identity, so nothing stored here links two of the user's identities to the + * same browser. + */ +const storageKey = (identityNumber: bigint): string => + identityNumber.toString(); + +const generate = (): Promise => + crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, false, [ + "sign", + "verify", + ]) as Promise; + +const read = async ( + identityNumber: bigint, +): Promise => { + try { + return await idbGet( + storageKey(identityNumber), + BROWSER_KEY_STORE, + ); + } catch { + return undefined; + } +}; + +const write = async ( + identityNumber: bigint, + record: BrowserKeyRecord, +): Promise => { + try { + await idbSet(storageKey(identityNumber), record, BROWSER_KEY_STORE); + } catch { + // A browser that cannot keep its key signs in as a new one next time, which the + // identity sees as a new browser rather than as a failure. + } +}; + +const exported = (key: CryptoKey): Promise => + crypto.subtle.exportKey("spki", key).then((spki) => new Uint8Array(spki)); + +const signed = async ( + key: CryptoKey, + domain: Uint8Array, + sessionKey: Uint8Array, + otherKey: Uint8Array, +): Promise => { + const message = new Uint8Array( + domain.length + sessionKey.length + otherKey.length, + ); + message.set(domain); + message.set(sessionKey, domain.length); + message.set(otherKey, domain.length + sessionKey.length); + return new Uint8Array( + await crypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, key, message), + ); +}; + +export interface BrowserProof { + publicKey: Uint8Array; + nextPublicKey: Uint8Array; + signature: Uint8Array; + /** By the successor itself, so a key the browser does not hold cannot be announced. */ + nextSignature: Uint8Array; +} + +/** Serialises sign-ins for one identity: two at once would leave us holding a key the + * canister never accepted, which reads as a different browser. */ +const exclusively = async ( + identityNumber: bigint, + run: () => Promise, +): Promise => { + const locks = navigator.locks; + if (locks === undefined) { + return run(); + } + // Awaited, because `request` types its callback's return as the value it resolves to, + // so the promise `run` returns would otherwise nest. + return await locks.request(`ii-browser-key:${identityNumber}`, run); +}; + +/** The variant's tag and its payload, which is all a description is made of. */ +const token = (variant: object): string => Object.entries(variant)[0].join(":"); + +const sameDescription = ( + one: BrowserDescription, + other: BrowserDescription, +): boolean => + token(one.brand) === token(other.brand) && + token(one.os) === token(other.os) && + token(one.form_factor) === token(other.form_factor) && + (one.model[0] ?? "") === (other.model[0] ?? ""); + +/** + * Returns the stored record unless the browser description changed; in that case, rotates + * to a fresh record so this sign-in registers as a new browser. + * + * A registered entry keeps the description it was created with, so a browser reporting + * something else is one the canister has not seen. Rather than ask for an entry to be + * changed, this presents a key pair no entry holds, which registers under its own — and + * because nothing has been sent yet, a browser that gets this far has not disturbed the + * entry it is leaving behind. + */ +const forDescription = async ( + identityNumber: bigint, + description: BrowserDescription, +): Promise => { + const stored = await read(identityNumber); + if ( + stored?.description === undefined || + sameDescription(stored.description, description) + ) { + return stored; + } + const fresh: BrowserKeyRecord = { + keyPair: await generate(), + announcedSuccessor: await generate(), + }; + await write(identityNumber, fresh); + return fresh; +}; + +/** The record a sign-in proves with: what is stored, completed with whatever it lacks. */ +const prepared = async ( + identityNumber: bigint, + from?: BrowserKeyRecord, +): Promise< + Required> +> => { + const stored = from ?? (await read(identityNumber)); + const keyPair = stored?.keyPair ?? (await generate()); + // Both halves go on disk before the call. The canister may accept this sign-in and + // never tell us, and from that moment the only key that reaches our entry is the + // successor we announced — a successor generated and discarded per attempt would be + // gone with the response that carried it. + const announcedSuccessor = stored?.announcedSuccessor ?? (await generate()); + if ( + stored?.keyPair !== keyPair || + stored?.announcedSuccessor !== announcedSuccessor + ) { + await write(identityNumber, { ...stored, keyPair, announcedSuccessor }); + } + return { keyPair, announcedSuccessor }; +}; + +/** One attempt, proving `keyPair` and announcing `announcedSuccessor`. */ +const attempt = async ( + identityNumber: bigint, + sessionKey: Uint8Array, + description: BrowserDescription, + signIn: (proof: BrowserProof) => Promise, + browserIdOf: (value: T) => number, + from?: BrowserKeyRecord, +): Promise => { + const { keyPair, announcedSuccessor: successor } = await prepared( + identityNumber, + from, + ); + + const [publicKey, nextPublicKey] = await Promise.all([ + exported(keyPair.publicKey), + exported(successor.publicKey), + ]); + + const [signature, nextSignature] = await Promise.all([ + signed(keyPair.privateKey, SIGNATURE_DOMAIN, sessionKey, nextPublicKey), + signed( + successor.privateKey, + SUCCESSOR_SIGNATURE_DOMAIN, + sessionKey, + publicKey, + ), + ]); + + const value = await signIn({ + publicKey, + nextPublicKey, + signature, + nextSignature, + }); + + // The canister accepted, so this browser is now the successor it announced. Done here + // rather than handed back as something to call: a caller that forgot would keep + // proving with the key the canister has just retired, and pay a recovery round-trip at + // every later sign-in with nothing to say why. + await write(identityNumber, { + keyPair: successor, + browserId: browserIdOf(value), + description, + }); + return value; +}; + +/** + * Proves possession of this browser's key and announces the successor it rotates to. + * + * The proof covers the session key, which is fresh for every session, so it is good for + * exactly one sign-in. Advancing to the successor is this function's own job, done once + * the canister has answered — which is why the id is asked for as `browserIdOf` rather + * than left to the caller to hand back. + * + * The canister accepts only the successor an entry is waiting for, so a sign-in whose + * response was lost leaves this browser proving with a key that has since been retired. + * That is refused rather than registered afresh, and this is the only party holding the + * key that does resolve: on refusal the announced successor is promoted and the sign-in + * runs once more. The old key is discarded only after that succeeds. + */ +export const withBrowserProof = ( + identityNumber: bigint, + sessionKey: Uint8Array, + description: BrowserDescription, + signIn: (proof: BrowserProof) => Promise, + /** Which browser the canister said this is, read off whatever `signIn` returned. A + * required argument rather than a callback to remember: the id and the rotation are + * written together, and `tsc` refuses a caller that offers neither. */ + browserIdOf: (value: T) => number, +): Promise => + exclusively(identityNumber, async () => { + const from = await forDescription(identityNumber, description); + try { + return await attempt( + identityNumber, + sessionKey, + description, + signIn, + browserIdOf, + from, + ); + } catch (error) { + if (!(error instanceof StaleBrowserKeyError)) { + throw error; + } + const stored = await read(identityNumber); + // Nothing to promote means the canister holds an entry this browser can no longer + // reach — only possible where a write was lost, since the successor is stored before + // it is announced. Starting over costs a second row in the user's list, which beats + // a browser that can never sign in again. + const promoted: BrowserKeyRecord = { + keyPair: stored?.announcedSuccessor ?? (await generate()), + }; + // Carried into the retry rather than read back, so a storage failure costs the + // rotation and not the sign-in. + await write(identityNumber, promoted); + return await attempt( + identityNumber, + sessionKey, + description, + signIn, + browserIdOf, + promoted, + ); + } + }); + +/** Which browser the canister knows this one as, for the settings list to mark it. */ +export const currentBrowserId = async ( + identityNumber: bigint, +): Promise => (await read(identityNumber))?.browserId;