Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
111 commits
Select commit Hold shift + click to select a range
f7b8adf
feat(fe): hold a browser key and rotate it on every sign-in
sea-snake Aug 22, 2026
9e63a30
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Aug 25, 2026
982a103
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Aug 25, 2026
407c9b9
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Aug 25, 2026
065e36a
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Aug 31, 2026
1822da1
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Aug 31, 2026
5224974
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Aug 31, 2026
0fdc097
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 1, 2026
ff45af8
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 1, 2026
ac4883d
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 1, 2026
d8e484d
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 1, 2026
b68eeba
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 1, 2026
563d5ea
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 2, 2026
69c35d5
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 4, 2026
fdef0fe
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 4, 2026
2e58991
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 4, 2026
730d8f9
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 4, 2026
78afa45
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 4, 2026
074d2d5
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 4, 2026
01df69a
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 4, 2026
476f1b8
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 5, 2026
cc31ddd
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 5, 2026
c2ad3ee
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 5, 2026
9d8353f
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 5, 2026
4541fb1
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 5, 2026
b8cc134
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 5, 2026
4a79d30
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 5, 2026
918a3d7
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 5, 2026
5c222e6
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 5, 2026
579c407
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 5, 2026
661f7e8
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 5, 2026
e15eba1
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 5, 2026
17b53ff
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 5, 2026
ed28779
feat(fe): promote the announced successor when the canister calls a k…
sea-snake Sep 5, 2026
6f843a2
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 5, 2026
f2621b3
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 5, 2026
d447926
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 5, 2026
71dedd1
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 5, 2026
fbd5299
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 5, 2026
44937f0
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 5, 2026
c0b1f4c
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 5, 2026
11350c8
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 5, 2026
24faebd
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 5, 2026
ef0697f
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 5, 2026
53ebdf6
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 5, 2026
57dc297
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 5, 2026
851e361
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 5, 2026
dd496a6
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 5, 2026
a5b7175
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 5, 2026
764b0ab
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 5, 2026
80c49db
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 6, 2026
32cd7c8
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 6, 2026
7be7d7d
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 6, 2026
325526b
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 6, 2026
d3f488e
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 6, 2026
177ba28
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 6, 2026
e510e1f
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 6, 2026
01ed1e3
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 6, 2026
72a15bf
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 6, 2026
8a0bd40
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 6, 2026
8cab998
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 6, 2026
d707470
refactor(fe): the browser key store says browser
sea-snake Sep 6, 2026
bc7becf
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 6, 2026
e3d5923
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 6, 2026
80504b5
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 6, 2026
c569a4d
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 6, 2026
c1abf60
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 6, 2026
dc17f14
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 7, 2026
0ab888b
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 7, 2026
2db3e99
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 7, 2026
7688631
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 7, 2026
5b8c473
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 7, 2026
0e86187
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 7, 2026
55ee698
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 7, 2026
41311fc
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 7, 2026
6476f67
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 7, 2026
e6ebdd1
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 7, 2026
0d5420e
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 7, 2026
a649fac
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 7, 2026
6bee296
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 8, 2026
1d23c40
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 8, 2026
741b812
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 8, 2026
591173e
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 8, 2026
0604a3c
chore: merge fe/describe-browser
sea-snake Sep 8, 2026
d2a9c35
chore: merge fe/describe-browser
sea-snake Sep 8, 2026
3aecc8b
chore: merge fe/describe-browser
sea-snake Sep 8, 2026
ed43310
chore: merge fe/describe-browser
sea-snake Sep 8, 2026
f366042
chore: merge fe/describe-browser
sea-snake Sep 8, 2026
2026c27
chore: merge fe/describe-browser
sea-snake Sep 8, 2026
d12543e
feat(frontend): a browser that no longer matches signs in as a new one
sea-snake Sep 8, 2026
eedeabc
chore: merge fe/describe-browser
sea-snake Sep 8, 2026
9dd96d0
chore: merge fe/describe-browser
sea-snake Sep 9, 2026
5fe7ee2
chore: merge fe/describe-browser
sea-snake Sep 9, 2026
2e79018
test(frontend): fork against a brand that is still named
sea-snake Sep 9, 2026
be1ac87
chore: merge fe/describe-browser
sea-snake Sep 9, 2026
6ec18f3
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 9, 2026
76519f3
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 9, 2026
abe58e1
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 9, 2026
edd5e61
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 9, 2026
9953678
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 9, 2026
c157b27
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 9, 2026
c4fe9c3
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 9, 2026
1befa72
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 9, 2026
5a5035f
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 10, 2026
b07e825
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 10, 2026
b9d5f7b
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 10, 2026
ee0a03f
refactor(browser-key): the store rotates, rather than asking to be to…
sea-snake Sep 10, 2026
b0e1a39
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 10, 2026
8728364
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 10, 2026
7771951
refactor(browser-key): name the successor the record holds
sea-snake Sep 10, 2026
80c7e09
Merge branch 'fe/describe-browser' into fe/browser-key-store
sea-snake Sep 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
225 changes: 225 additions & 0 deletions src/frontend/src/lib/stores/browser-key.store.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
import "fake-indexeddb/auto";
import { beforeEach, describe, expect, it } from "vitest";
import { clear, createStore } from "idb-keyval";
import { currentDeviceId, withBrowserProof } from "./browser-key.store";

