From 25694a3945fd243c26fb87488cd694e8741f85d7 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 20 Oct 2026 23:52:49 -0700 Subject: [PATCH 1/9] Hosted: confirm a device-code approval with two digits Begin answers confirmDigits, the HMAC of the user code under RELAY_ENROLL_SECRET; the account's approval stores the two digits the person typed; a poll redeems an approval, for either purpose, only where they match, and answers mismatch otherwise. A live approval is never replaced, so each code takes one attempt. Migration 010 adds the column and drops approvals from before it. Co-Authored-By: Claude Opus 5.5 --- .../010_enrollment_confirm_digits.sql | 11 ++++ hosted/server/relay-account.ts | 17 ++++-- hosted/server/relay-api.ts | 38 +++++++++---- hosted/server/tests/boundary.test.ts | 2 +- hosted/server/tests/migrations.test.ts | 2 + hosted/server/tests/relay-room.test.ts | 2 +- hosted/server/tests/relay.test.ts | 47 ++++++++++++++-- hosted/server/tests/workers.test.ts | 35 +++++++----- remote-lib-common/src/remote/enroll-code.ts | 56 +++++++++++++++---- remote-lib-common/src/remote/wire.ts | 21 +++++-- remote-lib-common/test/wire.test.mjs | 44 ++++++++++++++- 11 files changed, 220 insertions(+), 55 deletions(-) create mode 100644 hosted/server/dormouse-migrations/010_enrollment_confirm_digits.sql diff --git a/hosted/server/dormouse-migrations/010_enrollment_confirm_digits.sql b/hosted/server/dormouse-migrations/010_enrollment_confirm_digits.sql new file mode 100644 index 000000000..1b8645afc --- /dev/null +++ b/hosted/server/dormouse-migrations/010_enrollment_confirm_digits.sql @@ -0,0 +1,11 @@ +-- Up Migration +-- The two digits the approver typed (docs/specs/hosted.md -> "Burrow +-- enrollment"): a poll redeems an approval only where they are its user +-- code's confirmation digits. An approval from before them, which no desktop +-- could have confirmed, is dropped. +DELETE FROM dormouse_relay_enrollment_approvals; +ALTER TABLE dormouse_relay_enrollment_approvals + ADD COLUMN "confirmDigits" text NOT NULL CHECK ("confirmDigits" ~ '^[0-9]{2}$'); + +-- Down Migration +ALTER TABLE dormouse_relay_enrollment_approvals DROP COLUMN "confirmDigits"; diff --git a/hosted/server/relay-account.ts b/hosted/server/relay-account.ts index cb0bde3ae..cea54cc57 100644 --- a/hosted/server/relay-account.ts +++ b/hosted/server/relay-account.ts @@ -4,6 +4,7 @@ import { bodyLimit } from "hono/body-limit"; import { NOT_ENTITLED_ERROR, isE2eId, + isEnrollConfirmDigits, normalizeEnrollUserCode, readJson, } from "remote-lib-common"; @@ -50,12 +51,17 @@ export function relayAccountRoutes(app: Hono, host: (c: Context) => RelayAc c.header("Retry-After", "60"); return c.json({ message: "Too many attempts. Wait a minute before trying again." }, 429); } - const body = await readJson<{ userCode?: unknown; purpose?: unknown }>(c); + const body = await readJson<{ userCode?: unknown; purpose?: unknown; confirmDigits?: unknown }>(c); const purpose = body?.purpose ?? "remote"; if (purpose !== "remote" && purpose !== "voice") return c.json({ message: "Invalid purpose." }, 400); const userCode = normalizeEnrollUserCode(body?.userCode); if (userCode === null) return c.json({ message: "That is not a code from Dormouse." }, 400); + // Unchecked here: only the relay can derive them. The poll compares, and an + // approval with other digits redeems nothing until it expires. + const confirmDigits = body?.confirmDigits; + if (!isEnrollConfirmDigits(confirmDigits)) + return c.json({ message: "Type the two digits Dormouse shows." }, 400); // Whether the code was ever issued is unknowable here: begin stores // nothing. An approval expires, redeemed or not. A live approval never // moves to another account, nor is one approved again once redeemed; an @@ -64,14 +70,15 @@ export function relayAccountRoutes(app: Hono, host: (c: Context) => RelayAc ( await accountQuery( host(c), - `INSERT INTO dormouse_relay_enrollment_approvals AS a ("userCode", "userId", "expiresAt", purpose) - VALUES ($1, $2, now() + ($3::float8 * interval '1 millisecond'), $4) + `INSERT INTO dormouse_relay_enrollment_approvals AS a + ("userCode", "userId", "expiresAt", purpose, "confirmDigits") + VALUES ($1, $2, now() + ($3::float8 * interval '1 millisecond'), $4, $5) ON CONFLICT ("userCode") DO UPDATE SET purpose = EXCLUDED.purpose, "userId" = EXCLUDED."userId", "expiresAt" = EXCLUDED."expiresAt", - "redeemedBurrowId" = NULL, "redeemedAt" = NULL + "confirmDigits" = EXCLUDED."confirmDigits", "redeemedBurrowId" = NULL, "redeemedAt" = NULL WHERE a."expiresAt" <= now() RETURNING 1`, - [userCode, login.userId, ENROLLMENT_TTL_MS, purpose], + [userCode, login.userId, ENROLLMENT_TTL_MS, purpose, confirmDigits], ) ).length > 0; return approved ? c.body(null, 204) : c.json({ message: ALREADY_APPROVED }, 409); diff --git a/hosted/server/relay-api.ts b/hosted/server/relay-api.ts index a68a75d36..3232597dd 100644 --- a/hosted/server/relay-api.ts +++ b/hosted/server/relay-api.ts @@ -27,6 +27,7 @@ import { WRONG_CREDENTIAL_ERROR, assertionRejectedError, checkRegistration, + enrollConfirmDigits, enrollUserCode, fromBase64Url, isPresenceBinding, @@ -480,8 +481,9 @@ export function relayApiRoutes(app: Hono<{ Bindings: RelayEnv }>) { // --- Enrollment: device code, approved on the account origin -------------- - // Begin writes nothing: the device code carries its own expiry, and the - // user code is derived from it, so an unauthenticated caller grows no table. + // Begin writes nothing: the device code carries its own expiry, the user + // code is derived from it and the digits from that, so an unauthenticated + // caller grows no table. app.post(API_ROUTES.burrowEnrollBegin, async (c) => { const body = await readJson<{ origin?: unknown; purpose?: unknown }>(c); const purpose = body?.purpose ?? "remote"; @@ -502,6 +504,7 @@ export function relayApiRoutes(app: Hono<{ Bindings: RelayEnv }>) { purpose, deviceCode: toBase64Url(bytes), userCode, + confirmDigits: await enrollConfirmDigits(enrollSecret(c.env), userCode), ...(c.env.ACCOUNT_ORIGIN && { verificationUrl: `${c.env.ACCOUNT_ORIGIN}${ENROLL_PAGE_PATH}${purpose === "voice" ? "?purpose=voice" : ""}#${userCode}`, }), @@ -527,8 +530,11 @@ export function relayApiRoutes(app: Hono<{ Bindings: RelayEnv }>) { return database(c, async (db) => { const { rows: [approval], - } = await db.query( - `SELECT a."userId", a."redeemedBurrowId", a."redeemedAt" IS NOT NULL AS redeemed, ${OWNER_COLUMNS} + } = await db.query< + OwnerRow & { redeemedBurrowId: string | null; redeemed: boolean; confirmDigits: string } + >( + `SELECT a."userId", a."redeemedBurrowId", a."redeemedAt" IS NOT NULL AS redeemed, + a."confirmDigits", ${OWNER_COLUMNS} FROM dormouse_relay_enrollment_approvals a JOIN "user" u ON u.id = a."userId" WHERE a."userCode" = $1 AND a.purpose = $2 AND a."expiresAt" > now()`, [userCode, purpose], @@ -541,6 +547,10 @@ export function relayApiRoutes(app: Hono<{ Bindings: RelayEnv }>) { ? voiceAnswer({ status: "redeemed" }) : answer({ status: "redeemed", burrowId: approval.redeemedBurrowId! }); } + // Approved with digits other than the ones this code showed: one attempt, + // spent, since a live approval is never replaced. + const confirmDigits = await enrollConfirmDigits(enrollSecret(c.env), userCode); + if (approval.confirmDigits !== confirmDigits) return answer({ status: "mismatch" }); const owner = ownerOf(approval); // Rechecked here: an approval does not outlive its approver's entitlement. if (!owner.entitled) return c.json({ error: NOT_ENTITLED_ERROR }, 403); @@ -550,12 +560,12 @@ export function relayApiRoutes(app: Hono<{ Bindings: RelayEnv }>) { const { rowCount } = await db.query( `WITH spent AS ( UPDATE dormouse_relay_enrollment_approvals SET "redeemedAt" = now() - WHERE "userCode" = $1 AND "userId" = $2 AND purpose = 'voice' + WHERE "userCode" = $1 AND "userId" = $2 AND purpose = 'voice' AND "confirmDigits" = $4 AND "expiresAt" > now() AND "redeemedAt" IS NULL RETURNING "userId" ) INSERT INTO dormouse_voice_tokens ("userId", hash) SELECT "userId", $3 FROM spent`, - [userCode, owner.userId, digest(voiceToken)], + [userCode, owner.userId, digest(voiceToken), confirmDigits], ); return voiceAnswer(rowCount ? { status: "enabled", voiceToken } : { status: "redeemed" }); } @@ -579,11 +589,11 @@ export function relayApiRoutes(app: Hono<{ Bindings: RelayEnv }>) { UPDATE dormouse_relay_enrollment_approvals SET "redeemedBurrowId" = $3, "redeemedAt" = now() WHERE "userCode" = $1 AND "userId" = $2 AND "expiresAt" > ${LOCKED_NOW} - AND "redeemedAt" IS NULL AND purpose = 'remote' + AND "redeemedAt" IS NULL AND purpose = 'remote' AND "confirmDigits" = $5 RETURNING "userId" ) INSERT INTO dormouse_relay_burrows ("burrowId", "userId", "tokenHash") SELECT $3, "userId", $4 FROM spent`, - [userCode, owner.userId, burrowId, digest(burrowToken)], + [userCode, owner.userId, burrowId, digest(burrowToken), confirmDigits], ); if (rowCount) return "enrolled"; // Lost to a poll that redeemed it while this one waited on the lock, @@ -774,9 +784,15 @@ const rpIdOf = (env: RelayEnv) => new URL(env.APP_ORIGIN).hostname; const removeOneFirst = ({ ACCOUNT_ORIGIN: where }: RelayEnv) => `remove one ${where ? `at ${where}/account` : "from your account"} first`; -/** A device code's user code; a deployment without its secret answers 503. */ -function userCodeOf(env: RelayEnv, deviceCode: Uint8Array, purpose: "remote" | "voice") { +/** The enrollment secret; a deployment without it answers 503. */ +function enrollSecret(env: RelayEnv): string { if (!env.RELAY_ENROLL_SECRET) throw new Error("RELAY_ENROLL_SECRET is not set"); - return enrollUserCode(purpose === "voice" ? `${env.RELAY_ENROLL_SECRET}:voice` : env.RELAY_ENROLL_SECRET, deviceCode); + return env.RELAY_ENROLL_SECRET; +} + +/** A device code's user code, under its purpose's key. */ +function userCodeOf(env: RelayEnv, deviceCode: Uint8Array, purpose: "remote" | "voice") { + const secret = enrollSecret(env); + return enrollUserCode(purpose === "voice" ? `${secret}:voice` : secret, deviceCode); } const invalidSetup = (c: Context) => c.json({ error: SETUP_TOKEN_INVALID_ERROR }, 401); diff --git a/hosted/server/tests/boundary.test.ts b/hosted/server/tests/boundary.test.ts index 8c223f945..5f8617ff8 100644 --- a/hosted/server/tests/boundary.test.ts +++ b/hosted/server/tests/boundary.test.ts @@ -808,7 +808,7 @@ test("the cookie gate needs no login creation time; approval reads it and fails const response = await app.request(`${origin}/api/relay/enrollments/approve`, { method: "POST", headers: { origin, "content-type": "application/json" }, - body: JSON.stringify({ userCode: "ZZZZ-ZZZZ" }), + body: JSON.stringify({ userCode: "ZZZZ-ZZZZ", confirmDigits: "00" }), }); expect(response.status, String(createdAt)).toBe(403); expect(await response.json()).toEqual({ message: RECENT_LOGIN_REQUIRED }); diff --git a/hosted/server/tests/migrations.test.ts b/hosted/server/tests/migrations.test.ts index 9ec2e01c8..c86e7e115 100644 --- a/hosted/server/tests/migrations.test.ts +++ b/hosted/server/tests/migrations.test.ts @@ -24,6 +24,8 @@ const PINNED: Record = { "b3f265e4c99b7d78c84aa58fe3e95b9a609c450ccc0a7f3701f871c8798d4801", "009_relay_passkey_removal.sql": "c6c220960187e33a1350dc85e93d24f99866a72a579ed9def6a3663643f3e928", + "010_enrollment_confirm_digits.sql": + "1547a0d3062da19ee1f075b46d01bacff8c32aad9c98353edd1dfa42f5bdc375", }; const directory = new URL("../dormouse-migrations/", import.meta.url); diff --git a/hosted/server/tests/relay-room.test.ts b/hosted/server/tests/relay-room.test.ts index 813123216..5cf9bb690 100644 --- a/hosted/server/tests/relay-room.test.ts +++ b/hosted/server/tests/relay-room.test.ts @@ -766,7 +766,7 @@ test("a Burrow enrolled by device code pairs and connects a phone, and under Loc const approved = await account.request(`${ORIGINS.account}/api/relay/enrollments/approve`, { method: "POST", headers: { origin: ORIGINS.account, "content-type": "application/json" }, - body: JSON.stringify({ userCode: begun.json.userCode }), + body: JSON.stringify({ userCode: begun.json.userCode, confirmDigits: begun.json.confirmDigits }), }); expect(approved.status).toBe(204); // … and its poll answers the enrollment, once. diff --git a/hosted/server/tests/relay.test.ts b/hosted/server/tests/relay.test.ts index cedf9afcb..3c9f75c0d 100644 --- a/hosted/server/tests/relay.test.ts +++ b/hosted/server/tests/relay.test.ts @@ -15,6 +15,7 @@ import { SETUP_TOKEN_INVALID_ERROR, UNAUTHORIZED_ERROR, UNMINTED_DELIVERY_ID_ERROR, + enrollConfirmDigits, enrollUserCode, fromBase64Url, generateNoiseKeyPair, @@ -283,6 +284,7 @@ async function fixture({ bindings = {} }: { bindings?: Record call("POST", API_ROUTES.burrowEnrollPoll, { body: { deviceCode, purpose } }); - /** What the account Worker's approval writes (`hosted/server/relay-account.ts`). */ - const approve = (userCode: string, userId: string, purpose: "remote" | "voice" = "remote") => + /** + * What the account Worker's approval writes (`hosted/server/relay-account.ts`), + * with the digits `userCode` shows unless `confirmDigits` types others. + */ + const approve = async ( + userCode: string, + userId: string, + purpose: "remote" | "voice" = "remote", + confirmDigits?: string, + ) => sql( - `INSERT INTO dormouse_relay_enrollment_approvals ("userCode", "userId", "expiresAt", purpose) - VALUES ($1, $2, now() + interval '10 minutes', $3)`, - [userCode, userId, purpose], + `INSERT INTO dormouse_relay_enrollment_approvals ("userCode", "userId", "expiresAt", purpose, "confirmDigits") + VALUES ($1, $2, now() + interval '10 minutes', $3, $4)`, + [userCode, userId, purpose, confirmDigits ?? (await enrollConfirmDigits(TEST_ENROLL_SECRET, userCode))], ); /** The relay's Cron Trigger, as Cloudflare fires it. */ @@ -996,6 +1006,8 @@ test("a device-code enrollment: begin stores nothing, pending until approved, th expect(Math.abs(begun.expiresAt - (Date.now() + ENROLLMENT_TTL_MS))).toBeLessThan(60_000); expect(isEnrollUserCode(begun.userCode)).toBe(true); expect(begun.userCode).toBe(await enrollUserCode(TEST_ENROLL_SECRET, bytes)); + // The digits Dormouse shows, the same secret's HMAC of the user code. + expect(begun.confirmDigits).toBe(await enrollConfirmDigits(TEST_ENROLL_SECRET, begun.userCode)); expect(begun.verificationUrl).toBe(`${ORIGINS.account}/enroll#${begun.userCode}`); expect(begun.interval).toBe(ENROLLMENT_POLL_INTERVAL_S); // Begin wrote nothing (`pocket.test.ts` begins with no database at all). @@ -1066,6 +1078,31 @@ test("an approval redeems only the device code its user code is derived from", a expect((await f.poll(a.deviceCode)).json).toMatchObject({ status: "enrolled" }); }); +test("an approval with other digits redeems nothing, for either purpose, until it expires", async ({ + onTestFinished, +}) => { + const f = await fixture(); + onTestFinished(f.close); + const owner = await f.account(ADMIN_EMAIL); + for (const purpose of ["remote", "voice"] as const) { + const begun = await f.begin(purpose); + const other = String((Number(begun.confirmDigits) + 1) % 100).padStart(2, "0"); + await f.approve(begun.userCode, owner, purpose, other); + // Every poll says so, so a poll whose answer was lost learns it too. + for (let i = 0; i < 2; i++) + expect(await f.poll(begun.deviceCode, purpose)).toMatchObject({ status: 200, json: { status: "mismatch" } }); + // Even an approver no longer entitled: the digits are told first. + await f.sql(`UPDATE "user" SET "emailVerified" = false WHERE id = $1`, [owner]); + expect((await f.poll(begun.deviceCode, purpose)).json).toEqual({ status: "mismatch" }); + await f.sql(`UPDATE "user" SET "emailVerified" = true WHERE id = $1`, [owner]); + } + expect(await f.sql(`SELECT count(*)::int AS n FROM dormouse_relay_burrows`)).toEqual([{ n: 0 }]); + expect(await f.sql(`SELECT count(*)::int AS n FROM dormouse_voice_tokens`)).toEqual([{ n: 0 }]); + expect( + await f.sql(`SELECT count(*)::int AS n FROM dormouse_relay_enrollment_approvals WHERE "redeemedAt" IS NOT NULL`), + ).toEqual([{ n: 0 }]); +}); + test("two polls racing one approval mint one Burrow", async ({ onTestFinished }) => { const f = await fixture(); onTestFinished(f.close); diff --git a/hosted/server/tests/workers.test.ts b/hosted/server/tests/workers.test.ts index fbb21ab24..f74e49992 100644 --- a/hosted/server/tests/workers.test.ts +++ b/hosted/server/tests/workers.test.ts @@ -414,10 +414,10 @@ async function fixture( return token; }; /** The account's Relay routes, from this origin unless `from` names another. */ - const approve = (userCode: unknown, from = origin) => + const approve = (userCode: unknown, confirmDigits: unknown, from = origin) => request("/api/relay/enrollments/approve", { method: "POST", - body: JSON.stringify({ userCode }), + body: JSON.stringify({ userCode, confirmDigits }), headers: { origin: from, "content-type": "application/json" }, }); const computers = async () => @@ -458,6 +458,7 @@ async function fixture( (await burrowCall(API_ROUTES.burrowEnrollBegin, { origin: ORIGINS.relay })).json as { deviceCode: string; userCode: string; + confirmDigits: string; verificationUrl: string; }, poll: (deviceCode: string) => burrowCall(API_ROUTES.burrowEnrollPoll, { deviceCode }), @@ -1142,28 +1143,34 @@ test("enrollment: only a recent admin login from this origin approves, and the B const begun = await f.begin(); expect(begun.verificationUrl).toBe(`${origin}/enroll#${begun.userCode}`); - expect((await admin.approve(begun.userCode)).status).toBe(401); + const digits = begun.confirmDigits; + expect((await admin.approve(begun.userCode, digits)).status).toBe(401); await other.email("other@example.test"); - const refused = await other.approve(begun.userCode); + const refused = await other.approve(begun.userCode, digits); expect(refused.status).toBe(403); expect(await refused.json()).toEqual({ message: NOT_ENTITLED_ERROR }); expect((await other.request("/api/relay/burrows")).status).toBe(403); await admin.email(ADMIN_EMAIL); // A same-site page — a sibling Worker's included — carries the cookie but not this origin. for (const sameSite of SAME_SITE) - expect((await admin.approve(begun.userCode, sameSite)).status, sameSite).toBe(403); + expect((await admin.approve(begun.userCode, digits, sameSite)).status, sameSite).toBe(403); expect((await f.poll(begun.deviceCode)).json).toEqual({ status: "pending" }); - // A malformed code is a 400; the code forgives case and spacing. + // A malformed code or digits is a 400; the code forgives case and spacing. for (const code of ["not a code", "ZZZZ-ZZZ0", 7]) - expect((await admin.approve(code)).status, String(code)).toBe(400); - expect((await admin.approve(` ${begun.userCode.toLowerCase().replace("-", "")} `)).status).toBe(204); + expect((await admin.approve(code, digits)).status, String(code)).toBe(400); + for (const typed of [undefined, "007"]) { + const response = await admin.approve(begun.userCode, typed); + expect(response.status, String(typed)).toBe(400); + expect(await response.json()).toEqual({ message: "Type the two digits Dormouse shows." }); + } + expect((await admin.approve(` ${begun.userCode.toLowerCase().replace("-", "")} `, digits)).status).toBe(204); // A live approval answers the same 409 to a second approval, and never moves. - const again = await admin.approve(begun.userCode); + const again = await admin.approve(begun.userCode, digits); expect(again.status).toBe(409); expect(await again.json()).toEqual({ message: ALREADY_APPROVED }); // A code no Burrow holds is approved all the same, and expires unredeemed. - expect((await admin.approve("ZZZZ-ZZZZ")).status).toBe(204); + expect((await admin.approve("ZZZZ-ZZZZ", "00")).status).toBe(204); const enrolled = await f.poll(begun.deviceCode); expect(enrolled.json.status).toBe("enrolled"); @@ -1206,13 +1213,13 @@ test("enrollment: only a recent admin login from this origin approves, and the B // A redeemed approval is approved again only once it has expired, and then unredeemed. expect((await f.poll(begun.deviceCode)).json).toEqual({ status: "redeemed", burrowId }); - expect((await admin.approve(begun.userCode)).status).toBe(409); + expect((await admin.approve(begun.userCode, digits)).status).toBe(409); await queryDatabase( f.database.url, `UPDATE dormouse_relay_enrollment_approvals SET "expiresAt" = now() - interval '1 second' WHERE "userCode" = $1`, [begun.userCode], ); - expect((await admin.approve(begun.userCode)).status).toBe(204); + expect((await admin.approve(begun.userCode, digits)).status).toBe(204); expect( await queryDatabase( f.database.url, @@ -1296,7 +1303,7 @@ test("enrollment approval needs a login from the recent-login window, and attemp const begun = await f.begin(); const signedIn = Date.parse((await admin.session())!.session.createdAt); await f.advance(new Date(signedIn + 10 * 60_000).toISOString()); - const stale = await admin.approve(begun.userCode); + const stale = await admin.approve(begun.userCode, begun.confirmDigits); expect(stale.status).toBe(403); expect(await stale.json()).toEqual({ message: RECENT_LOGIN_REQUIRED }); expect((await f.poll(begun.deviceCode)).json).toEqual({ status: "pending" }); @@ -1306,7 +1313,7 @@ test("enrollment approval needs a login from the recent-login window, and attemp const approveLimit = limitOf("RELAY_APPROVE_LIMIT"); const limited = await untilLimited(approveLimit, async (i) => { const valid = i === approveLimit - 1; - const response = await admin.approve(valid ? begun.userCode : "not a code"); + const response = await admin.approve(valid ? begun.userCode : "not a code", begun.confirmDigits); if (response.status !== 429) expect(response.status).toBe(valid ? 204 : 400); return response; }); diff --git a/remote-lib-common/src/remote/enroll-code.ts b/remote-lib-common/src/remote/enroll-code.ts index 9ddfd1238..33918c879 100644 --- a/remote-lib-common/src/remote/enroll-code.ts +++ b/remote-lib-common/src/remote/enroll-code.ts @@ -1,8 +1,8 @@ /** - * The user code of Hosted's device-code Burrow enrollment - * (`docs/specs/hosted.md` -> "Burrow enrollment"): what Dormouse shows, what the - * account page shows beside Approve, and what the user compares between them, - * and its derivation from the device code. + * The codes of Hosted's device-code approval (`docs/specs/hosted.md` -> "Burrow + * enrollment"): the user code, which the link carries to the account page and + * keys the approval, derived from the device code; and the two confirmation + * digits Dormouse shows and the user types there, derived from the user code. * * Imports nothing, so the account frontend takes it without the rest of this * package. @@ -35,6 +35,13 @@ export function normalizeEnrollUserCode(text: unknown): string | null { return isEnrollUserCode(code) ? code : null; } +const CONFIRM_DIGITS = /^[0-9]{2}$/; + +/** Whether `value` is two confirmation digits, `00`–`99`. */ +export function isEnrollConfirmDigits(value: unknown): value is string { + return typeof value === 'string' && CONFIRM_DIGITS.test(value); +} + /** The slice of `SubtleCrypto` {@link enrollUserCode} uses. */ interface HmacSubtle { importKey( @@ -59,14 +66,7 @@ export async function enrollUserCode( deviceCode: Uint8Array, subtle: HmacSubtle = (globalThis as unknown as { crypto: { subtle: HmacSubtle } }).crypto.subtle, ): Promise { - const key = await subtle.importKey( - 'raw', - new TextEncoder().encode(secret), - { name: 'HMAC', hash: 'SHA-256' }, - false, - ['sign'], - ); - const mac = new Uint8Array(await subtle.sign('HMAC', key, deviceCode)); + const mac = await hmac(secret, deviceCode, subtle); let code = ''; for (let bit = 0; code.length < ENROLL_USER_CODE_CHARACTERS; bit += 5) { // 51 groups for 8 characters: running out has odds below 10^-40, and @@ -78,3 +78,35 @@ export async function enrollUserCode( } return `${code.slice(0, 4)}-${code.slice(4)}`; } + +/** + * The confirmation digits of `userCode`: `HMAC-SHA-256(secret, "confirm:" + + * userCode)` read sixteen bits at a time, most significant first, a value of + * 65500 or more skipped so each of the hundred is equally likely. A link + * carries the user code, never these: without `secret` no one can tell them + * from it, so only the person who sees Dormouse's screen can type them. + */ +export async function enrollConfirmDigits( + secret: string, + userCode: string, + subtle: HmacSubtle = (globalThis as unknown as { crypto: { subtle: HmacSubtle } }).crypto.subtle, +): Promise { + const mac = await hmac(secret, new TextEncoder().encode(`confirm:${userCode}`), subtle); + for (let byte = 0; byte + 1 < mac.length; byte += 2) { + const value = (mac[byte] << 8) | mac[byte + 1]; + // Sixteen draws: all skipped has odds below 10^-52. + if (value < 65500) return String(value % 100).padStart(2, '0'); + } + throw new Error('No confirmation digits in this MAC'); +} + +async function hmac(secret: string, data: Uint8Array, subtle: HmacSubtle): Promise { + const key = await subtle.importKey( + 'raw', + new TextEncoder().encode(secret), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'], + ); + return new Uint8Array(await subtle.sign('HMAC', key, data)); +} diff --git a/remote-lib-common/src/remote/wire.ts b/remote-lib-common/src/remote/wire.ts index 6e5fd9a1f..149b67e38 100644 --- a/remote-lib-common/src/remote/wire.ts +++ b/remote-lib-common/src/remote/wire.ts @@ -12,7 +12,7 @@ import { isExactBase64Url, } from '../security/bytes.js'; import { NOISE_MAX_MESSAGE_LENGTH } from '../security/noise.js'; -import { isEnrollUserCode } from './enroll-code.js'; +import { isEnrollConfirmDigits, isEnrollUserCode } from './enroll-code.js'; import { isManagedVoiceToken } from './managed-voice.js'; import type { PasskeyAssertion } from '../security/passkey.js'; import type { PresenceBinding } from '../security/presence.js'; @@ -397,8 +397,13 @@ export interface BurrowEnrollBeginResponse { * "Burrow enrollment". */ deviceCode: string; - /** What Dormouse shows and the account approves, `XXXX-XXXX`. */ + /** What the link carries and the account approves, `XXXX-XXXX`. */ userCode: string; + /** + * The two digits Dormouse shows, which the person types beside Approve; a + * poll of an approval whose digits differ answers `mismatch`. + */ + confirmDigits: string; /** * The account page that approves `userCode`, the code in its fragment; * absent where the deployment names no account origin. @@ -434,6 +439,7 @@ export function isBurrowEnrollBeginResponse(value: unknown): value is BurrowEnro return ( isRelayBearer(candidate.deviceCode) && isEnrollUserCode(candidate.userCode) && + isEnrollConfirmDigits(candidate.confirmDigits) && (verificationUrl === undefined || isBoundedString(verificationUrl, MAX_ENROLL_VERIFICATION_URL_LENGTH)) && typeof expiresAt === 'number' && Number.isFinite(expiresAt) && @@ -453,11 +459,14 @@ export interface BurrowEnrollPollRequest { * `expired` also answers an unknown device code. `enrolled` answers once: the * redemption is single-use. `redeemed` answers every later poll of that code * until the approval expires: an earlier poll enrolled `burrowId`, whose answer - * never reached this one, and which the account must remove. + * never reached this one, and which the account must remove. `mismatch` + * answers every poll of a code approved with other digits than its + * `confirmDigits`, which nothing redeems. */ export type BurrowEnrollPollResponse = | { status: 'pending' } | { status: 'expired' } + | { status: 'mismatch' } | { status: 'redeemed'; burrowId: string } | { status: 'enrolled'; enrollment: BurrowEnrollResponse }; @@ -472,6 +481,7 @@ export function isBurrowEnrollPollResponse(value: unknown): value is BurrowEnrol switch (answer.status) { case 'pending': case 'expired': + case 'mismatch': return true; case 'redeemed': return isE2eId(answer.burrowId); @@ -486,11 +496,13 @@ export function isBurrowEnrollPollResponse(value: unknown): value is BurrowEnrol * A voice-purpose poll's answer (`docs/specs/hosted.md` -> "Burrow * enrollment"). `enabled` answers once, carrying the managed-voice token the * redemption minted; `redeemed` answers every later poll of that code until - * the approval expires, naming nothing, since a voice approval mints no Burrow. + * the approval expires, naming nothing, since a voice approval mints no Burrow; + * `mismatch` as {@link BurrowEnrollPollResponse}'s. */ export type BurrowVoicePollResponse = | { status: 'pending' } | { status: 'expired' } + | { status: 'mismatch' } | { status: 'redeemed' } | { status: 'enabled'; voiceToken: string }; @@ -501,6 +513,7 @@ export function isBurrowVoicePollResponse(value: unknown): value is BurrowVoiceP switch (answer.status) { case 'pending': case 'expired': + case 'mismatch': case 'redeemed': return true; case 'enabled': diff --git a/remote-lib-common/test/wire.test.mjs b/remote-lib-common/test/wire.test.mjs index f9244303f..08df5d6a5 100644 --- a/remote-lib-common/test/wire.test.mjs +++ b/remote-lib-common/test/wire.test.mjs @@ -6,7 +6,9 @@ import { API_ROUTES, E2E_ID_LENGTH, ENROLL_USER_CODE_ALPHABET, + enrollConfirmDigits, enrollUserCode, + isEnrollConfirmDigits, isEnrollUserCode, normalizeEnrollUserCode, MAX_E2E_CIPHERTEXT_LENGTH, @@ -296,10 +298,43 @@ test('enrollUserCode is the HMAC of the device code, five bits a character, past await assert.rejects(enrollUserCode(secret, deviceCode, fake(macOf([0, 1, 2, 3, 4, 5, 6]))), /No user code/); }); +test('enrollConfirmDigits is the HMAC of the user code, sixteen bits a draw, 65500 and up skipped', async () => { + const secret = 'test-enroll-secret'; + // An independent reading of the same rule over node's HMAC. + const mac = createHmac('sha256', secret).update(`confirm:${userCode}`).digest(); + let expected; + for (let at = 0; expected === undefined; at += 2) { + const value = mac.readUInt16BE(at); + if (value < 65500) expected = String(value % 100).padStart(2, '0'); + } + assert.equal(await enrollConfirmDigits(secret, userCode), expected); + assert.ok(isEnrollConfirmDigits(expected)); + // Another secret, or another user code, is other digits. + assert.notEqual(await enrollConfirmDigits(`${secret}x`, userCode), expected); + assert.notEqual(await enrollConfirmDigits(secret, '23AB-YZ9M'), expected); + + // A draw of 65500 or more is skipped, never folded onto the low digits; a + // single digit is padded. + const macOf = (words) => { + const bytes = new Uint8Array(32).fill(0xff); + words.forEach((word, i) => new DataView(bytes.buffer).setUint16(i * 2, word)); + return bytes; + }; + const fake = (bytes) => ({ importKey: async () => ({}), sign: async () => bytes.buffer }); + assert.equal(await enrollConfirmDigits(secret, userCode, fake(macOf([65500, 65535, 65499]))), '99'); + assert.equal(await enrollConfirmDigits(secret, userCode, fake(macOf([65500, 7]))), '07'); + await assert.rejects(enrollConfirmDigits(secret, userCode, fake(macOf([]))), /No confirmation digits/); + for (const shape of ['00', '99']) + assert.ok(isEnrollConfirmDigits(shape), shape); + for (const shape of ['0', '100', ' 42', '4a', 42, null]) + assert.equal(isEnrollConfirmDigits(shape), false, String(shape)); +}); + test('isBurrowEnrollBeginResponse holds each field to its shape and bounds', () => { const begin = { deviceCode: 'D'.repeat(RELAY_BEARER_LENGTH), userCode, + confirmDigits: '07', verificationUrl: `https://hosted.dormouse.sh/enroll#${userCode}`, expiresAt: 1_800_000_000_000, interval: 5, @@ -319,6 +354,9 @@ test('isBurrowEnrollBeginResponse holds each field to its shape and bounds', () { ...begin, userCode: undefined }, { ...begin, userCode: '23ab-yz9k' }, { ...begin, userCode: '23ABYZ9K' }, + { ...begin, confirmDigits: undefined }, + { ...begin, confirmDigits: '7' }, + { ...begin, confirmDigits: 7 }, { ...begin, verificationUrl: 7 }, { ...begin, verificationUrl: `https://hosted.dormouse.sh/enroll#${'x'.repeat(512)}` }, { ...begin, expiresAt: undefined }, @@ -335,10 +373,11 @@ test('isBurrowEnrollBeginResponse holds each field to its shape and bounds', () } }); -test('isBurrowVoicePollResponse knows four answers, an enabled one carrying a voice token', () => { +test('isBurrowVoicePollResponse knows five answers, an enabled one carrying a voice token', () => { const voiceToken = `dmv_${'A'.repeat(43)}`; assert.ok(isBurrowVoicePollResponse({ status: 'pending' })); assert.ok(isBurrowVoicePollResponse({ status: 'expired' })); + assert.ok(isBurrowVoicePollResponse({ status: 'mismatch' })); assert.ok(isBurrowVoicePollResponse({ status: 'redeemed' })); assert.ok(isBurrowVoicePollResponse({ status: 'enabled', voiceToken })); for (const wrong of [ @@ -354,10 +393,11 @@ test('isBurrowVoicePollResponse knows four answers, an enabled one carrying a vo } }); -test('isBurrowEnrollPollResponse knows four answers, a redeemed one naming its Burrow', () => { +test('isBurrowEnrollPollResponse knows five answers, a redeemed one naming its Burrow', () => { const burrowId = 'A'.repeat(22); assert.ok(isBurrowEnrollPollResponse({ status: 'pending' })); assert.ok(isBurrowEnrollPollResponse({ status: 'expired' })); + assert.ok(isBurrowEnrollPollResponse({ status: 'mismatch' })); assert.ok(isBurrowEnrollPollResponse({ status: 'redeemed', burrowId })); assert.ok(isBurrowEnrollPollResponse({ status: 'enrolled', enrollment: {} })); for (const wrong of [ From 4885c7fe22df95adacce0aaa27c5df3ecbf24598 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 20 Oct 2026 23:52:56 -0700 Subject: [PATCH 2/9] One desktop approval run for remote control and Natural Voice HostedApproval (lib/src/host/remote/hosted-approval.ts) holds the begin, the code waiting, the polls, and the redemption for both purposes; the Burrow service and the managed-voice host differ only in their flow: what begin holds back, how a poll reads, and what redemption keeps (a Burrow, or a voice token, deleted at Hosted when discarded). Voice's status now carries the same HostedEnrollmentState, parsed by the same reader, with typed end reasons in place of free-text messages. The desktop shows the two confirmation digits instead of the user code, through one view, HostedApprovalProgress, worded per purpose, and ends on a mismatch. Co-Authored-By: Claude Opus 5.5 --- lib/src/components/HostedEnrollment.tsx | 128 +++++--- .../components/ManagedVoiceSection.test.tsx | 28 +- lib/src/components/ManagedVoiceSection.tsx | 24 +- .../components/RemoteControlSection.test.tsx | 26 +- lib/src/host/managed-voice-host.test.ts | 20 +- lib/src/host/managed-voice-host.ts | 142 +++------ lib/src/host/remote/hosted-approval.ts | 282 +++++++++++++++++ lib/src/host/remote/service-protocol.ts | 39 ++- lib/src/host/remote/service.test.ts | 36 ++- lib/src/host/remote/service.ts | 284 ++++-------------- lib/src/lib/platform/managed-voice-port.ts | 4 +- lib/src/lib/platform/managed-voice-types.ts | 8 +- .../remote/burrow/burrow-status-store.test.ts | 4 +- lib/src/remote/burrow/burrow-status-store.ts | 30 +- lib/src/remote/burrow/enrollment.test.ts | 10 +- lib/src/remote/burrow/enrollment.ts | 48 ++- .../stories/ManagedVoiceSection.stories.tsx | 18 +- .../stories/RemoteControlSection.stories.tsx | 19 +- 18 files changed, 667 insertions(+), 483 deletions(-) create mode 100644 lib/src/host/remote/hosted-approval.ts diff --git a/lib/src/components/HostedEnrollment.tsx b/lib/src/components/HostedEnrollment.tsx index f5b6bc162..3637af7d6 100644 --- a/lib/src/components/HostedEnrollment.tsx +++ b/lib/src/components/HostedEnrollment.tsx @@ -40,27 +40,45 @@ export function BurrowNameField({ ); } +/** What a Hosted approval turns on: remote control, or Natural Voice. */ +export type HostedApprovalPurpose = 'remote' | 'voice'; + /** - * The accessible name of the code a Hosted enrollment shows, which the account - * page shows beside Approve for the person to compare. + * The accessible name of the two digits a Hosted approval shows, which the + * person types beside Approve on the account page. */ -export const HOSTED_ENROLLMENT_CODE_LABEL = 'Enrollment code'; +export const HOSTED_ENROLLMENT_CODE_LABEL = 'Digits to type at your account'; + +/** What an approval that ended short did not do. */ +const NOT_DONE: Record = { + remote: 'this computer was not enrolled', + voice: 'Natural Voice was not turned on', +}; /** - * What an enrollment that ended short of enrolling says. **Fixed copy chosen - * by code**, as `PAIRING_OUTCOME_COPY` is: `failed` alone adds the service's - * own sentence. + * What an approval that ended short says, by purpose. **Fixed copy chosen by + * code**, as `PAIRING_OUTCOME_COPY` is: `failed` alone adds the host's own + * sentence. */ -export const HOSTED_ENROLLMENT_ENDED_COPY: Record = { - expired: 'That code expired before it was approved, so this computer was not enrolled.', - 'not-entitled': 'The account that approved that code has no Hosted plan, so this computer was not enrolled.', - 'answer-lost': - 'That code was approved and used, but the answer never reached this computer, so it was not enrolled.', - failed: 'This computer could not finish enrolling.', -}; +export const HOSTED_APPROVAL_ENDED_COPY = Object.fromEntries( + (['remote', 'voice'] as const).map((purpose) => { + const result = NOT_DONE[purpose]; + const copy: Record = { + expired: `That code expired before it was approved, so ${result}.`, + 'not-entitled': `The account that approved that code has no Hosted plan, so ${result}.`, + mismatch: `The digits typed at your account were not the ones shown here, so ${result}.`, + 'answer-lost': `That code was approved and used, but the answer never reached this computer, so ${result}.`, + failed: purpose === 'remote' ? 'This computer could not finish enrolling.' : 'Natural Voice could not finish turning on.', + }; + return [purpose, copy]; + }), +) as Record>; -/** The copy a status's `redeeming` shows: approved, and the enrollment being saved. */ -export const HOSTED_ENROLLMENT_REDEEMING_COPY = 'Approved. Enrolling this computer…'; +/** The copy a status's `redeeming` shows: approved, and the credential being saved. */ +export const HOSTED_APPROVAL_REDEEMING_COPY: Record = { + remote: 'Approved. Enrolling this computer…', + voice: 'Approved. Turning on Natural Voice…', +}; /** The account page the service names in `status.accountOrigin`, where computers are removed. */ export function accountPage(accountOrigin: string | null): string | null { @@ -74,19 +92,24 @@ export function answerLostRemoval(burrowId: string | undefined): string { : 'Remove the computer it added from your account, then enroll again.'; } -/** Why the last Hosted enrollment ended, with the account page where it says to go there. */ +/** + * Why the last Hosted approval ended, with the account page where it says to + * go there: only a lost remote-control answer left something to remove. + */ export function HostedEnrollmentEnded({ ended, accountOrigin, + purpose = 'remote', }: { ended: Extract; accountOrigin: string | null; + purpose?: HostedApprovalPurpose; }) { - const lost = ended.reason === 'answer-lost'; + const lost = purpose === 'remote' && ended.reason === 'answer-lost'; const page = lost ? accountPage(accountOrigin) : null; return (
-
{HOSTED_ENROLLMENT_ENDED_COPY[ended.reason]}
+
{HOSTED_APPROVAL_ENDED_COPY[purpose][ended.reason]}
{lost ?
{answerLostRemoval(ended.burrowId)}
: null} {ended.reason === 'failed' && ended.message ?
{ended.message}
: null} {ended.reason === 'not-entitled' ? ( @@ -103,13 +126,44 @@ export function HostedEnrollmentEnded({ ); } +/** + * A Hosted approval under way, for either purpose: the code waiting, the + * credential being saved, or why it ended. The host polls; this renders its + * state, never one of its own. + */ +export function HostedApprovalProgress({ + enrollment, + purpose, + accountOrigin, + busy, + onCancel, +}: { + enrollment: HostedEnrollmentState | null | undefined; + purpose: HostedApprovalPurpose; + accountOrigin: string | null; + busy: boolean; + onCancel: () => void; +}) { + if (enrollment?.status === 'waiting') { + return ; + } + if (enrollment?.status === 'redeeming') { + return ( +
+ {HOSTED_APPROVAL_REDEEMING_COPY[purpose]} +
+ ); + } + if (enrollment?.status === 'ended') { + return ; + } + return null; +} + /** * Enrolling (`docs/specs/hosted.md` -> "Burrow enrollment"): a button that * begins it, under the name to keep for this machine where `askName`; then - * the code, in large type, a button that opens the account page the service - * composed to approve it, the time left, and Cancel; ended, why, with a new - * code a click away. The service polls; this renders its `status`, never a - * state of its own. + * {@link HostedApprovalProgress}; ended, a new code a click away. * * **The name field is hidden while a code waits, never unmounted**, so what * was typed survives a Cancel. Without `askName` the service's suggested name @@ -147,20 +201,13 @@ export function HostedEnrollView({ return (
- {waiting ? ( - void run(cancelHostedEnrollment)} - /> - ) : null} - {redeeming ? ( -
- {HOSTED_ENROLLMENT_REDEEMING_COPY} -
- ) : null} - {ended ? : null} + void run(cancelHostedEnrollment)} + />
)} {notice && ( -

- {notice} +

+ {notice.text}

)} {enrolling && (session || !enrolling.code) ? ( @@ -751,7 +769,7 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) { onClick={() => { setEnterCode(false); setCode(""); - setNotice(""); + setNotice(null); setError(keepRead); }} > diff --git a/hosted/src/api.ts b/hosted/src/api.ts index 915a76cee..5bbf8ded2 100644 --- a/hosted/src/api.ts +++ b/hosted/src/api.ts @@ -181,3 +181,9 @@ export async function approveEnrollment( }); if (!response.ok) throw await refused(response); } +/** Whether this account's live approval of `userCode` was typed with other digits than its computer showed. */ +export async function getApprovalMismatched(userCode: string): Promise { + const response = await relay(`/enrollments/${encodeURIComponent(userCode)}`); + if (!response.ok) throw await refused(response); + return ((await response.json()) as { mismatched: boolean }).mismatched; +} diff --git a/hosted/src/settle.ts b/hosted/src/settle.ts index 2421f567b..2b0f20987 100644 --- a/hosted/src/settle.ts +++ b/hosted/src/settle.ts @@ -4,6 +4,43 @@ import type { Computer, HostedVoice } from "./api"; export interface AccountState { voice: HostedVoice | null; computers: Computer[] | null; + /** + * Whether the approval the page waits on was typed with other digits than + * its computer showed; unset when none is waited on, or the read failed. + */ + mismatched?: boolean; +} + +/** A notice the page shows; `error` when it reports a failure. */ +export interface Notice { + text: string; + error?: boolean; +} + +/** An approval Dormouse on the computer finishes after the page's mutation. */ +export interface Pending { + /** The approved user code, read for a mismatch while the page waits. */ + userCode: string; + /** Whether the account, as last read, shows the change. */ + shows: (after: AccountState) => boolean; + waiting: string; + done: string; + late: string; + /** Once a read finds the digits typed were not the computer's. */ + mismatch: string; +} + +/** + * The notice a read of the account ends `pending` with, or null while it + * shows neither end. A mismatch first: that approval never redeems, so a + * change shown beside it is another's. + */ +export function pendingEnd( + pending: Pending, + after: AccountState, +): Notice | null { + if (after.mismatched) return { text: pending.mismatch, error: true }; + return pending.shows(after) ? { text: pending.done } : null; } /** @@ -92,33 +129,35 @@ export async function reread( } } -export type Settled = "done" | "timeout" | "cancelled"; +/** How {@link settle} stopped: a read's end, or no read ended it. */ +export type Settled = { ended: R } | "timeout" | "cancelled"; const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); /** - * Reads every `intervalMs` until a read satisfies `done`, `timeoutMs` passes, - * or `cancelled()` holds. A read that fails, or resolves `undefined` (one a - * newer read superseded), only waits for the next. + * Reads every `intervalMs` until `end` maps a read to something other than + * null, `timeoutMs` passes, or `cancelled()` holds. A read that fails, or + * resolves `undefined` (one a newer read superseded), only waits for the next. */ -export async function settle( +export async function settle( read: () => Promise, - done: (value: T) => boolean, + end: (value: T) => R | null, options: { intervalMs: number; timeoutMs: number; cancelled: () => boolean; wait?: (ms: number) => Promise; }, -): Promise { +): Promise> { const { intervalMs, timeoutMs, cancelled, wait = delay } = options; for (let waited = 0; waited < timeoutMs; waited += intervalMs) { await wait(intervalMs); if (cancelled()) return "cancelled"; const value = await read().catch(() => undefined); if (cancelled()) return "cancelled"; - if (value !== undefined && done(value)) return "done"; + const ended = value === undefined ? null : end(value); + if (ended !== null) return { ended }; } return "timeout"; } diff --git a/hosted/src/style.css b/hosted/src/style.css index 1358e4a5e..54d8ead6a 100644 --- a/hosted/src/style.css +++ b/hosted/src/style.css @@ -192,6 +192,9 @@ section { background: var(--vscode-list-inactiveSelectionBackground); color: var(--vscode-list-inactiveSelectionForeground); } +.notice.failed { + color: var(--vscode-errorForeground); +} .identity { margin: 0; } From 1ced9ba218e69622d9cf1fea02cb71cb24db0763 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 21 Oct 2026 08:25:48 -0700 Subject: [PATCH 8/9] Hosted account page: wait on the approval's own redemption when nothing else shows it With Natural Voice already on, or no computers list read, an approval's redemption changes nothing on the page, so it showed a fixed notice and a wrong-digits entry went unshown. The approval read now answers { mismatched, redeemed } (still the signed-in account's own live approval only), and approvalShows falls back to that redemption where no change could show it: the page waits as it does elsewhere, ending in done, the mismatch error, or the late notice. Co-Authored-By: Claude Opus 5.5 --- docs/specs/hosted.md | 4 ++-- docs/specs/security-hosted.md | 2 +- hosted/server/relay-account.ts | 23 +++++++++---------- hosted/server/tests/settle.test.ts | 35 +++++++++++++++++++++-------- hosted/server/tests/workers.test.ts | 18 +++++++++++---- hosted/src/App.tsx | 17 ++++++-------- hosted/src/api.ts | 11 ++++++--- hosted/src/settle.ts | 28 +++++++++++------------ 8 files changed, 82 insertions(+), 56 deletions(-) diff --git a/docs/specs/hosted.md b/docs/specs/hosted.md index 38e905cc8..ce85da54f 100644 --- a/docs/specs/hosted.md +++ b/docs/specs/hosted.md @@ -211,7 +211,7 @@ The pre-launch sign-in gate is "Identity and login". Begin refuses an absent or mismatched origin with `ORIGIN_MISMATCH_ERROR`. The verification link points to the account's `/enroll` page, with the user code in its fragment and voice purpose in its query. `redeemed` names the Burrow whose answer was lost; 409 names the account page at the abuse ceiling `MAX_BURROWS_PER_ACCOUNT` (`docs/specs/pricing.md` -> "Checkout and entitlement"). - **Never write from begin.** The device code is 32 bytes, the bearer shape: its expiry in its leading bytes, the rest random. The user code is `enrollUserCode` under `RELAY_ENROLL_SECRET` for remote enrollment, or that secret suffixed with `:voice` for managed voice (rationale). The confirmation digits are `enrollConfirmDigits` of the user code under `RELAY_ENROLL_SECRET`; the link carries the user code, never the digits. -- **Must redeem only an approval whose typed digits are its code's confirmation digits, compared at the poll** (rationale). Any other answers `mismatch` to every poll until it expires: one attempt per code. The first such poll stamps the live, unredeemed approval's `mismatchedAt`, so its approver's waiting page says so at once. +- **Must redeem only an approval whose typed digits are its code's confirmation digits, compared at the poll** (rationale). Any other answers `mismatch` to every poll until it expires: one attempt per code. The first such poll stamps the live, unredeemed approval's `mismatchedAt` for its approver's waiting page. - **Must store the approval alone** (`dormouse_relay_enrollment_approvals`): it cannot tell an issued code from any well-formed one, so it approves any; one no Burrow redeems expires (rationale). - **Never admit a begin or poll request carrying `Origin`** (403): only a Node Burrow calls them. Then 429 with `Retry-After` past `RELAY_ENROLL_BEGIN_LIMIT` (10 a minute per address) or `RELAY_ENROLL_POLL_LIMIT` (60), before the body limit and any database read. - **Must answer an expired device code from the code alone**, reading no database. @@ -221,7 +221,7 @@ Begin refuses an absent or mismatched origin with `ORIGIN_MISMATCH_ERROR`. The v | Route (account) | Credential | Success | |---|---|---| | `POST /api/relay/enrollments/approve` | login cookie, exact `Origin`, JSON `{ userCode, purpose, confirmDigits }` | 204 | -| `GET /api/relay/enrollments/:userCode` | login cookie, same-origin fetch | 200 `{ mismatched }`, true only for the caller's own live approval a poll stamped | +| `GET /api/relay/enrollments/:userCode` | login cookie, same-origin fetch | 200 `{ mismatched, redeemed }`, each true only for the caller's own live approval | | `GET /api/relay/burrows` | login cookie, same-origin fetch | 200 `{ burrows }` | | `DELETE /api/relay/burrows/:burrowId` | login cookie, exact `Origin` | 204; 404 for another account's or an unknown ID | | `GET /api/relay/passkeys` | login cookie, same-origin fetch | 200 `{ passkeys }`, each with the enrolled Burrow whose setup token registered it, or none | diff --git a/docs/specs/security-hosted.md b/docs/specs/security-hosted.md index b642e7144..ad9b91aac 100644 --- a/docs/specs/security-hosted.md +++ b/docs/specs/security-hosted.md @@ -68,7 +68,7 @@ What ElevenLabs keeps of it is `docs/specs/security.md` -> "What is not defended - **FAIL IF** `S100` a Burrow-authenticated request, setup redemption, enrollment redemption, sign-in, or session-gated request skips rechecking the owner's entitlement (`entitledSql` on its user row) in that request, so a de-entitled account's session acts or a sign-in mints one; or a removed Burrow's row, setup tokens, or setup challenges survive its removal, or removal reaches another account's Burrow; or a removed passkey's sessions survive its removal, a session is stored naming no passkey, or removal reaches another account's passkey. - **FAIL IF** `S101` a route that presents no credential writes a row other than a sign-in challenge (enrollment begin writes none); setup routes write only under a live setup token and a poll only under an approval; a device code's expiry is read from anywhere but its own leading bytes, or a code past it reaches the database; or a user code is anything but the purpose-bound derivation in `docs/specs/hosted.md` -> "Burrow enrollment". - **FAIL IF** `S102` an approval is redeemed other than in the one statement that marks it redeemed and inserts only its purpose's credential (Burrow or voice token), or a redeemed approval redeems again (removing its Burrow included), so two polls can mint two Burrows; the Burrow is owned by anyone but the approval's `userId`; a poll redeems an approval for another purpose or a user code its device code does not derive; an approval replaces a live one; or a poll or approval treats an approval past its `expiresAt` as live (an approval may only replace it). -- **FAIL IF** `S276` a poll redeems an approval whose typed digits are not `enrollConfirmDigits` of its user code under `RELAY_ENROLL_SECRET`, in its read and in its redeeming statement both; `mismatchedAt` is stamped other than by a poll under a live, unredeemed approval, read by any account but the approval's own, or lets an approval redeem; begin answers digits derived otherwise; the approval route stores one without exactly two digits; or a `verificationUrl` carries them. +- **FAIL IF** `S276` a poll redeems an approval whose typed digits are not `enrollConfirmDigits` of its user code under `RELAY_ENROLL_SECRET`, in its read and in its redeeming statement both; `mismatchedAt` is stamped other than by a poll under a live, unredeemed approval or lets an approval redeem; another account reads an approval's mismatch or redemption; begin answers digits derived otherwise; the approval route stores one without exactly two digits; or a `verificationUrl` carries them. - **FAIL IF** `S103` enrollment begin or poll admits a request carrying `Origin`, or either reaches the database before its per-address limit. - **FAIL IF** `S104` an approval admits a request without a login, with an `Origin` other than the account's own exactly, from a login older than `LOGIN_FRESH_AGE_MS` or one whose creation time it cannot read, for an account but the entitled admin, or past the per-account attempt limit (`RELAY_APPROVE_LIMIT`, counted before the user code is parsed). - **FAIL IF** `S105` a Relay table a caller can grow has no cap keyed by whoever grows it (approvals: the per-account attempt limit; flat sign-in challenges: the per-address limit and the sweep), or a capped write deletes another key's rows; `signin/*` or `setup/begin`/`finish` reaches the database before its per-address limit; or a production `namespace_id` reaches `PREVIEW_RATELIMIT_OFFSET`. diff --git a/hosted/server/relay-account.ts b/hosted/server/relay-account.ts index 71a1b3c5b..405ffcb4b 100644 --- a/hosted/server/relay-account.ts +++ b/hosted/server/relay-account.ts @@ -85,22 +85,21 @@ export function relayAccountRoutes(app: Hono, host: (c: Context) => RelayAc return approved ? c.body(null, 204) : c.json({ message: ALREADY_APPROVED }, 409); }); - // Whether the poll found this account's live approval of the code typed - // with other digits: another account's approval, or none, reads as not. + // What a poll made of this account's live approval of the code: typed with + // other digits, or redeemed. Another account's approval, or none, reads as + // neither. app.get("/api/relay/enrollments/:userCode", gate, async (c) => { const userCode = normalizeEnrollUserCode(c.req.param("userCode")); if (userCode === null) return c.json({ message: "That is not a code from Dormouse." }, 400); - const mismatched = - ( - await accountQuery( - host(c), - `SELECT 1 FROM dormouse_relay_enrollment_approvals - WHERE "userCode" = $1 AND "userId" = $2 AND "expiresAt" > now() AND "mismatchedAt" IS NOT NULL`, - [userCode, c.get("login").userId], - ) - ).length > 0; - return c.json({ mismatched }); + const [approval] = await accountQuery<{ mismatched: boolean; redeemed: boolean }>( + host(c), + `SELECT "mismatchedAt" IS NOT NULL AS mismatched, "redeemedAt" IS NOT NULL AS redeemed + FROM dormouse_relay_enrollment_approvals + WHERE "userCode" = $1 AND "userId" = $2 AND "expiresAt" > now()`, + [userCode, c.get("login").userId], + ); + return c.json(approval ?? { mismatched: false, redeemed: false }); }); app.get("/api/relay/burrows", gate, async (c) => { diff --git a/hosted/server/tests/settle.test.ts b/hosted/server/tests/settle.test.ts index c3c75f1cc..64c987492 100644 --- a/hosted/server/tests/settle.test.ts +++ b/hosted/server/tests/settle.test.ts @@ -19,28 +19,45 @@ const state = (on: boolean | null, ids: string[] | null): AccountState => ({ computers: ids && ids.map(computer), }); +/** `after` with the approval's own read. */ +const approved = ( + after: AccountState, + approval: { mismatched?: boolean; redeemed?: boolean }, +): AccountState => ({ ...after, approval: { mismatched: false, redeemed: false, ...approval } }); + describe("approvalShows", () => { it("waits on a voice approval until Natural Voice is on", () => { - const shows = approvalShows("voice", state(false, []))!; + const shows = approvalShows("voice", state(false, [])); expect(shows(state(false, []))).toBe(false); expect(shows(state(null, []))).toBe(false); expect(shows(state(true, []))).toBe(true); }); - it("has nothing to wait on when Natural Voice is already on", () => { - expect(approvalShows("voice", state(true, []))).toBeNull(); + it("waits on the approval's own redemption when Natural Voice is already on", () => { + const shows = approvalShows("voice", state(true, [])); + expect(shows(state(true, []))).toBe(false); + expect(shows(approved(state(true, []), { redeemed: false }))).toBe(false); + expect(shows(approved(state(true, []), { redeemed: true }))).toBe(true); }); it("waits on a remote approval until a computer not listed before appears", () => { - const shows = approvalShows("remote", state(false, ["a", "b"]))!; + const shows = approvalShows("remote", state(false, ["a", "b"])); expect(shows(state(false, ["a", "b"]))).toBe(false); expect(shows(state(false, ["a"]))).toBe(false); expect(shows(state(false, null))).toBe(false); expect(shows(state(false, ["c", "a"]))).toBe(true); }); - it("has nothing to compare a remote approval with when no list was read", () => { - expect(approvalShows("remote", state(false, null))).toBeNull(); + it("waits on the approval's own redemption when no computers list was read", () => { + const shows = approvalShows("remote", state(false, null)); + expect(shows(state(false, ["c"]))).toBe(false); + expect(shows(approved(state(false, null), { redeemed: false }))).toBe(false); + expect(shows(approved(state(false, null), { redeemed: true }))).toBe(true); + }); + + it("waits on a change the page shows where one can, not the redemption read", () => { + expect(approvalShows("voice", state(false, []))(approved(state(false, []), { redeemed: true }))).toBe(false); + expect(approvalShows("remote", state(false, []))(approved(state(false, []), { redeemed: true }))).toBe(false); }); }); @@ -56,14 +73,14 @@ describe("pendingEnd", () => { it("waits until a read shows the change or the approval's mismatch", () => { expect(pendingEnd(pending, state(false, []))).toBeNull(); - expect(pendingEnd(pending, { ...state(false, []), mismatched: false })).toBeNull(); + expect(pendingEnd(pending, approved(state(false, []), { mismatched: false }))).toBeNull(); expect(pendingEnd(pending, state(true, []))).toEqual({ text: "done" }); }); it("ends a mismatched approval with its error, even beside a change another made", () => { const error = { text: "mismatch", error: true }; - expect(pendingEnd(pending, { ...state(false, []), mismatched: true })).toEqual(error); - expect(pendingEnd(pending, { ...state(true, []), mismatched: true })).toEqual(error); + expect(pendingEnd(pending, approved(state(false, []), { mismatched: true }))).toEqual(error); + expect(pendingEnd(pending, approved(state(true, []), { mismatched: true }))).toEqual(error); }); }); diff --git a/hosted/server/tests/workers.test.ts b/hosted/server/tests/workers.test.ts index f56ba3f96..13962650b 100644 --- a/hosted/server/tests/workers.test.ts +++ b/hosted/server/tests/workers.test.ts @@ -420,7 +420,7 @@ async function fixture( body: JSON.stringify({ userCode, confirmDigits }), headers: { origin: from, "content-type": "application/json" }, }); - /** Whether this account's live approval of `userCode` was found mismatched, as the page reads it. */ + /** What a poll made of this account's live approval of `userCode`, as the page reads it. */ const mismatched = async (userCode: string, headers?: Record) => { const response = await request(`/api/relay/enrollments/${encodeURIComponent(userCode)}`, { headers }); return { status: response.status, json: await response.json() }; @@ -1234,7 +1234,7 @@ test("enrollment: only a recent admin login from this origin approves, and the B ).toEqual([{ redeemedBurrowId: null, redeemedAt: null }]); }); -test("the approver's page reads its approval's mismatch once a poll found it, and only its own approval's", async ({ +test("the approver's page reads its approval's mismatch or redemption once a poll found it, and only its own approval's", async ({ onTestFinished, }) => { const f = await fixture(); @@ -1245,7 +1245,7 @@ test("the approver's page reads its approval's mismatch once a poll found it, an await other.email("other@example.test"); const begun = await f.begin(); const wrong = String((Number(begun.confirmDigits) + 1) % 100).padStart(2, "0"); - const unmatched = { status: 200, json: { mismatched: false } }; + const unmatched = { status: 200, json: { mismatched: false, redeemed: false } }; expect(await admin.mismatched(begun.userCode)).toEqual(unmatched); expect((await admin.approve(begun.userCode, wrong)).status).toBe(204); // Approved, but no poll has compared the digits yet. @@ -1254,7 +1254,7 @@ test("the approver's page reads its approval's mismatch once a poll found it, an // The code forgives case and spacing, as the approval does. expect(await admin.mismatched(` ${begun.userCode.toLowerCase().replace("-", "")} `)).toEqual({ status: 200, - json: { mismatched: true }, + json: { mismatched: true, redeemed: false }, }); expect(await admin.mismatched("not a code")).toEqual({ status: 400, @@ -1290,6 +1290,16 @@ test("the approver's page reads its approval's mismatch once a poll found it, an ), ).toEqual([{ mismatchedAt: null }]); expect(await admin.mismatched(begun.userCode)).toEqual(unmatched); + + // The code's own digits: redeemed once a poll redeems it, never mismatched. + const matched = await f.begin(); + expect((await admin.approve(matched.userCode, matched.confirmDigits)).status).toBe(204); + expect(await admin.mismatched(matched.userCode)).toEqual(unmatched); + expect((await f.poll(matched.deviceCode)).json.status).toBe("enrolled"); + expect(await admin.mismatched(matched.userCode)).toEqual({ + status: 200, + json: { mismatched: false, redeemed: true }, + }); }); test("the account lists its passkeys with the computer that added each, and removing one ends its sessions", async ({ diff --git a/hosted/src/App.tsx b/hosted/src/App.tsx index a727d62d0..0b83daf13 100644 --- a/hosted/src/App.tsx +++ b/hosted/src/App.tsx @@ -9,7 +9,7 @@ import { acceptTerms, approveEnrollment, getAccounts, - getApprovalMismatched, + getApproval, getComputers, getHostedVoice, getPasskeys, @@ -123,7 +123,7 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) { // Bumped by every action and on unmount: stops re-reading for a Pending. const actionGeneration = useRef(0); // The Pending the page waits on, from its first re-read until a read ends - // it or an action: each read asks whether its digits mismatched. Once its + // it or an action: each read asks what a poll made of it. Once its // late notice is up (`late`), a later read that ends it replaces that. const awaited = useRef<{ pending: Pending; late: boolean } | null>(null); const callbackError = useRef( @@ -142,7 +142,7 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) { ).catch(failed); // A failed list hides its section, never the account page. const watched = awaited.current?.pending.userCode; - const [linked, voice, enrolled, registered, mismatched] = current + const [linked, voice, enrolled, registered, approval] = current ? await Promise.all([ asRead(getAccounts()).catch(failed), getHostedVoice().catch(() => null), @@ -150,7 +150,7 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) { getPasskeys().catch(() => null), watched === undefined ? undefined - : getApprovalMismatched(watched).catch(() => undefined), + : getApproval(watched).catch(() => undefined), ]) : [[], null, null, null, undefined]; if (current) await recordContinued().catch(() => {}); @@ -168,7 +168,7 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) { "", enrollingRef.current ? "/enroll" : current ? "/account" : "/login", ); - const state: AccountState = { voice, computers: enrolled, mismatched }; + const state: AccountState = { voice, computers: enrolled, approval }; const late = awaited.current?.late && pendingEnd(awaited.current.pending, state); if (late) { awaited.current = null; @@ -346,7 +346,8 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) { mutate("approve", async () => { const voice = enrolling?.purpose === "voice"; // The computer's next poll redeems the approval: until then neither - // Natural Voice nor the computer shows on the account. + // Natural Voice nor the computer shows on the account, nor the + // approval's own read says so. const shows = approvalShows(voice ? "voice" : "remote", { voice: hostedVoice, computers, @@ -355,10 +356,6 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) { clearEnrollment(); const later = "Keep Dormouse open on your computer; this page updates when you come back to it."; - if (!shows) - return setNotice({ - text: `Approved ${code}. Dormouse on your computer finishes the requested setup in a few seconds.`, - }); const mismatch = `The digits you typed for ${code} were not the ones Dormouse showed on your computer, so nothing was approved. Start again from Dormouse on your computer.`; return voice ? { diff --git a/hosted/src/api.ts b/hosted/src/api.ts index 5bbf8ded2..4244746e2 100644 --- a/hosted/src/api.ts +++ b/hosted/src/api.ts @@ -181,9 +181,14 @@ export async function approveEnrollment( }); if (!response.ok) throw await refused(response); } -/** Whether this account's live approval of `userCode` was typed with other digits than its computer showed. */ -export async function getApprovalMismatched(userCode: string): Promise { +/** What a poll made of this account's live approval of a code; neither when there is none. */ +export interface ApprovalState { + /** Typed with other digits than its computer showed: it never redeems. */ + mismatched: boolean; + redeemed: boolean; +} +export async function getApproval(userCode: string): Promise { const response = await relay(`/enrollments/${encodeURIComponent(userCode)}`); if (!response.ok) throw await refused(response); - return ((await response.json()) as { mismatched: boolean }).mismatched; + return (await response.json()) as ApprovalState; } diff --git a/hosted/src/settle.ts b/hosted/src/settle.ts index 2b0f20987..8ed708de6 100644 --- a/hosted/src/settle.ts +++ b/hosted/src/settle.ts @@ -1,14 +1,11 @@ -import type { Computer, HostedVoice } from "./api"; +import type { ApprovalState, Computer, HostedVoice } from "./api"; /** The part of the account page's state a change finished elsewhere shows in. */ export interface AccountState { voice: HostedVoice | null; computers: Computer[] | null; - /** - * Whether the approval the page waits on was typed with other digits than - * its computer showed; unset when none is waited on, or the read failed. - */ - mismatched?: boolean; + /** The approval the page waits on; unset when none is, or its read failed. */ + approval?: ApprovalState; } /** A notice the page shows; `error` when it reports a failure. */ @@ -19,7 +16,7 @@ export interface Notice { /** An approval Dormouse on the computer finishes after the page's mutation. */ export interface Pending { - /** The approved user code, read for a mismatch while the page waits. */ + /** The approved user code, read while the page waits. */ userCode: string; /** Whether the account, as last read, shows the change. */ shows: (after: AccountState) => boolean; @@ -39,23 +36,24 @@ export function pendingEnd( pending: Pending, after: AccountState, ): Notice | null { - if (after.mismatched) return { text: pending.mismatch, error: true }; + if (after.approval?.mismatched) return { text: pending.mismatch, error: true }; return pending.shows(after) ? { text: pending.done } : null; } /** - * What an approval's redemption changes on the page, read against the state - * before it: Natural Voice turning on, or a computer not listed before. Null - * when no change could show it: voice already on, or no computers list to - * compare with. + * What shows an approval redeemed, read against the state before it: Natural + * Voice turning on, or a computer not listed before. Where no change could + * show it (voice already on, or no computers list to compare with), the + * approval's own read saying it was redeemed. */ export function approvalShows( purpose: "remote" | "voice", before: AccountState, -): ((after: AccountState) => boolean) | null { +): (after: AccountState) => boolean { + const redeemed = (after: AccountState) => after.approval?.redeemed === true; if (purpose === "voice") - return before.voice?.on ? null : (after) => after.voice?.on === true; - if (!before.computers) return null; + return before.voice?.on ? redeemed : (after) => after.voice?.on === true; + if (!before.computers) return redeemed; const known = new Set(before.computers.map((computer) => computer.burrowId)); return (after) => !!after.computers?.some((computer) => !known.has(computer.burrowId)); From 4f1fc9c6a78f7c2b525a4871694a0c28b932fd65 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 21 Oct 2026 09:00:01 -0700 Subject: [PATCH 9/9] Fix the two tests the confirm digits reached outside this diff The vscode-ext begin stub now answers confirmDigits, which the begin guard requires, and the passkey-removal canary's migrations hunk takes the 011 pin as context. Co-Authored-By: Claude Opus 5.5 --- .github/audit/canaries/passkey-removal-keeps-sessions.patch | 4 +++- vscode-ext/test/burrow.test.ts | 5 +++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/audit/canaries/passkey-removal-keeps-sessions.patch b/.github/audit/canaries/passkey-removal-keeps-sessions.patch index 165220fa2..41140be08 100644 --- a/.github/audit/canaries/passkey-removal-keeps-sessions.patch +++ b/.github/audit/canaries/passkey-removal-keeps-sessions.patch @@ -48,12 +48,14 @@ diff --git a/hosted/server/tests/migrations.test.ts b/hosted/server/tests/migrat index 09806ad75..cb7005345 100644 --- a/hosted/server/tests/migrations.test.ts +++ b/hosted/server/tests/migrations.test.ts -@@ -32,7 +32,7 @@ const PINNED: Record = { +@@ -32,9 +32,9 @@ const PINNED: Record = { "009_relay_passkey_removal.sql": "c6c220960187e33a1350dc85e93d24f99866a72a579ed9def6a3663643f3e928", "010_relay_session_credential_required.sql": - "f385398d16126381b6a976d74e23e10b07370c1a1da3205e71697aa62a3399f5", + "892b0116797b52e9f42617be5703d2e516f3ec5c6d37cbc8c4f8504e4fcce969", + "011_enrollment_confirm_digits.sql": + "54afe9ae8a2efd3c654503d479fce96c1ce8ff4f2df249aa93b5a91e925def6e", }; const directory = new URL("../dormouse-migrations/", import.meta.url); diff --git a/vscode-ext/test/burrow.test.ts b/vscode-ext/test/burrow.test.ts index cc83c7138..7494dc4b8 100644 --- a/vscode-ext/test/burrow.test.ts +++ b/vscode-ext/test/burrow.test.ts @@ -1635,6 +1635,7 @@ describe('serving the other windows', () => { // The bearer shape: 32 bytes, base64url. deviceCode: String(begun).repeat(43), userCode: '23AB-YZ9K', + confirmDigits: '42', expiresAt: Date.now() + 10 * 60_000, interval: 5, }), @@ -1650,13 +1651,13 @@ describe('serving the other windows', () => { try { mod.handleBurrowCommand({ burrowRequestId: 'rh-0', cmd: 'beginHostedEnrollment', params: { label: 'Laptop' } }); await waitFor(() => results(bound.posted).length > 0); - expect(results(bound.posted)[0]).toMatchObject({ result: { status: 'waiting', userCode: '23AB-YZ9K' } }); + expect(results(bound.posted)[0]).toMatchObject({ result: { status: 'waiting', confirmDigits: '42' } }); const far = fakeWindow(); const link = await openFarWindow(far); link.forwardCommand({ burrowRequestId: 'rh-1', cmd: 'beginHostedEnrollment', params: { label: 'Other' } }); await waitFor(() => far.results.length > 0); - expect(far.results[0]).toMatchObject({ result: { status: 'waiting', userCode: '23AB-YZ9K' } }); + expect(far.results[0]).toMatchObject({ result: { status: 'waiting', confirmDigits: '42' } }); expect(begun).toBe(1); } finally { window.dispose();