Skip to content

Commit 2e2b753

Browse files
sea-snakeclaude
andcommitted
feat(fe): hold a browser key and rotate it on every sign-in
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>
1 parent 4d9c5d4 commit 2e2b753

2 files changed

Lines changed: 385 additions & 0 deletions

File tree

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
import "fake-indexeddb/auto";
2+
import { beforeEach, describe, expect, it } from "vitest";
3+
import { clear, createStore } from "idb-keyval";
4+
import { currentDeviceId, withBrowserProof } from "./browser-key.store";
5+
6+
/// Names the same store the module under test writes to, so a test can wipe it.
7+
const BROWSER_KEY_STORE = createStore("ii-browser-keys", "keys");
8+
9+
const SIGNATURE_DOMAIN = new TextEncoder().encode("ii-session-device-key");
10+
const SUCCESSOR_SIGNATURE_DOMAIN = new TextEncoder().encode(
11+
"ii-session-device-successor",
12+
);
13+
14+
const signedMessage = (
15+
domain: Uint8Array,
16+
sessionKey: Uint8Array,
17+
otherKey: Uint8Array,
18+
): Uint8Array => {
19+
const message = new Uint8Array(
20+
domain.length + sessionKey.length + otherKey.length,
21+
);
22+
message.set(domain);
23+
message.set(sessionKey, domain.length);
24+
message.set(otherKey, domain.length + sessionKey.length);
25+
return message;
26+
};
27+
28+
const verify = async (
29+
publicKey: Uint8Array,
30+
signature: Uint8Array,
31+
message: Uint8Array,
32+
): Promise<boolean> => {
33+
const key = await crypto.subtle.importKey(
34+
"spki",
35+
new Uint8Array(publicKey),
36+
{ name: "ECDSA", namedCurve: "P-256" },
37+
true,
38+
["verify"],
39+
);
40+
return crypto.subtle.verify(
41+
{ name: "ECDSA", hash: "SHA-256" },
42+
key,
43+
new Uint8Array(signature),
44+
new Uint8Array(message),
45+
);
46+
};
47+
48+
const sessionKey = (seed: number) => new Uint8Array(62).fill(seed);
49+
50+
const IDENTITY = BigInt(10_000);
51+
52+
/** Signs in and rotates, the way a successful ceremony does. */
53+
const signIn = (identityNumber: bigint, seed: number, deviceId = 1) =>
54+
withBrowserProof(identityNumber, sessionKey(seed), async (proof) => {
55+
await proof.accept(deviceId);
56+
return proof;
57+
});
58+
59+
/** Signs in without accepting, the way a call that fails or never returns leaves it. */
60+
const attempt = (identityNumber: bigint, seed: number) =>
61+
withBrowserProof(identityNumber, sessionKey(seed), (proof) =>
62+
Promise.resolve(proof),
63+
);
64+
65+
/** jsdom has no Web Locks, so this is what serialisation is tested against. */
66+
const stubLockApi = (): void => {
67+
let tail: Promise<unknown> = Promise.resolve();
68+
Object.defineProperty(navigator, "locks", {
69+
configurable: true,
70+
value: {
71+
request: (_name: string, run: () => Promise<unknown>) => {
72+
const next = tail.then(run);
73+
tail = next.then(
74+
() => undefined,
75+
() => undefined,
76+
);
77+
return next;
78+
},
79+
},
80+
});
81+
};
82+
83+
const withoutLockApi = (): void => {
84+
Object.defineProperty(navigator, "locks", {
85+
configurable: true,
86+
value: undefined,
87+
});
88+
};
89+
90+
describe("browser key", () => {
91+
beforeEach(async () => {
92+
await clear(BROWSER_KEY_STORE);
93+
withoutLockApi();
94+
});
95+
96+
it("signs the session key and the successor under the domain the canister verifies", async () => {
97+
const key = sessionKey(1);
98+
99+
const proof = await attempt(IDENTITY, 1);
100+
101+
await expect(
102+
verify(
103+
proof.publicKey,
104+
proof.signature,
105+
signedMessage(SIGNATURE_DOMAIN, key, proof.nextPublicKey),
106+
),
107+
).resolves.toBe(true);
108+
});
109+
110+
it("does not sign the session key alone", async () => {
111+
const proof = await attempt(IDENTITY, 1);
112+
113+
await expect(
114+
verify(proof.publicKey, proof.signature, sessionKey(1)),
115+
).resolves.toBe(false);
116+
});
117+
118+
it("announces a successor it does not yet use", async () => {
119+
const proof = await attempt(IDENTITY, 1);
120+
121+
expect(proof.nextPublicKey).not.toEqual(proof.publicKey);
122+
});
123+
124+
it("rotates to the successor once a sign-in is accepted", async () => {
125+
const first = await signIn(IDENTITY, 1);
126+
127+
const second = await attempt(IDENTITY, 2);
128+
129+
expect(second.publicKey).toEqual(first.nextPublicKey);
130+
});
131+
132+
it("keeps the current key when a sign-in is not accepted", async () => {
133+
const first = await attempt(IDENTITY, 1);
134+
135+
const second = await attempt(IDENTITY, 2);
136+
137+
expect(second.publicKey).toEqual(first.publicKey);
138+
expect(second.nextPublicKey).not.toEqual(first.nextPublicKey);
139+
});
140+
141+
it("holds a separate key per identity", async () => {
142+
const first = await attempt(IDENTITY, 1);
143+
144+
const second = await attempt(BigInt(10_001), 1);
145+
146+
expect(second.publicKey).not.toEqual(first.publicKey);
147+
});
148+
149+
it("registers a fresh key once storage is cleared", async () => {
150+
const before = await signIn(IDENTITY, 1);
151+
await clear(BROWSER_KEY_STORE);
152+
153+
const after = await attempt(IDENTITY, 1);
154+
155+
expect(after.publicKey).not.toEqual(before.publicKey);
156+
expect(after.publicKey).not.toEqual(before.nextPublicKey);
157+
});
158+
159+
it("serialises concurrent sign-ins, so the second builds on the first", async () => {
160+
stubLockApi();
161+
162+
const [first, second] = await Promise.all([
163+
signIn(IDENTITY, 1),
164+
signIn(IDENTITY, 2),
165+
]);
166+
167+
expect(second.publicKey).toEqual(first.nextPublicKey);
168+
});
169+
170+
it("still signs in on a browser without the lock API", async () => {
171+
const proof = await attempt(IDENTITY, 1);
172+
173+
expect(proof.publicKey.length).toBe(91);
174+
});
175+
176+
it("exports the keys in the encoding the canister parses", async () => {
177+
const proof = await attempt(IDENTITY, 1);
178+
179+
expect(proof.publicKey.length).toBe(91);
180+
expect(proof.nextPublicKey.length).toBe(91);
181+
expect(proof.signature.length).toBe(64);
182+
});
183+
184+
it("remembers which browser the canister said this is", async () => {
185+
await signIn(IDENTITY, 1, 7);
186+
187+
await expect(currentDeviceId(IDENTITY)).resolves.toBe(7);
188+
});
189+
190+
it("knows of no browser before a sign-in is accepted", async () => {
191+
await attempt(IDENTITY, 1);
192+
193+
await expect(currentDeviceId(IDENTITY)).resolves.toBeUndefined();
194+
});
195+
196+
it("has the successor sign for itself, so an unheld key cannot be announced", async () => {
197+
const key = sessionKey(1);
198+
199+
const proof = await attempt(IDENTITY, 1);
200+
201+
await expect(
202+
verify(
203+
proof.nextPublicKey,
204+
proof.nextSignature,
205+
signedMessage(SUCCESSOR_SIGNATURE_DOMAIN, key, proof.publicKey),
206+
),
207+
).resolves.toBe(true);
208+
});
209+
210+
it("keeps the two signatures in their own roles", async () => {
211+
const key = sessionKey(1);
212+
213+
const proof = await attempt(IDENTITY, 1);
214+
215+
// The successor's signature must not verify as the current key's, or one could be
216+
// replayed as the other.
217+
await expect(
218+
verify(
219+
proof.publicKey,
220+
proof.nextSignature,
221+
signedMessage(SIGNATURE_DOMAIN, key, proof.nextPublicKey),
222+
),
223+
).resolves.toBe(false);
224+
});
225+
});
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import { createStore, get as idbGet, set as idbSet } from "idb-keyval";
2+
3+
/**
4+
* The key this browser proves itself with when it creates a session, and the id the
5+
* canister attributed it to.
6+
*
7+
* The key never leaves this origin: it appears in no delegation chain and in nothing an app
8+
* receives, which is what lets it identify the browser without letting two apps recognise
9+
* it. It is replaced at every sign-in, so a copy of it taken off disk stops working as soon
10+
* as this browser signs in again.
11+
*/
12+
interface BrowserKeyRecord {
13+
keyPair: CryptoKeyPair;
14+
/** Absent until a sign-in has told us which browser we are. */
15+
deviceId?: number;
16+
}
17+
18+
const BROWSER_KEY_STORE = createStore("ii-browser-keys", "keys");
19+
20+
/** Must match the domains the canister verifies the two signatures under. */
21+
const SIGNATURE_DOMAIN = new TextEncoder().encode("ii-session-device-key");
22+
const SUCCESSOR_SIGNATURE_DOMAIN = new TextEncoder().encode(
23+
"ii-session-device-successor",
24+
);
25+
26+
/**
27+
* One key per identity, so nothing stored here links two of the user's identities to the
28+
* same browser.
29+
*/
30+
const storageKey = (identityNumber: bigint): string =>
31+
identityNumber.toString();
32+
33+
const generate = (): Promise<CryptoKeyPair> =>
34+
crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, false, [
35+
"sign",
36+
"verify",
37+
]) as Promise<CryptoKeyPair>;
38+
39+
const read = async (
40+
identityNumber: bigint,
41+
): Promise<BrowserKeyRecord | undefined> => {
42+
try {
43+
return await idbGet<BrowserKeyRecord>(
44+
storageKey(identityNumber),
45+
BROWSER_KEY_STORE,
46+
);
47+
} catch {
48+
return undefined;
49+
}
50+
};
51+
52+
const write = async (
53+
identityNumber: bigint,
54+
record: BrowserKeyRecord,
55+
): Promise<void> => {
56+
try {
57+
await idbSet(storageKey(identityNumber), record, BROWSER_KEY_STORE);
58+
} catch {
59+
// A browser that cannot keep its key signs in as a new one next time, which the
60+
// identity sees as a new browser rather than as a failure.
61+
}
62+
};
63+
64+
const exported = (key: CryptoKey): Promise<Uint8Array> =>
65+
crypto.subtle.exportKey("spki", key).then((spki) => new Uint8Array(spki));
66+
67+
const signed = async (
68+
key: CryptoKey,
69+
domain: Uint8Array,
70+
sessionKey: Uint8Array,
71+
otherKey: Uint8Array,
72+
): Promise<Uint8Array> => {
73+
const message = new Uint8Array(
74+
domain.length + sessionKey.length + otherKey.length,
75+
);
76+
message.set(domain);
77+
message.set(sessionKey, domain.length);
78+
message.set(otherKey, domain.length + sessionKey.length);
79+
return new Uint8Array(
80+
await crypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, key, message),
81+
);
82+
};
83+
84+
export interface BrowserProof {
85+
publicKey: Uint8Array;
86+
nextPublicKey: Uint8Array;
87+
signature: Uint8Array;
88+
/** By the successor itself, so a key the browser does not hold cannot be announced. */
89+
nextSignature: Uint8Array;
90+
/** Rotates to the successor. Called once the canister has accepted the sign-in. */
91+
accept: (deviceId: number) => Promise<void>;
92+
}
93+
94+
/** Serialises sign-ins for one identity: two at once would leave us holding a key the
95+
* canister never accepted, which reads as a different browser. */
96+
const exclusively = async <T>(
97+
identityNumber: bigint,
98+
run: () => Promise<T>,
99+
): Promise<T> => {
100+
const locks = navigator.locks;
101+
if (locks === undefined) {
102+
return run();
103+
}
104+
// Awaited, because `request` types its callback's return as the value it resolves to,
105+
// so the promise `run` returns would otherwise nest.
106+
return await locks.request(`ii-browser-key:${identityNumber}`, run);
107+
};
108+
109+
/**
110+
* Proves possession of this browser's key and announces the successor it rotates to.
111+
*
112+
* The proof covers the session key, which is fresh for every session, so it is good for
113+
* exactly one sign-in. `accept` is what advances this browser to the successor, and until
114+
* it is called the current key stays in place — so a call that never comes back leaves both
115+
* sides on the key the canister still holds.
116+
*/
117+
export const withBrowserProof = <T>(
118+
identityNumber: bigint,
119+
sessionKey: Uint8Array,
120+
signIn: (proof: BrowserProof) => Promise<T>,
121+
): Promise<T> =>
122+
exclusively(identityNumber, async () => {
123+
const stored = await read(identityNumber);
124+
let keyPair = stored?.keyPair;
125+
if (keyPair === undefined) {
126+
// Kept before the call, not after: a first sign-in whose response is lost has still
127+
// registered this key, and coming back with a different one would enrol us twice.
128+
keyPair = await generate();
129+
await write(identityNumber, { keyPair });
130+
}
131+
const successor = await generate();
132+
const [publicKey, nextPublicKey] = await Promise.all([
133+
exported(keyPair.publicKey),
134+
exported(successor.publicKey),
135+
]);
136+
137+
const [signature, nextSignature] = await Promise.all([
138+
signed(keyPair.privateKey, SIGNATURE_DOMAIN, sessionKey, nextPublicKey),
139+
signed(
140+
successor.privateKey,
141+
SUCCESSOR_SIGNATURE_DOMAIN,
142+
sessionKey,
143+
publicKey,
144+
),
145+
]);
146+
147+
return signIn({
148+
publicKey,
149+
nextPublicKey,
150+
signature,
151+
nextSignature,
152+
accept: (deviceId) =>
153+
write(identityNumber, { keyPair: successor, deviceId }),
154+
});
155+
});
156+
157+
/** Which browser the canister knows this one as, for the settings list to mark it. */
158+
export const currentDeviceId = async (
159+
identityNumber: bigint,
160+
): Promise<number | undefined> => (await read(identityNumber))?.deviceId;

0 commit comments

Comments
 (0)