/// 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-device-key");
const SUCCESSOR_SIGNATURE_DOMAIN = new TextEncoder().encode(
"ii-session-device-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<boolean> => {
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);

/** Signs in and rotates, the way a successful ceremony does. */
const signIn = (identityNumber: bigint, seed: number, deviceId = 1) =>
withBrowserProof(identityNumber, sessionKey(seed), async (proof) => {
await proof.accept(deviceId);
return proof;
});

/** Signs in without accepting, the way a call that fails or never returns leaves it. */
const attempt = (identityNumber: bigint, seed: number) =>
withBrowserProof(identityNumber, sessionKey(seed), (proof) =>
Promise.resolve(proof),
);

/** jsdom has no Web Locks, so this is what serialisation is tested against. */
const stubLockApi = (): void => {
let tail: Promise<unknown> = Promise.resolve();
Object.defineProperty(navigator, "locks", {
configurable: true,
value: {
request: (_name: string, run: () => Promise<unknown>) => {
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 () => {
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);
expect(second.nextPublicKey).not.toEqual(first.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(currentDeviceId(IDENTITY)).resolves.toBe(7);
});

it("knows of no browser before a sign-in is accepted", async () => {
await attempt(IDENTITY, 1);

await expect(currentDeviceId(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);
});
});
160 changes: 160 additions & 0 deletions src/frontend/src/lib/stores/browser-key.store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { createStore, get as idbGet, set as idbSet } from "idb-keyval";

/**
* 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;
/** Absent until a sign-in has told us which browser we are. */
deviceId?: number;
}

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-device-key");
const SUCCESSOR_SIGNATURE_DOMAIN = new TextEncoder().encode(
"ii-session-device-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<CryptoKeyPair> =>
crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, false, [
"sign",
"verify",
]) as Promise<CryptoKeyPair>;

const read = async (
Comment thread
sea-snake marked this conversation as resolved.
identityNumber: bigint,
): Promise<BrowserKeyRecord | undefined> => {
try {
return await idbGet<BrowserKeyRecord>(
storageKey(identityNumber),
BROWSER_KEY_STORE,
);
} catch {
return undefined;
}
};

const write = async (
Comment thread
sea-snake marked this conversation as resolved.
identityNumber: bigint,
record: BrowserKeyRecord,
): Promise<void> => {
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.
}
Comment thread
sea-snake marked this conversation as resolved.
};

const exported = (key: CryptoKey): Promise<Uint8Array> =>
crypto.subtle.exportKey("spki", key).then((spki) => new Uint8Array(spki));

const signed = async (
key: CryptoKey,
domain: Uint8Array,
sessionKey: Uint8Array,
otherKey: Uint8Array,
): Promise<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 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;
/** Rotates to the successor. Called once the canister has accepted the sign-in. */
accept: (deviceId: number) => Promise<void>;
Comment thread
sea-snake marked this conversation as resolved.
Outdated
}

/** 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 <T>(
identityNumber: bigint,
run: () => Promise<T>,
): Promise<T> => {
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);
};

/**
* 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. `accept` is what advances this browser to the successor, and until
* it is called the current key stays in place — so a call that never comes back leaves both
* sides on the key the canister still holds.
*/
export const withBrowserProof = <T>(
identityNumber: bigint,
sessionKey: Uint8Array,
signIn: (proof: BrowserProof) => Promise<T>,
): Promise<T> =>
exclusively(identityNumber, async () => {
const stored = await read(identityNumber);
let keyPair = stored?.keyPair;
if (keyPair === undefined) {
// Kept before the call, not after: a first sign-in whose response is lost has still
// registered this key, and coming back with a different one would enrol us twice.
keyPair = await generate();
await write(identityNumber, { keyPair });
}
const successor = await generate();
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,
),
]);

return signIn({
publicKey,
nextPublicKey,
signature,
nextSignature,
accept: (deviceId) =>
write(identityNumber, { keyPair: successor, deviceId }),
});
});

/** Which browser the canister knows this one as, for the settings list to mark it. */
export const currentDeviceId = async (
identityNumber: bigint,
): Promise<number | undefined> => (await read(identityNumber))?.deviceId;
Loading