diff --git a/NOTES.md b/NOTES.md index 10a8c3b..7c217d5 100644 --- a/NOTES.md +++ b/NOTES.md @@ -2221,6 +2221,60 @@ by recovery; boot pull never awaited by unseal), the pairing battery's chain act asserting zero duplicate chunk names across concurrent sibling flushes, resume/pair/check green, invariants 9/9. +**Recovery: the account outlives its last device** (2026-08-25; design +record [runtime/RECOVERY.md](runtime/RECOVERY.md)). The round that +closed SYNC.md's parked us-doc-through-the-bucket and #11's recovery +body. The ruling: **recovery is a DEVICE, not a resurrection** — the +kit ceremony mints a dormant member leaf through the ordinary +`enroll_device` path (visible in the devices sheet, revocable like a +lost phone) whose secrets exist only in a sealed bundle; dormancy is +what kills bundle staleness (the G5 self-rotation finding never fires +for a leaf that never authors — the proven G4 tablet path with the +tablet replaced by a blob), and the K_p pickup answers the us-doc +bootstrap chicken-and-egg (one pickup, sealed to prekeys that ride the +bundle, bootstraps everything; `KpPayload.devices` became directory ∪ +grantees on the way, without which a post-SYNC.md account's pickup +named no real author). Two kit kinds, one mechanism: a BUCKET kit +under a generated 10-word EFF-short phrase (argon2id at a fixed +context salt → HKDF → both the object name and the KEK — the exposure +rule made structural: replicated copies get generated-secret slots +only; S3-only at this rev, names being locations only there) and a +FILE kit under a user passphrase (the custody exception, warned +loudly, provider-neutral; the owner's amendment — disallowing custody +would be paternalism). **Single-use, consumed at restore**: dormancy +ends at first authoring, double-restore is an identity fork against +the single-writer-per-name invariant, and a phrase captured during +the ceremony must be worthless afterward; the K_p's deletion enforces +single-use even for a file we cannot delete (a second restore is a +404, never a fork). The us-doc now rides the ordinary flush/pull +cycle via an empty-doc-id sentinel; restore is a two-stage worker +ceremony (bind with fail-at-bind, engine born from the bundle instead +of init, fan-out, checkpoint, then consume) ending at the claim — +colour, name, icon from the pulled profile. Three defects found and +pinned during the round, each with a regression owner: the solo +page's nested-`enqueue` self-deadlock (a file-kit mint wedged the +page-wide chain forever; the sheet's own "measured" comment had +recorded the symptom as an engine bug), the consume-outlives-its- +checkpoint strand (internal driver mutations bypass the client-call +debounce, and the pull fan-out's self-filter keeps a device's own +flushed clear permanently out of reach — every internal mutation now +checkpoints explicitly; devstore row 64 pins it with a negative +control), and the same stranding for kit create/revoke. Known +limitation recorded, follow-up owed: a name-key rotation strands a +bucket-only lagging sibling on the us-doc until wire contact — +pre-existing SYNC.md territory that kit revocation now makes +reachable. Parked: Drive bucket kits, the PRF second-input slot, kit +migration on storage rebind, platform-posture migration for restored +devices. Gates: a new `just recover` battery (8 acts: both kinds +round-trip against MinIO with a post-kit revocation epoch crossed +before restore, refusals as classes, consume verified by object +set-difference and idempotent retry, the restored device's own-view +registry agreement), devstore 64-row matrix, e2e 27/27 (`solo-recovery` +destroys the browser context and restores from phrase + re-entered +credentials alone; `solo-recovery-file` catches the real download and +proves the wrong passphrase is one clean keyslot miss), +pair/resume/check green, invariants 9/9. + ## Parked and candidate non-goals - **Metadata privacy**: relays, push services, and origins see traffic diff --git a/demo/e2e/run.ts b/demo/e2e/run.ts index edb0d93..86fcae6 100644 --- a/demo/e2e/run.ts +++ b/demo/e2e/run.ts @@ -67,6 +67,8 @@ import drawerOverflow from "./scenarios/drawer-overflow.ts"; import soloAccountStorage from "./scenarios/solo-account-storage.ts"; import soloOfflineSync from "./scenarios/solo-offline-sync.ts"; import soloPasskey from "./scenarios/solo-passkey.ts"; +import soloRecovery from "./scenarios/solo-recovery.ts"; +import soloRecoveryFile from "./scenarios/solo-recovery-file.ts"; import visorReset from "./scenarios/visor-reset.ts"; import firefoxSmoke from "./scenarios/firefox-smoke.ts"; import crossEnginePairing from "./scenarios/cross-engine-pairing.ts"; @@ -268,6 +270,24 @@ const SCENARIOS: Scenario[] = [ // follows (nothing in this suite currently depends on that, but nor // did the observation cost anything to write down). soloPasskey, + // ACCOUNT RECOVERY (runtime/RECOVERY.md's T-C gate) — the round's + // money shot, and the only pair of scenarios in this suite that + // DESTROY a browser context mid-story rather than merely reloading. + // + // THEY RUN HERE, after every storage scenario, because they consume + // all of it: an account, a bound bucket, a flush that actually landed, + // and then a wiped browser that has to find its way back to the + // account with nothing but a phrase (or a file) and the credentials + // the user remembers. A failure here with solo-storage and + // solo-offline-sync green says the fault is in the RECOVERY path — the + // kit ceremony, the restore bring-up, the consume — and not in the + // egress or the schedule it rides on. + // + // The phrase kind goes first: it is the record's primary kind, it + // needs no filesystem, and a failure in it makes the file kind's + // failure much easier to read. + soloRecovery, + soloRecoveryFile, // THE TWO RELAY-PARTITION PINS, both expected RED and both SLOW — // each spends minutes proving a heal that does not come (the // freshly-paired ceremony wires never re-dial, and `conn-status` diff --git a/demo/e2e/scenarios/solo-recovery-file.ts b/demo/e2e/scenarios/solo-recovery-file.ts new file mode 100644 index 0000000..24f595d --- /dev/null +++ b/demo/e2e/scenarios/solo-recovery-file.ts @@ -0,0 +1,298 @@ +// THE FILE KIT — the owner's amendment to runtime/RECOVERY.md, driven +// end to end: a bundle the user KEEPS, opened by a passphrase the user +// CHOSE, restoring an account into a wiped browser. +// +// WHY IT IS A SEPARATE SCENARIO FROM `solo-recovery`. The two kinds +// share a mechanism and differ in exactly the places that are worth a +// test of their own — a different secret slot (argon2id over a +// user-chosen passphrase with a random per-file salt, rather than a KEK +// derived from generated entropy), a different way of finding the bundle +// (the file IS the bundle; nothing is fetched by a derived name), and a +// different failure mode when the secret is wrong. Folding it into the +// phrase scenario would have produced one long scenario whose failure +// told you less about which half broke. +// +// FOUR CLAIMS: +// +// 1. THE LOUD WARNING IS ON SCREEN BEFORE THE PASSPHRASE IS CHOSEN +// (RECOVERY.md: "disallowing custody would be paternalism, so the +// ceremony WARNS LOUDLY instead"). All three of the record's +// sentences are asserted, because the amendment is the copy: a file +// kit whose ceremony went quiet would be the paternalism ruling +// inverted into negligence. +// 2. THE CONFIRM FIELD IS REAL: two different passphrases are refused +// before anything is minted. A mistyped passphrase on a file kit is +// undiscoverable until the disaster — nothing ever asks for it +// again until then — so this is the one chance to catch it. +// 3. A WRONG PASSPHRASE REFUSES, AND THE CEREMONY RECOVERS. This is +// the "no wedged ceremony" rule at its sharpest: the person typing +// is mid-disaster and has one artifact left, and a sheet that died +// on the first mistyped attempt would strand them with a file that +// was always fine. +// 4. THE RIGHT ONE RESTORES THE ACCOUNT, into a context that was +// destroyed and recreated — the todos come back. +// +// The file arrives through Playwright's DOWNLOAD event, which is the +// real delivery path: the sheet mints a blob URL and clicks an anchor, +// exactly as it does for a user, and the harness catches what the +// browser was handed rather than reaching into page memory for bytes. + +import type { Ctx, Scenario } from "../run.ts"; +import { act, assert, assertEquals, assertIncludes, SOLO_KEYS, waitForBoot } from "../util.ts"; +import { addTodo, createAccount, solo, until, WAITS } from "../solo-util.ts"; +import type { Page } from "npm:playwright@1.57.0"; + +const BUCKET = "pm-recovery-file"; +const ACCESS = "minioadmin"; +const SECRET = "minioadmin"; +const TODOS = ["water the plants"]; +const KIT_PASS = "a-passphrase-the-user-chose-TEST"; +const WRONG_PASS = "not-the-one-TEST"; + +async function openStorageSheet(page: Page) { + await page.evaluate(() => { + (document.getElementById("visor-settings") as HTMLButtonElement | null)?.click(); + }); + await page.waitForFunction( + () => + (document.querySelector( + '#visor-drawer-inner .settings-extra-action[data-action="storage"]', + ) as HTMLButtonElement | null) !== null, + undefined, + { timeout: 15_000 }, + ); + await solo(page, "openStorageSheet"); +} + +const scenario: Scenario = { + name: "solo-recovery-file", + why: + "the file kit: the ceremony warns loudly and confirms the passphrase before minting, the bundle is delivered as a real download, a wrong passphrase refuses without wedging the ceremony, and the right one restores the account into a destroyed-and-recreated browser", + page: { + path: "/solo.html", + bootGlobal: "__solo", + storage: { + [SOLO_KEYS.hue]: "120", + }, + }, + + async run(page: Page, ctx: Ctx) { + let kitPath = ""; + + await act("an account, a todo, and a bucket behind it", async () => { + await createAccount(page); + for (const t of TODOS) await addTodo(page, t); + await openStorageSheet(page); + await page.waitForSelector("#storage-endpoint", { timeout: 15_000 }); + await page.fill("#storage-endpoint", ctx.minioUrl); + await page.fill("#storage-bucket", BUCKET); + await page.fill("#storage-access", ACCESS); + await page.fill("#storage-secret", SECRET); + await page.click("#storage-connect"); + await until([page], "storage:bound", async () => { + const t = (await solo(page, "bootTrace")) as string[]; + return t.includes("storage:bound") ? t : false; + }, 60_000); + await page.waitForSelector("#storage-sync", { state: "visible", timeout: 15_000 }); + await page.click("#storage-sync"); + await until([page], "storage:synced", async () => { + const t = (await solo(page, "bootTrace")) as string[]; + return t.includes("storage:synced") ? t : false; + }, 60_000); + }); + + await act("the file kind WARNS LOUDLY, in the record's own three sentences", async () => { + await page.click("#storage-kits"); + await page.waitForSelector("#recovery-make", { state: "visible", timeout: 15_000 }); + await page.click("#recovery-kind-file"); + await page.waitForSelector("#recovery-file-warning", { state: "visible", timeout: 15_000 }); + const warning = (await page.textContent("#recovery-file-warning") ?? "").toLowerCase(); + // 1. the passphrase's strength is the USER'S own, and the visor + // says so rather than measuring it. + assertIncludes(warning, "your choice", "the warning puts the strength on the user"); + assert( + warning.includes("does not judge") || warning.includes("does not measure"), + `the warning must decline to measure the passphrase: ${warning}`, + ); + // 2. the file plus its passphrase open the WHOLE account. + assertIncludes(warning, "whole account", "the warning states the blast radius"); + // 3. the file is dead the day it is used or its device revoked. + assertIncludes(warning, "revoke", "the warning states how the file dies"); + }); + + await act("the confirm field is real: two different passphrases mint nothing", async () => { + await page.fill("#recovery-label", "the file one"); + await page.fill("#recovery-file-pass", KIT_PASS); + await page.fill("#recovery-file-pass2", WRONG_PASS); + await page.click("#recovery-make"); + const problem = await until([page], "the mismatch refusal", async () => { + const el = await page.$("#recovery-problem"); + if (el === null || !(await el.isVisible())) return false; + const t = (await el.textContent()) ?? ""; + return t.trim() === "" ? false : t; + }, 15_000); + assertIncludes(problem, "did not match", "the refusal names the mismatch"); + // NOTHING WAS MINTED: the account still has no kit. + await page.waitForSelector("#recovery-none", { timeout: 15_000 }); + }); + + await act("mint the file kit, and catch the real download", async () => { + // The fields were cleared by the refused attempt — the same + // one-moment-of-cleartext discipline every secret field on this + // page keeps — so both are typed again, as a user would. + await page.fill("#recovery-file-pass", KIT_PASS); + await page.fill("#recovery-file-pass2", KIT_PASS); + const waitForDownload = page.waitForEvent("download", { timeout: 120_000 }); + await page.click("#recovery-make"); + const download = await waitForDownload; + // A VISOR-VOICED FILENAME: the user's own label, the date, and an + // extension that says what it is. + const name = download.suggestedFilename(); + assertIncludes(name, "the-file-one", "the download wears the label the user typed"); + assertIncludes(name, ".polyvisor-kit", "the download says what kind of thing it is"); + kitPath = await Deno.makeTempFile({ suffix: ".polyvisor-kit" }); + await download.saveAs(kitPath); + const size = (await Deno.stat(kitPath)).size; + assert(size > 0, "the downloaded kit is empty"); + + // A KIT IS A DEVICE, AND IT IS IN THE LIST — RECOVERY.md's core + // ruling, asserted for the file kind on the repaint the mint + // ceremony does for itself. No re-entry, no polling, no grace + // period: the account's registry answers immediately, so the sheet + // that just minted a kit must be showing it by the time the + // ceremony reports success. + // + // THIS ASSERTION EXISTS BECAUSE IT ONCE FAILED, and the reason is + // worth carrying: the repaint ran INSIDE the mint's own job on the + // page's serialized chain and re-entered `enqueue` to do its read, + // so the read queued behind the job that was awaiting it and never + // ran at all. The list stayed empty, every later call on the chain + // queued behind a promise that would never settle, and the whole + // thing presented as "the engine does not register file kits" — + // which was false. See host/solo.ts's note on `enqueue`. + await page.waitForSelector(".recovery-row", { timeout: 30_000 }); + const listed = await page.$$eval(".recovery-row", (els) => els.map((e) => e.textContent ?? "")); + assertEquals(listed.length, 1, `one kit should be listed, got ${JSON.stringify(listed)}`); + assertIncludes(listed[0], "file kit", "the listed kit names its kind"); + }); + + await act("a file kit is REVOCABLE from the sheet — the answer to a leaked one", async () => { + // RECOVERY.md's core ruling in full: a kit is "a real leaf in the + // account's delegation graph, visible in the devices sheet under + // the user's own label, REVOCABLE LIKE ANY DEVICE" — and + // revocation is the record's whole answer to a leaked phrase or + // file. An unrevocable kit would leave that answer with no + // interface, so this claim has to be executable. + // + // A SECOND, THROWAWAY KIT IS MINTED TO SPEND. The first one is + // this scenario's restore artifact and must survive to be used, so + // revoking it here would trade one claim for another. Two kits + // also make the assertion sharper: revoking must remove THE ONE + // CHOSEN and leave the other alone. + await page.fill("#recovery-file-pass", KIT_PASS); + await page.fill("#recovery-file-pass2", KIT_PASS); + await page.fill("#recovery-label", "the spare one"); + const spareDownload = page.waitForEvent("download", { timeout: 120_000 }); + await page.click("#recovery-make"); + await (await spareDownload).saveAs(await Deno.makeTempFile({ suffix: ".polyvisor-kit" })); + await page.waitForFunction( + () => document.querySelectorAll(".recovery-row").length === 2, + undefined, + { timeout: 60_000 }, + ); + + // THE LAST ROW IS THE SPARE: the account's registry sorts by + // creation time, so the kit minted a moment ago is the one at the + // end. Revoking by POSITION rather than by a captured id is also + // what a user does — they read the list and press the control on a + // row. + const revokeLast = () => + page.evaluate(() => { + const rows = document.querySelectorAll(".recovery-row"); + (rows[rows.length - 1].querySelector(".recovery-revoke") as HTMLButtonElement).click(); + }); + // TWO CLICKS, ARMED: the first states what the second will do, the + // same shape the reseal and forget-Google controls use. A driver + // that could revoke in one click would not be driving this + // ceremony. + await revokeLast(); + const armedText = await page.evaluate(() => { + const rows = document.querySelectorAll(".recovery-row"); + return rows[rows.length - 1].querySelector(".recovery-revoke")?.textContent ?? ""; + }); + assertIncludes(armedText, "Yes", "the first click arms rather than revokes"); + await revokeLast(); + + await page.waitForFunction( + () => document.querySelectorAll(".recovery-row").length === 1, + undefined, + { timeout: 120_000 }, + ); + // THE GUARANTEE NOTE, rendered as prose in its own node — the + // store-revoke discipline: what revocation does and does not + // promise is the one thing the user needs to read here. + const guarantee = (await page.textContent("#recovery-guarantee") ?? "").trim(); + assert(guarantee !== "", "revoking must render the guarantee note it hands back"); + // AND THE SURVIVOR IS THE RESTORE ARTIFACT, untouched. + const left = await page.$$eval(".recovery-row", (els) => els.map((e) => e.textContent ?? "")); + assertIncludes(left[0], "file kit", "the kit that was not chosen is still there"); + }); + + await act("DESTROY the browser", async () => { + await page.context().close(); + }); + + let fresh!: Page; + await act("a wrong passphrase refuses — and the ceremony survives it", async () => { + fresh = await ctx.fresh({ path: "/solo.html", bootGlobal: "__solo" }); + await fresh.waitForSelector("#solo-restore-account", { timeout: WAITS.boot }); + await fresh.click("#solo-restore-account"); + await fresh.waitForSelector("#restore-sheet", { state: "visible", timeout: WAITS.boot }); + await fresh.click("#restore-kind-file"); + await fresh.click("#restore-dest-s3"); + await fresh.fill("#restore-endpoint", ctx.minioUrl); + await fresh.fill("#restore-bucket", BUCKET); + await fresh.fill("#restore-access", ACCESS); + await fresh.fill("#restore-secret", SECRET); + await fresh.setInputFiles("#restore-file", kitPath); + await fresh.fill("#restore-file-pass", WRONG_PASS); + await fresh.fill("#restore-device-name", "the replacement laptop"); + await fresh.click("#restore-go"); + + const text = await until([fresh], "the refusal", async () => { + const el = await fresh.$("#restore-problem"); + if (el === null || !(await el.isVisible())) return false; + const t = (await el.textContent()) ?? ""; + return t.trim() === "" ? false : t; + }, WAITS.boot); + assertIncludes(text, "passphrase", "the refusal names which secret was wrong"); + // THE FILE ITSELF IS FINE, and the sentence says so: the user must + // not conclude their one artifact is ruined. + assertIncludes(text, "file itself is fine", "the refusal exonerates the file"); + assert(await fresh.isVisible("#restore-go"), "the ceremony must stay usable"); + }); + + await act("the right passphrase restores the account", async () => { + // The file input survives the refusal (it is not a secret); the + // passphrase field was cleared, so it is typed again. + await fresh.setInputFiles("#restore-file", kitPath); + await fresh.fill("#restore-file-pass", KIT_PASS); + await fresh.click("#restore-go"); + await until([fresh], "the restore to land", async () => { + return (await fresh.$("#restore-sheet")) === null; + }, WAITS.boot); + await waitForBoot(fresh, "__solo"); + const titles = await until([fresh], "the restored todos", async () => { + const t = (await solo(fresh, "todos").catch(() => [])) as string[]; + return t.length >= TODOS.length ? t : false; + }, WAITS.converge); + for (const want of TODOS) { + assertIncludes(titles.join(" | "), want, "a todo that was in the bucket"); + } + }); + + await Deno.remove(kitPath).catch(() => {}); + }, +}; + +export default scenario; diff --git a/demo/e2e/scenarios/solo-recovery.ts b/demo/e2e/scenarios/solo-recovery.ts new file mode 100644 index 0000000..2446aa3 --- /dev/null +++ b/demo/e2e/scenarios/solo-recovery.ts @@ -0,0 +1,324 @@ +// THE ACCOUNT OUTLIVES ITS LAST DEVICE — runtime/RECOVERY.md's whole +// claim, made executable against a real browser and a real MinIO. +// +// This is the round's money shot, and what makes it one is the DESTROYED +// CONTEXT in the middle. A fresh Playwright `BrowserContext` is a wiped +// browser: no IndexedDB, no localStorage, no sessionStorage, no OPFS, no +// SharedWorker, no device index, no escrowed credential. Nothing of the +// first half survives into the second except two things the user +// carried: the recovery phrase they wrote down, and the storage +// credentials they know. That is exactly the disaster the feature is +// for, and it is the reason this scenario cannot be written as a reload. +// +// FIVE CLAIMS, in the order the record makes them: +// +// 1. A KIT IS MINTED THROUGH THE REAL SHEET (RECOVERY.md, "The kit +// ceremony"): the storage sheet's own control, the phrase kind, and +// a phrase displayed ONCE in visor pixels behind an explicit +// confirm-dismiss — no timer, because a user copying ten words must +// not be racing one. +// 2. THE ACCOUNT COMES BACK ON A VIRGIN BROWSER, with no live peer +// anywhere: the todos are present afterwards, which is the only +// assertion that distinguishes "an account was restored" from "a +// device was made". +// 3. THE VISOR CLAIMS AT THE END (RECOVERY.md, "Restore"; the +// anti-spoofing sentence): nothing personal is on screen while the +// ceremony is collecting, and the colour and the name arrive +// together from the PULLED profile. Both halves are asserted — +// the absence before, the presence after — because only the pair +// is the property. +// 4. THE KIT IS CONSUMED, AND SAID SO (RECOVERY.md, "Single-use"): +// "your recovery kit was used — create a new one" reaches the +// strip, and the account's kit list is empty afterwards. The +// announcement is the honest half of the bargain: a window with no +// kit, loudly. +// 5. THE SAME PHRASE REFUSES A SECOND TIME, in a THIRD virgin +// context, with the sheet's own plain sentence rather than a raw +// seam error — and the ceremony stays usable, which is the "no +// wedged ceremony" rule. +// +// Claim 5 is what makes claim 4 more than a message: double-restore is +// an identity fork (two live instances of one identity clobbering each +// other's keyed oplog names), and consumption is what makes the fork +// structurally impossible rather than merely discouraged. + +import type { Ctx, Scenario } from "../run.ts"; +import { act, assert, assertEquals, assertIncludes, SOLO_KEYS, waitForBoot } from "../util.ts"; +import { addTodo, createAccount, setAccountName, solo, stripPersonal, until, WAITS } from "../solo-util.ts"; +import type { Page } from "npm:playwright@1.57.0"; + +const BUCKET = "pm-recovery"; +// The harness's own MinIO root credentials — synthetic by construction +// (run.ts's `Minio` class), never anything a person would type. +const ACCESS = "minioadmin"; +const SECRET = "minioadmin"; +const TODOS = ["buy the milk", "call the bank"]; + +/** Open settings → storage, the way a user reaches that sheet. */ +async function openStorageSheet(page: Page) { + await page.evaluate(() => { + (document.getElementById("visor-settings") as HTMLButtonElement | null)?.click(); + }); + await page.waitForFunction( + () => + (document.querySelector( + '#visor-drawer-inner .settings-extra-action[data-action="storage"]', + ) as HTMLButtonElement | null) !== null, + undefined, + { timeout: 15_000 }, + ); + await solo(page, "openStorageSheet"); +} + +/** Drive the restore ceremony's own fields and press its own button. + * + * A DRIVER, NOT A CLAIM (solo-util.ts's rule for this shape): it asserts + * nothing, so a scenario's assertions all live in the scenario. */ +async function fillRestore( + page: Page, + opts: { endpoint: string; phrase: string; deviceName: string }, +) { + await page.waitForSelector("#restore-sheet", { state: "visible", timeout: WAITS.boot }); + // The phrase kind is the default, but the driver clicks it anyway: a + // default is a thing that can change, and a scenario that silently + // rode one would stop testing what it says it tests. + await page.click("#restore-kind-phrase"); + await page.click("#restore-dest-s3"); + await page.fill("#restore-endpoint", opts.endpoint); + await page.fill("#restore-bucket", BUCKET); + await page.fill("#restore-access", ACCESS); + await page.fill("#restore-secret", SECRET); + await page.fill("#restore-phrase", opts.phrase); + await page.fill("#restore-device-name", opts.deviceName); + await page.click("#restore-go"); +} + +const scenario: Scenario = { + name: "solo-recovery", + why: + "the account outlives its last device: a phrase kit minted through the real sheet restores the whole account — todos included — into a DESTROYED-and-recreated browser context, the visor claims from the pulled profile, and the spent phrase refuses a second time", + page: { + path: "/solo.html", + bootGlobal: "__solo", + storage: { + [SOLO_KEYS.hue]: "265", + }, + }, + + async run(page: Page, ctx: Ctx) { + let phrase = ""; + + await act("an account with work in it, and a bucket to keep it in", async () => { + await createAccount(page); + await setAccountName(page, "Ada"); + for (const t of TODOS) await addTodo(page, t); + + await openStorageSheet(page); + await page.waitForSelector("#storage-endpoint", { timeout: 15_000 }); + await page.fill("#storage-endpoint", ctx.minioUrl); + await page.fill("#storage-bucket", BUCKET); + await page.fill("#storage-access", ACCESS); + await page.fill("#storage-secret", SECRET); + await page.click("#storage-connect"); + await until([page], "storage:bound", async () => { + const t = (await solo(page, "bootTrace")) as string[]; + return t.includes("storage:bound") ? t : false; + }, 60_000); + }); + + await act("push the work to the bucket by hand, so the kit has something to find", async () => { + // THE KIT IS A KEY TO THE BUCKET, NOT A COPY OF THE ACCOUNT + // (RECOVERY.md, "The claim"), so a restore can only ever be as + // fresh as what actually reached storage. Pressing Sync now is + // what a user does; waiting for the scheduler would be waiting on + // a cadence this scenario is not testing. + await page.waitForSelector("#storage-sync", { state: "visible", timeout: 15_000 }); + await page.click("#storage-sync"); + await until([page], "storage:synced", async () => { + const t = (await solo(page, "bootTrace")) as string[]; + return t.includes("storage:synced") ? t : false; + }, 60_000); + }); + + await act("mint a PHRASE kit through the storage sheet, and read it once", async () => { + await page.click("#storage-kits"); + await page.waitForSelector("#recovery-make", { state: "visible", timeout: 15_000 }); + // THE ACCOUNT STARTS WITH NONE, which is the baseline claim 4 + // needs at the other end. + await page.waitForSelector("#recovery-none", { timeout: 15_000 }); + await page.click("#recovery-kind-bucket"); + await page.fill("#recovery-label", "the paper one"); + await page.click("#recovery-make"); + + await page.waitForSelector("#recovery-phrase", { state: "visible", timeout: 90_000 }); + phrase = (await page.textContent("#recovery-phrase") ?? "").trim(); + // The record pins the format: ten words from the EFF short list. + // Asserting the SHAPE (not the words) is what catches a derivation + // that silently changed under the ceremony. + const words = phrase.split(/\s+/).filter((w) => w !== ""); + assertEquals(words.length, 10, `the phrase should be ten words, got: ${words.length}`); + + // NO TIMER: the phrase is still on screen, and the ceremony is + // waiting for the user's word. Dismissing is what publishes the + // kit to the list. + await page.click("#recovery-phrase-done"); + await page.waitForSelector(".recovery-row", { timeout: 30_000 }); + const rows = await page.$$eval(".recovery-row", (els) => els.map((e) => e.textContent ?? "")); + assertEquals(rows.length, 1, `one kit should be listed, got ${JSON.stringify(rows)}`); + assertIncludes(rows[0], "phrase kit", "the listed kit names its kind"); + + // THE PHRASE IS NEVER RENDERED AGAIN. There is no call that + // returns it, so the list must not contain it either — a leak here + // would turn a write-it-down secret into a read-it-later one. + const sheetText = await page.textContent("#storage-sheet") ?? ""; + assert( + !sheetText.includes(phrase), + "the kit list rendered the phrase again after it was dismissed", + ); + }); + + await act("DESTROY the browser: a context close is a wiped browser", async () => { + // Everything goes with it — the device index, the sealed device, + // the escrowed signing key, the worker. What survives is what the + // user carried out of the room in their hand. + await page.context().close(); + }); + + let fresh!: Page; + await act("a virgin browser lands on the first-run fork, with nothing personal", async () => { + fresh = await ctx.fresh({ path: "/solo.html", bootGlobal: "__solo" }); + // THE RECOVERY DOOR IS ON THE FORK because a browser with no + // devices never sees the picker — and a browser with no devices is + // exactly the browser a real recovery happens on. + await fresh.waitForSelector("#solo-restore-account", { timeout: WAITS.boot }); + // THE ANTI-SPOOFING HALF THAT COMES FIRST: no name of the user's + // anywhere on this screen, because the account is not in hand yet. + const body = await fresh.evaluate(() => document.body.textContent ?? ""); + assert(!body.includes("Ada"), "a pre-restore screen must render nothing personal"); + }); + + await act("restore: destination, credentials, the phrase, and a name for this machine", async () => { + await fresh.click("#solo-restore-account"); + await fillRestore(fresh, { + endpoint: ctx.minioUrl, + phrase, + deviceName: "the replacement laptop", + }); + // The fork's door reaches the record's claim-at-the-end ordering + // through a reload (solo.ts says why), so the wait is a BOOT wait, + // not a selector wait. + await until([fresh], "the restore to land", async () => { + const problem = await fresh.$("#restore-problem"); + if (problem !== null && (await problem.isVisible())) { + throw new Error(`the restore refused: ${await problem.textContent()}`); + } + return (await fresh.$("#restore-sheet")) === null; + }, WAITS.boot); + await waitForBoot(fresh, "__solo"); + }); + + await act("the account is here: the todos came back through the bucket", async () => { + const titles = await until([fresh], "the restored todos", async () => { + const t = (await solo(fresh, "todos").catch(() => [])) as string[]; + return t.length >= TODOS.length ? t : false; + }, WAITS.converge); + for (const want of TODOS) { + assertIncludes(titles.join(" | "), want, "a todo that was in the bucket"); + } + }); + + await act("the visor CLAIMED: colour and name arrived together, from the pulled profile", async () => { + const personal = await until([fresh], "the claim", async () => { + const p = await stripPersonal(fresh); + return p.anchorColour !== "" && p.identityText.includes("Ada") ? p : false; + }, WAITS.converge); + assert(personal.anchorColour !== "", "the anchor colour is painted after the claim"); + assertIncludes(personal.identityText, "Ada", "the name came from the account, not the page"); + }); + + await act("the kit was CONSUMED, and the visor said so", async () => { + // `restore:announced` is the marker on THIS page. The ceremony's + // own `restored` marker belongs to the page that ran it, and that + // page's trace died with the reload the fork's door goes through — + // which is the honest shape of the evidence here rather than a + // gap: what is being asserted is that the RESTORED boot said the + // sentence, not that some earlier page intended to. + await until([fresh], "restore:announced", async () => { + const t = (await solo(fresh, "bootTrace")) as string[]; + return t.includes("restore:announced") ? t : false; + }, WAITS.converge); + // THE SENTENCE ITSELF — the record's exact stance, read from the + // page's own account of what it said rather than off the strip. + // `visor.announce` REPLACES, and a restored boot has several other + // things to say in the seconds that follow (the resume path's + // reconcile among them), so a DOM read here would be asserting on + // a race rather than on the copy. + const announced = (await solo(fresh, "restoreAnnouncement")) as string; + assertIncludes(announced, "recovery kit was used", "the record's exact stance"); + assertIncludes(announced, "create a new one", "the announcement says what to do next"); + + // AND THE ACCOUNT AGREES WITH THE ANNOUNCEMENT. The sheet is + // where a user goes to ask "do I still have a kit", and after a + // restore the true answer is the one the visor just said out loud: + // no, make a new one. A list still showing the spent kit would be + // the sheet arguing with the visor about the single fact this + // moment turns on — so "no kit, LOUDLY" (RECOVERY.md's honest + // cost) has to be assertable on the restored device's own list, + // not merely on the sentence. + // + // THIS IS A REGRESSION TEST WITH A KNOWN PAST. It did not pass + // before the worker learned to checkpoint after a successful + // consume: internal driver mutations bypass the debounce hooks, so + // a restore's consume cleared the account record and flushed it + // while the CHECKPOINT still predated the clear — and this + // scenario's fork door goes through a reload, so the respawned + // worker resumed exactly that stale checkpoint, with the pull + // fan-out's self-filter keeping the flushed clear permanently out + // of its own author's reach. The devstore matrix owns that + // regression directly (row 64, with a negative control); this act + // is the same fact seen from where the user sees it. + await openStorageSheet(fresh); + await fresh.waitForSelector("#storage-kits", { state: "visible", timeout: WAITS.boot }); + await fresh.click("#storage-kits"); + await fresh.waitForSelector("#recovery-none", { timeout: 60_000 }); + const kitList = (await fresh.textContent("#recovery-kits") ?? "").trim(); + assertIncludes(kitList, "no recovery kit", "the restored device's list says the kit is gone"); + const rowsLeft = await fresh.$$eval(".recovery-row", (els) => els.length); + assertEquals(rowsLeft, 0, "the spent kit must not still be listed after a restore"); + }); + + await act("a spent phrase refuses, in plain words, in a third virgin browser", async () => { + const second = await ctx.fresh({ path: "/solo.html", bootGlobal: "__solo" }); + await second.waitForSelector("#solo-restore-account", { timeout: WAITS.boot }); + await second.click("#solo-restore-account"); + await fillRestore(second, { + endpoint: ctx.minioUrl, + phrase, + deviceName: "should not happen", + }); + const text = await until([second], "the refusal", async () => { + const el = await second.$("#restore-problem"); + if (el === null || !(await el.isVisible())) return false; + const t = (await el.textContent()) ?? ""; + return t.trim() === "" ? false : t; + }, WAITS.boot); + // A PLAIN SENTENCE, not a raw seam error: the refusal has to tell + // someone mid-disaster what actually happened. + assertIncludes(text, "recovery kit", "the refusal names what was not found"); + assertIncludes(text, "used up", "the refusal explains single-use"); + + // NO WEDGED CEREMONY: the sheet is still up, still usable, and the + // way back out is still there. + assert( + await second.isVisible("#restore-go"), + "the ceremony must stay usable after a refusal", + ); + assert( + await second.isVisible("#restore-cancel"), + "the way back out must survive a refusal", + ); + }); + }, +}; + +export default scenario; diff --git a/demo/host/solo.ts b/demo/host/solo.ts index 933a3ee..788ba98 100644 --- a/demo/host/solo.ts +++ b/demo/host/solo.ts @@ -93,7 +93,14 @@ import { import type { PairingDriver, UsMark, UsProfile } from "../../visor/ui/pairing-driver.ts"; import { createEnginePairingDriver } from "../../runtime/pairing-engine.ts"; import type { UiEvent } from "../../visor/surface/events.ts"; -import { type EngineArtifacts, hex, unhex, until, type UsStorage } from "../../runtime/engine.ts"; +import { + type EngineArtifacts, + hex, + type RecoveryKit, + unhex, + until, + type UsStorage, +} from "../../runtime/engine.ts"; import { adoptAnchor, clearAnchor } from "../../runtime/device-store/anchor.ts"; import { connectDevice, @@ -109,6 +116,7 @@ import { import type { DeviceStatus, GdriveSpace, + RecoveryKitInput, StoreBinding, } from "../../runtime/device-store/rpc.ts"; import { putSigningKey } from "../../runtime/keystore.ts"; @@ -276,6 +284,49 @@ interface AppExports { // note), so a tab that fires two overlapping driver calls still fires // them at one cooperative guest. The chain is therefore still this // page's job even though the engine moved. +// +// ──────────────────────────────────────────────────────────────────── +// NEVER CALL `enqueue` FROM INSIDE A JOB, and never `await` a helper +// that does. THIS IS A PAGE-WIDE FOOTGUN, not a local one. +// +// The chain is ONE promise. An `enqueue` issued while a job is running +// is appended AFTER that job, so it cannot start until the job finishes +// — and if the job is awaiting it, the job never finishes. That is a +// permanent self-deadlock, and the casualty is not just the caller: the +// chain is left holding a promise that will never settle, so EVERY +// later enqueue on the page (the announcement drain, the pairing polls, +// every serialized driver call) queues behind it forever. The page goes +// quietly dead rather than throwing. +// +// It has been paid for twice now. demo.ts's own note records the first; +// the second was the recovery-kit sheet repainting its list from inside +// its mint job (RECOVERY.md's T-C), which presented as "the file kind +// never lists" and was misdiagnosed as an engine bug — the registry was +// correct all along and the read was simply never run. Nesting fails +// SILENTLY and looks exactly like the thing you are calling being +// broken, which is why it is worth this many lines. +// +// The rule at a call site: if you are already inside a job, call the +// connection or the driver DIRECTLY — the enclosing job is already the +// serialization. A helper that reads the engine should either take its +// reader as a parameter, so the question is answered where the answer +// is known (see the recovery sheet's `readKitsQueued`/`readKitsInJob` +// pair), or be fire-and-forget and never awaited (see +// `writeThroughAccountStorage`, which enqueues from inside a job +// harmlessly precisely because nothing waits for it). +// +// WHY THERE IS NO RE-ENTRANCY GUARD HERE, though one would make the +// whole class impossible: it cannot be made sound in a browser. A guard +// needs to know "is this call coming from within a running job", and a +// plain boolean set around the job's execution answers a DIFFERENT +// question — "is a job in flight" — which is also true for a timer or +// an event handler that fires while a job sits at an await. Those are +// not in the job; running them directly would put a second guest call +// in flight beside the first and break exactly the serialization this +// chain exists to provide. The honest tool is an async-context tracker +// (`AsyncLocalStorage`), which the browser does not have. So the +// invariant is kept at the call sites, and stated here. +// ──────────────────────────────────────────────────────────────────── let chain: Promise = Promise.resolve(); function enqueue(f: () => Promise): Promise { const next = chain.then(f, f); @@ -659,6 +710,40 @@ function picker( throw { needsPassphrase: false, message: err(e) }; } }, + // THE RECOVERY DOOR (entry.ts's `DevicePickerHost.restore`). The + // picker has already closed by the time this runs and will not + // reopen itself, so this arm owns the drawer and owns getting the + // user back to a usable entry surface. + // + // THE SUCCESS PATH IS THE ORDINARY ONE, and that is the point: it + // resolves the very promise a picked device resolves, so boot + // continues into `startApp`, which claims the visor. Colour, name + // and icon therefore arrive together, from the profile the restore + // just pulled, through the SAME machinery unseal-as-login uses — + // no second claim path, and nothing personal painted before the + // account state was genuinely in hand (RECOVERY.md, "Restore"). + restore: () => { + say("ready — restore from your recovery kit"); + note("picker:restore"); + return new Promise((settle) => { + mountRestore(visor, { + onRestored: (conn, consumePending) => { + restoredKitNote = consumedKitSentence(consumePending); + note("restored"); + settle(); + resolve(conn); + }, + onAbandoned: () => { + // BACK TO THE PICKER, rebuilt: abandoning a ceremony must + // land somewhere a user can act, and the picker is where + // they were. + say("ready — choose a device"); + settle(); + mountDevicePicker(visor, rows, host); + }, + }); + }); + }, }; // ONE KEPT DEVICE AND A POLICY THAT PERMITS IT: straight through @@ -711,6 +796,657 @@ function picker( }); } +// --- account recovery: the restore ceremony (runtime/RECOVERY.md) ---------- +// +// THE CLAIM THIS EXISTS TO MAKE: losing every device does not lose the +// account. A recovery kit — a generated phrase, or a downloaded file +// plus its passphrase — together with access to the account's storage +// restores the account on a fresh browser with no live peer anywhere. +// +// WHERE IT RUNS, AND WHY THAT IS PRE-CLAIM. This ceremony lives at +// MODULE scope, beside `picker()`, and not inside `startApp` like the +// storage sheet it borrows its fields from. That is forced by the +// record's own ordering rule: "the visor claims at the end — colour, +// name and icon arrive from the pulled profile, and nothing personal +// renders before the account state is genuinely in hand" +// (RECOVERY.md, "Restore"). A ceremony hosted inside `startApp` would be +// a ceremony running AFTER `visor.claim()` had already painted a colour, +// which is precisely the ordering the anti-spoofing property forbids. +// +// So the restore runs on the UNCLAIMED grey dress, exactly as the picker +// does, and hands back a `DeviceConnection` for the ordinary boot to +// claim on. The two doors into it (the picker's, and the first-run +// fork's) differ only in what they do with that connection — see each +// call site. +// +// THE SECRET DISCIPLINE is the storage sheet's, unchanged: the phrase, +// the file passphrase and the S3 secret key are each read straight off +// their input and the field is cleared IN THE SAME TICK, before the +// value is used. A local carries it into the ceremony, so a refusal at +// any stage leaves no typed secret sitting in the DOM. + +/** A labelled field, the shape the credential sheets use. A module-scope + * twin of `startApp`'s own `field` — the restore ceremony cannot reach + * that one (it is a closure over a live device) and duplicating eleven + * lines beats hoisting a helper out of a sheet that is not this track's. */ +function credField(labelText: string, hintText?: string): HTMLElement { + const wrap = document.createElement("div"); + wrap.className = "cred-field"; + const label = document.createElement("label"); + label.textContent = labelText; + wrap.append(label); + if (hintText !== undefined) { + const hint = document.createElement("div"); + hint.className = "hint"; + hint.textContent = hintText; + wrap.append(hint); + } + return wrap; +} + +/** + * WHAT A COMPLETED RESTORE OWES THE NEXT SCREEN, parked here between the + * ceremony and the claim. + * + * The consumed-kit announcement is the record's own sentence and it is + * NOT OPTIONAL (RECOVERY.md, "Single-use"): "the visor announces 'your + * recovery kit was used — create a new one'". But the visor has no + * announcement surface worth using until it has CLAIMED, and the claim + * is deliberately the last thing that happens. So the ceremony leaves + * the sentence here and `startApp` says it immediately after claiming. + * + * A MODULE-LEVEL LATCH RATHER THAN A RETURN VALUE because the fork's + * door reaches the claim through a RELOAD (see its call site), and a + * value cannot cross that; `sessionStorage` carries it instead, and this + * variable carries it on the path that does not reload. Both are drained + * exactly once. + */ +const RESTORE_NOTE_KEY = "pm-solo-restored"; +let restoredKitNote: string | null = null; + +/** The consumed-kit sentence this page actually announced, or "". See + * the note at the announcement site: announcements REPLACE, so the strip + * is not a surface a claim about this sentence can rest on. */ +let restoreAnnouncedText = ""; + +/** The sentence, worded off the one fact the worker will tell us: has + * the kit actually been retired yet? A consume failure never blocks a + * restore (RECOVERY.md), so "used — make a new one" is true either way; + * what changes is whether the cleanup is still in flight, and saying so + * is honest without being alarming. */ +function consumedKitSentence(consumePending: boolean): string { + return consumePending + ? "your recovery kit was used — create a new one. Retiring the old one is still " + + "being retried against your storage; it cannot be used again either way." + : "your recovery kit was used — create a new one"; +} + +/** Map a restore refusal onto a plain sentence. + * + * THE TYPED CLASSES ARE THE WORKER'S AND THE GUEST'S (client.ts's + * `restore`): `bad-destination` and `no-credential` are host codes; + * a wrong or already-spent phrase and a wrong file passphrase arrive as + * the guest's own branded messages. Each becomes a sentence that says + * what to DO, because a bare refusal at the end of a disaster is the + * least useful thing this sheet could render. + * + * ANYTHING UNRECOGNISED IS REPORTED AS IT CAME. Guessing at a cause + * would be worse than quoting the seam. */ +function restoreRefusal(e: unknown): string { + const code = (e as { code?: string }).code; + const raw = err(e); + if (code === "no-credential") { + return "this browser has no credential for that destination yet — enter the secret key " + + `for it, or grant access again (${raw})`; + } + if (code === "bad-destination") { + return `that destination could not be used: ${raw}`; + } + // The guest's slot failures. Matched on the engine's own words rather + // than a code because they arrive as branded component exceptions, not + // as host conditions — see client.ts's `restore` refusal note. + if (/no recovery kit at this name|not found|404/i.test(raw)) { + return "no recovery kit answers that phrase. Check the words, and remember that a kit " + + "is used up the first time it works — a phrase that restored once will never " + + "restore again."; + } + if (/unlock failed|decrypt/i.test(raw)) { + return "that passphrase did not open this file. Check it and try again — the file " + + "itself is fine."; + } + return raw; +} + +/** What the ceremony needs from whichever door opened it. */ +interface RestoreCeremonyHost { + /** The restore succeeded and `conn` is a live, restored device. */ + onRestored(conn: DeviceConnection, consumePending: boolean): void; + /** The user backed out. The door owns putting them somewhere usable — + * the ceremony has by then given the drawer up. */ + onAbandoned(): void; +} + +/** + * Mount the restore ceremony as a drawer sheet, opened immediately. + * + * ONE SHEET, COLLECTING IN THE RECORD'S OWN ORDER: kind, then + * destination + credentials, then the kit secret, then the name of the + * machine this is becoming. They are collected on one surface rather + * than as a wizard because every one of them is needed before ANYTHING + * can be attempted — a staged ceremony would only be able to validate at + * the end anyway, and would have spent four screens getting there. + */ +function mountRestore(visor: Visor, host: RestoreCeremonyHost): void { + const tenant = visor.drawer.tenant<{ root: HTMLElement }>({ + name: "restore", + // EXCLUSIVE: this is a way IN, the same weight class as the picker, + // and nothing may displace a half-entered recovery phrase. + exclusive: true, + // ARMED: FALSE — the picker's ruling, for the picker's reason + // (entry.ts): pre-unseal there is no component frame on the page at + // all, so the arming tax would defend nothing. The geometry is doing + // the work: visor pixels, attached to the pinned strip, over a + // dimmed page. + armed: false, + dim: true, + context: () => ({ kind: "device-picker" }), + }); + + // THE PERSISTENT ROOT, for `mountDevicePicker`'s reason: the sheet + // re-measures on every visibility change, and a builder that rebuilt + // the tree would wipe a half-typed phrase the moment a refusal + // appeared under it. + const root = document.createElement("div"); + root.id = "restore-sheet"; + root.className = "cred-sheet"; + + const heading = document.createElement("h2"); + heading.textContent = "Restore from a recovery kit"; + + const lead = document.createElement("p"); + lead.className = "cred-note"; + // THE HONEST FLOOR, said before anything is typed (RECOVERY.md, "The + // claim"): the kit is the bucket's key, not a second bucket. A user + // who no longer has the storage is not going to be rescued by this + // sheet, and finding that out after typing a ten-word phrase would be + // the ceremony wasting their hope. + lead.textContent = + "Your account lives in your storage; the kit is the key to it. So this needs both — " + + "where the account syncs, and the kit you kept."; + + // --- the kit kind --------------------------------------------------------- + let kitKind: "bucket" | "file" = "bucket"; + const kindField = credField("Which kind of kit do you have?"); + const kindChoices: { value: "bucket" | "file"; id: string; label: string }[] = [ + { value: "bucket", id: "restore-kind-phrase", label: "A recovery phrase (about ten words)" }, + { value: "file", id: "restore-kind-file", label: "A recovery file, and its passphrase" }, + ]; + for (const k of kindChoices) { + const line = document.createElement("div"); + line.className = "cred-field"; + const label = document.createElement("label"); + const radio = document.createElement("input"); + radio.type = "radio"; + radio.name = "restore-kind"; + radio.id = k.id; + radio.value = k.value; + radio.checked = k.value === kitKind; + radio.onchange = () => { + kitKind = k.value; + phraseGroup.hidden = kitKind !== "bucket"; + fileGroup.hidden = kitKind !== "file"; + resize(); + }; + label.append(radio, document.createTextNode(` ${k.label}`)); + line.append(label); + kindField.append(line); + } + + // --- the destination ------------------------------------------------------ + // + // THE SAME TWO PROVIDERS AND THE SAME FIELDS as the storage ceremony + // (`renderUnbound`), because it IS the same question — and the same + // credential paths behind them: the S3 secret escrows page-side + // through `putSigningKey` keyed by destination origin, and Drive runs + // the worker-owned OAuth with the page owning the popup. What this + // sheet does NOT do is `bindStore`: `restore()` validates and binds + // the destination itself, with the fail-at-bind discipline, BEFORE it + // fetches anything (rpc.ts's `RestoreSpec.binding`). + // + // IDS ARE ITS OWN (`restore-*`, not `storage-*`) because both sheets + // can exist in one document's lifetime and duplicate ids are a bug + // waiting for whichever driver looks one up first. + let destKind: StoreBinding["kind"] = "s3"; + const destField = credField("Where does this account sync?"); + const destChoices: { value: StoreBinding["kind"]; id: string; label: string }[] = [ + { value: "s3", id: "restore-dest-s3", label: "S3-compatible object storage" }, + { value: "gdrive", id: "restore-dest-gdrive", label: "Google Drive" }, + ]; + for (const d of destChoices) { + const line = document.createElement("div"); + line.className = "cred-field"; + const label = document.createElement("label"); + const radio = document.createElement("input"); + radio.type = "radio"; + radio.name = "restore-dest"; + radio.id = d.id; + radio.value = d.value; + radio.checked = d.value === destKind; + radio.onchange = () => { + destKind = d.value; + s3Group.hidden = destKind !== "s3"; + gdGroup.hidden = destKind !== "gdrive"; + resize(); + }; + label.append(radio, document.createTextNode(` ${d.label}`)); + line.append(label); + destField.append(line); + } + + const s3Group = document.createElement("div"); + const mkInput = (id: string, masked = false): HTMLInputElement => { + const i = document.createElement("input"); + i.id = id; + i.type = masked ? MASKED.type : "text"; + i.autocomplete = "off"; + return i; + }; + const endpointInput = mkInput("restore-endpoint"); + const bucketInput = mkInput("restore-bucket"); + const accessInput = mkInput("restore-access"); + const secretInput = mkInput("restore-secret", true); + { + const f1 = credField("Endpoint"); + f1.append(endpointInput); + const f2 = credField("Bucket"); + f2.append(bucketInput); + const f3 = credField("Access key ID"); + f3.append(accessInput); + const f4 = credField( + "Secret key", + "Held here as a key this browser can use and never read back. Credentials never ride " + + "a recovery kit, so this is one thing the kit cannot bring for you.", + ); + f4.append(secretInput); + s3Group.append(f1, f2, f3, f4); + } + + const gdGroup = document.createElement("div"); + gdGroup.hidden = true; + let gdSpace: GdriveSpace = "appdata"; + const gdRootInput = mkInput("restore-gd-root"); + gdRootInput.value = params.get("gdroot") ?? "polyvisor"; + const gdClientInput = mkInput("restore-gd-client"); + gdClientInput.value = params.get("gdclient") ?? ""; + const gdSecretInput = mkInput("restore-gd-secret", true); + { + const spaceField = credField("Where in your Drive?"); + for ( + const sp of [ + { value: "appdata" as GdriveSpace, id: "restore-gd-space-appdata", label: "Hidden app data" }, + { value: "drive" as GdriveSpace, id: "restore-gd-space-drive", label: "A visible folder" }, + ] + ) { + const line = document.createElement("div"); + line.className = "cred-field"; + const label = document.createElement("label"); + const radio = document.createElement("input"); + radio.type = "radio"; + radio.name = "restore-gd-space"; + radio.id = sp.id; + radio.value = sp.value; + radio.checked = sp.value === gdSpace; + radio.onchange = () => { + gdSpace = sp.value; + }; + label.append(radio, document.createTextNode(` ${sp.label}`)); + line.append(label); + spaceField.append(line); + } + const f1 = credField("Drive folder"); + f1.append(gdRootInput); + const f2 = credField("OAuth client id"); + f2.append(gdClientInput); + const f3 = credField( + "OAuth client secret", + "This identifies the app to Google, not you — it is not your account's secret.", + ); + f3.append(gdSecretInput); + gdGroup.append(spaceField, f1, f2, f3); + } + + // --- the kit itself ------------------------------------------------------- + const phraseGroup = document.createElement("div"); + const phraseInput = document.createElement("textarea"); + phraseInput.id = "restore-phrase"; + phraseInput.rows = 3; + phraseInput.autocomplete = "off"; + { + const f = credField( + "Your recovery phrase", + // NORMALIZATION IS THE GUEST'S (RECOVERY.md, "Derivation, + // pinned": trim + lowercase + collapse internal whitespace), so + // the sheet can promise this rather than police it. + "The words in order. Capitals and extra spaces do not matter.", + ); + f.append(phraseInput); + phraseGroup.append(f); + } + + const fileGroup = document.createElement("div"); + fileGroup.hidden = true; + const fileInput = document.createElement("input"); + fileInput.type = "file"; + fileInput.id = "restore-file"; + const filePassInput = mkInput("restore-file-pass", true); + { + const f1 = credField("Your recovery file"); + f1.append(fileInput); + const f2 = credField("The passphrase for that file"); + f2.append(filePassInput); + // NO PRE-FILL FROM THE BUNDLE, and this is a recorded gap rather + // than an omission. RECOVERY.md's bundle payload carries the + // account's storage ADDRESSING snapshot precisely "so a file restore + // can pre-fill the destination fields after unlock" — but that + // snapshot does not surface page-side: `RecoveryKitResult`'s file arm + // is `{kind:"file", bundle}` and `RestoreSpec` takes a binding as an + // INPUT (rpc.ts:740-756), so the page must know the destination + // before the bundle is ever opened. Adding a worker surface to + // expose it is not this track's to add. + // + // CONTRACT: the file arm therefore asks for the destination exactly + // as the bucket arm does, where the record expects it to ask for + // credentials only. + fileGroup.append(f1, f2); + } + + // --- the name of the machine this becomes --------------------------------- + const nameInput = mkInput("restore-device-name"); + nameInput.value = "this device"; + const nameField = credField( + "What will you call this machine?", + // THE KIT'S LABEL GIVES WAY TO THIS (RECOVERY.md, "Restore"): the + // kit was a dormant device wearing whatever the minting ceremony + // called it; the restore ends with the user's own word for the + // machine it woke up as. + "It appears in your devices under this name, in place of the kit's own label.", + ); + nameField.append(nameInput); + + const problem = document.createElement("div"); + problem.id = "restore-problem"; + problem.className = "entry-problem"; + problem.hidden = true; + + const stepNote = document.createElement("div"); + stepNote.id = "restore-step"; + stepNote.className = "hint"; + + const go = document.createElement("button"); + go.type = "button"; + go.id = "restore-go"; + go.textContent = "Restore this account"; + + const cancel = document.createElement("button"); + cancel.type = "button"; + cancel.id = "restore-cancel"; + cancel.className = "entry-secondary"; + cancel.textContent = "not now"; + + root.append( + heading, + lead, + kindField, + destField, + s3Group, + gdGroup, + phraseGroup, + fileGroup, + nameField, + problem, + stepNote, + go, + cancel, + ); + + const resize = () => tenant.rebuild(); + let busy = false; + + const fail = (text: string) => { + busy = false; + go.disabled = false; + cancel.disabled = false; + stepNote.textContent = ""; + problem.textContent = text; + problem.hidden = false; + resize(); + }; + + cancel.onclick = () => { + // A CEREMONY IN FLIGHT IS NOT ABANDONABLE HERE: a half-run restore + // has a device namespace and possibly a bound store behind it, and + // "not now" cannot unwind those. The control simply refuses while + // busy, exactly as the picker's do. + if (busy) return; + tenant.close(); + host.onAbandoned(); + }; + + go.onclick = () => { + if (busy) return; + busy = true; + go.disabled = true; + cancel.disabled = true; + problem.hidden = true; + + const kind = kitKind; + const dest = destKind; + const deviceName = nameInput.value.trim() === "" ? "this device" : nameInput.value.trim(); + const endpoint = endpointInput.value.trim(); + const bucket = bucketInput.value.trim(); + const access = accessInput.value.trim(); + // THE ONE MOMENT OF CLEARTEXT, three times over — read once, cleared + // in the same tick, carried onward by a local (the storage sheet's + // discipline, and the record prices this exposure explicitly in its + // threat-model deltas). + const secret = secretInput.value; + secretInput.value = ""; + const phrase = phraseInput.value; + phraseInput.value = ""; + const filePass = filePassInput.value; + filePassInput.value = ""; + const gdRoot = gdRootInput.value.trim(); + const gdClient = gdClientInput.value.trim(); + const gdSecret = gdSecretInput.value; + gdSecretInput.value = ""; + const chosenFile = fileInput.files?.[0] ?? null; + const space = gdSpace; + + const at = (s: string) => { + stepNote.textContent = `${s}…`; + resize(); + }; + + void (async () => { + let conn: DeviceConnection | null = null; + try { + // THE KIT, ASSEMBLED FIRST — before a namespace exists, so a + // missing file or an empty phrase costs nothing to refuse. + let kit: RecoveryKitInput; + if (kind === "bucket") { + if (phrase.trim() === "") throw new Error("enter your recovery phrase"); + kit = { kind: "bucket", phrase }; + } else { + if (chosenFile === null) throw new Error("choose your recovery file"); + if (filePass === "") throw new Error("enter the passphrase for that file"); + kit = { + kind: "file", + bundle: new Uint8Array(await chosenFile.arrayBuffer()), + passphrase: filePass, + }; + } + + let binding: StoreBinding; + if (dest === "s3") { + at("checking the destination"); + const origin = normalizeOrigin(endpoint); + if (origin === null) { + throw new Error(`that endpoint is not a usable address: ${endpoint}`); + } + if (secret !== "") { + // ESCROW BEFORE THE NAMESPACE EXISTS: the keystore is + // PROFILE-tier and keyed by destination origin + // (STORAGE-EGRESS.md §2), so it belongs to the browser + // rather than to the device about to be made — which is + // exactly why the worker can read it back a moment later. + at("keeping the key for this browser"); + await putSigningKey(origin, access, secret); + } + binding = { kind: "s3", endpoint, bucket, accessKey: access }; + } else { + if (gdRoot === "") throw new Error("a Drive folder name is required"); + if (gdClient === "") throw new Error("an OAuth client id is required"); + binding = { + kind: "gdrive", + root: gdRoot, + apiBase: gdriveEndpoints.apiBase ?? "https://www.googleapis.com", + clientId: gdClient, + space, + }; + } + + // A BRAND-NEW NAMESPACE, always. `restore()` refuses on a + // namespace that already holds a device — "a restore is a NEW + // device, never an overwrite" (client.ts) — so `kind: "new"` is + // the only correct arm here: `"anchor"` would hand back whatever + // this tab was already looking at. + // + // NOT UNSEALED HERE. `restore` brings the engine up FROM THE KIT + // instead of from `init`, so an `unseal()` first would init the + // very engine the restore has to replace. + at("setting this device up"); + conn = await connectDevice({ + device: { + kind: "new", + petname: deviceName, + unsealPolicy: "while-open", + // SEED POSTURE, said honestly in the index row: the restored + // device's identity came out of a bundle, which is one notch + // below platform posture (RECOVERY.md, "Restore"). The + // migration for a restored device is parked, and a row + // claiming `platform` would be the index lying about it. + posture: "seed", + }, + workerUrl: WORKER_URL, + artifacts: ENGINE_ARTIFACTS, + label: "solo", + }); + + if (dest === "gdrive") { + // THE TWO-STAGE SHAPE (rpc.ts's `RestoreSpec` note): the Drive + // consent seals its tokens under the DEK, so a DEK must exist + // before the popup runs — and `restorePrepare` is exactly that + // and nothing more: it opens the namespace WITHOUT initing an + // engine. The S3 arm needs no such stage; its escrow is + // page-side and keyed by origin. + at("opening this device"); + await conn.restorePrepare({}); + at("asking Google for permission"); + const { authorizeUrl } = await conn.oauthStart({ + provider: "gdrive", + clientId: gdClient, + clientSecret: gdSecret || undefined, + space, + redirectUri: new URL("./oauth-callback.html", location.href).toString(), + authUrl: gdriveEndpoints.authUrl, + tokenUrl: gdriveEndpoints.tokenUrl, + }); + const expectedState = new URL(authorizeUrl).searchParams.get("state"); + const popup = window.open(authorizeUrl, "pm-gdrive-auth", "width=680,height=760"); + if (!popup) { + throw new Error("could not open the authorization window (popup blocked)"); + } + const relay = await new Promise<{ code: string; state: string }>((resolve, reject) => { + const done = (f: () => void) => { + globalThis.removeEventListener("message", onMessage); + clearInterval(closedTimer); + clearTimeout(deadline); + f(); + }; + const onMessage = (e: MessageEvent) => { + if (e.origin !== location.origin) return; + const d = e.data as + | { pmGdriveCode?: unknown; pmGdriveError?: unknown; state?: unknown } + | null; + if (!d) return; + if (expectedState !== null && d.state !== expectedState) return; + if (typeof d.pmGdriveError === "string") { + done(() => reject(new Error(`authorization was refused: ${d.pmGdriveError}`))); + return; + } + if (typeof d.pmGdriveCode !== "string") return; + done(() => + resolve({ + code: d.pmGdriveCode as string, + state: typeof d.state === "string" ? d.state : "", + }) + ); + }; + globalThis.addEventListener("message", onMessage); + const closedTimer = setInterval(() => { + if (popup.closed) done(() => reject(new Error("authorization window closed"))); + }, 500); + const deadline = setTimeout( + () => done(() => reject(new Error("authorization timed out"))), + AUTH_TIMEOUT_MS, + ); + }); + try { + popup.close(); + } catch { /* already gone */ } + await conn.oauthComplete(relay.code, relay.state); + } + + // THE RESTORE ITSELF. Everything after this line is the worker's + // (client.ts's `restore`): validate the binding, bring the engine + // up from the kit, pull the us-doc and then the account fan-out, + // checkpoint, and only then consume the kit. The page's only job + // is to say so while it happens. + at("finding your account in your storage"); + note("restore:started"); + const st = await conn.restore({ binding, kit, deviceName, unseal: {} }); + note("restore:done"); + at("your account is here"); + + // A CONSUME FAILURE NEVER FAILS A RESTORE, so it is a fact to + // REPORT rather than a branch to take. + const consumePending = st.sync?.consumePending ?? false; + tenant.close(); + host.onRestored(conn, consumePending); + } catch (e) { + // NOT A DEAD END. The sheet keeps everything the user typed + // except the secrets it cleared, says what happened in a plain + // sentence, and stays up — and the DOOR that opened it is still + // reachable through "not now", so a refused restore never wedges + // the way in. + note("restore:refused"); + if (conn !== null) { + // The half-born namespace goes. Leaving it would put a device + // in the index that holds nothing, under a name the user gave + // to a machine that never became anything. + try { + await conn.destroy(); + } catch { /* a namespace that will not go is not this refusal's story */ } + clearAnchor(); + } + fail(restoreRefusal(e)); + } + })(); + }; + + tenant.open({ root }, () => ({ root })); +} + // --- everything after the seal opens --------------------------------------- async function startApp( @@ -738,6 +1474,48 @@ async function startApp( const announce: AnnounceSink = visorAnnounceSink(visor); note("visor:painted"); + // THE CONSUMED-KIT ANNOUNCEMENT, said HERE and not in the ceremony + // (RECOVERY.md, "Single-use": "the visor announces 'your recovery kit + // was used — create a new one'"). + // + // AFTER THE CLAIM, DELIBERATELY. The ceremony that earned this + // sentence ran on the unclaimed grey dress, where the visor has no + // voice worth using yet; one line above, the visor became the user's. + // This is the first thing it says as theirs, which is also the right + // order for the user: they see their account arrive, then they are + // told what it cost. + // + // STICKY, because "you currently have no recovery kit" is a standing + // condition and not news that should scroll past. The honest cost the + // record names is a window with NO kit until the user mints a fresh + // one — "no kit, loudly" is the whole bargain, and an announcement + // that vanished after fifteen seconds would be the quiet version. + // + // TWO SOURCES, ONE DRAIN: the picker's door leaves the sentence in a + // module variable (no reload crosses that path), the fork's door + // leaves it in `sessionStorage` (a reload does). Drained exactly once + // either way. + { + let kitNote = restoredKitNote; + restoredKitNote = null; + if (kitNote === null) { + try { + kitNote = sessionStorage.getItem(RESTORE_NOTE_KEY); + if (kitNote !== null) sessionStorage.removeItem(RESTORE_NOTE_KEY); + } catch { /* a storage-less browser simply loses the sentence */ } + } + if (kitNote !== null && kitNote !== "") { + note("restore:announced"); + // KEPT FOR DRIVING, not for the UI: `visor.announce` REPLACES, so + // by the time anything else has been said the strip no longer + // holds this sentence — and a scenario asserting the record's + // exact stance would be asserting on a race. This is the same + // shape as `bootTrace`: the page's own account of what it said. + restoreAnnouncedText = kitNote; + announce(kitNote, true); + } + } + /** * THE DEVICE-NAME DISPLAY RULE (PERSISTENCE.md, "Unseal UX", ruled): * the strip shows this device's petname whenever this browser's index @@ -1513,6 +2291,13 @@ async function startApp( context: () => ({ kind: "settings" }), }); + /** Re-measure the storage sheet. Every view in it (bound, unbound, + * recovery kits) changes the sheet's height, and the drawer animates + * to a MEASURED pixel target and clips the overflow — so a view that + * grew without saying so is a view whose bottom controls cannot be + * reached. */ + const tenantRebuild = () => storageTenant.rebuild(); + /** The connect ceremony's own busy-guard, mirroring `setupInFlight` in * demo.ts's `setupBucket`: a duplicate click while one binding is in * flight would race the same escrow write (or the same OAuth @@ -1751,27 +2536,48 @@ async function startApp( const container = document.createElement("div"); container.className = "cred-sheet"; container.id = "storage-sheet"; + // THE PERSISTENT BODY — the same discipline the device picker's root + // keeps (visor/ui/entry.ts), and it became load-bearing when this + // sheet grew a view tall enough to need a re-measure. + // + // THE DRAWER'S `rebuild()` RE-RUNS THIS BUILDER. So a builder that + // created the body would hand every re-measure a FRESH EMPTY body + // and detach the one the current view is living in — the sheet would + // blank itself at exactly the moment it was asked to fit its + // contents. Creating the body once, out here, makes `rebuild()` mean + // what its name says: measure this again, change nothing. + // + // Measured the hard way, twice: without a re-measure the recovery + // control below the fold was unclickable (the drawer clips to a + // measured height, so a click on it lands on the dim); with a + // re-measure against a builder-created body, the whole sheet went + // blank instead. + const body = document.createElement("div"); const session = { container }; + /** The async fill runs ONCE per open, not once per re-measure: it is + * a read of the device and the account, and re-issuing it on every + * height change would race the view the user is currently in. */ + let filled = false; storageTenant.open(session, () => { const heading = document.createElement("h2"); heading.textContent = "Storage"; - const body = document.createElement("div"); - container.replaceChildren(heading, body); const close = document.createElement("button"); close.type = "button"; close.textContent = "Close"; close.onclick = () => { if (storageTenant.owns(session)) storageTenant.close(); }; - container.append(close); - // THE ACCOUNT'S RECORD IS READ ON EVERY OPEN AND EVERY RE-RENDER - // (DRIVE.md, "The account syncs its storage config; devices keep - // their credentials"): whether this device has a store of its own - // and whether its ACCOUNT has one are two different questions, and - // the interesting cell of that table — unbound device, bound - // account — is exactly the freshly-paired second device. A failed - // read is not a failed sheet: it degrades to the plain manual - // form, which is always correct, merely more typing. + container.replaceChildren(heading, body, close); + if (filled) return { root: container }; + filled = true; + // THE ACCOUNT'S RECORD IS READ ON EVERY OPEN (DRIVE.md, "The + // account syncs its storage config; devices keep their + // credentials"): whether this device has a store of its own and + // whether its ACCOUNT has one are two different questions, and the + // interesting cell of that table — unbound device, bound account — + // is exactly the freshly-paired second device. A failed read is + // not a failed sheet: it degrades to the plain manual form, which + // is always correct, merely more typing. void Promise.all([ conn.status(), enqueue(() => driver.usStorageGet()).catch(() => undefined), @@ -2454,6 +3260,24 @@ async function startApp( renderUnbound(body, storage); }; + // THE STORAGE-REBIND CAVEAT (RECOVERY.md's threat-model deltas, the + // recorded one): "storage rebind strands kits in the old bucket (K_p + // and bundle do not migrate). RECORDED CAVEAT: the storage + // ceremony's copy tells the user to re-mint kits after a destination + // change; no migration machinery." + // + // IT LANDS BESIDE "Change…", WHICH IS THE DESTINATION-CHANGE PATH, + // and it is stated BEFORE the change rather than announced after: + // the whole value of the sentence is that it is read by someone + // deciding, and a kit stranded in a bucket they have just stopped + // using is discovered at the disaster otherwise. + const rebindCaveat = document.createElement("div"); + rebindCaveat.className = "hint"; + rebindCaveat.id = "storage-rebind-caveat"; + rebindCaveat.textContent = + "Changing where this account syncs leaves any recovery kit behind in the old storage — " + + "kits do not move. Make a new one afterwards."; + // DISCONNECT VS. FORGET — THE SAME SPLIT AS STORAGE-EGRESS.md §6, // now with a second thing that can be forgotten: disconnecting the // DESTINATION is not forgetting the ACCOUNT, and (for Drive) forgetting @@ -2493,6 +3317,35 @@ async function startApp( body.append(sync, change, disconnect); + // THE WAY TO THE RECOVERY KITS, and this is where it belongs. + // + // PLACEMENT, JUSTIFIED (the track's one placement call): a kit + // REQUIRES a bound store — "both kinds still require a bound store + // at creation (a kit without a bucket restores nothing — content + // rehydrates from the bucket)" (RECOVERY.md). Hanging the control + // off the BOUND view makes that precondition structural instead of a + // refusal: the door only exists where walking it can work. The + // settings sheet was the alternative and it is the worse one — the + // control would be present on an unbound device, and the ceremony + // behind it would exist only to say no. + // + // IT SWAPS THIS SHEET'S BODY rather than opening a second drawer + // tenant. The storage tenant is EXCLUSIVE, so a second sheet could + // not open over it anyway; and view-swapping is already this sheet's + // grammar (bound ⇄ unbound ⇄ diverge). The kit ceremony is still in + // visor pixels, still attached to the pinned strip, still over a + // dimmed page — which is what the drawer rule is actually about. + const kits = document.createElement("button"); + kits.type = "button"; + kits.id = "storage-kits"; + kits.textContent = "Recovery kit…"; + kits.onclick = () => { + body.replaceChildren(); + syncLine = null; + renderKits(body, storage); + }; + body.append(kits, rebindCaveat); + if (storage.kind === "gdrive") { // FORGET THIS GOOGLE ACCOUNT: the mirror of disconnect, and a // control S3 has no analog for (there is no standing consent to @@ -2544,6 +3397,491 @@ async function startApp( } body.append(stepNote, problem); + // RE-MEASURE, because this view was just swapped in under a height + // the drawer measured for a DIFFERENT one (the connect form, or the + // kit list). The drawer animates to a measured pixel target and + // CLIPS the overflow, so a taller view under a stale height has + // controls that are on the page and not reachable — a click on one + // lands on the dim instead, silently. Measured the hard way: the + // recovery-kit control, appended below the existing three, was + // clicked by a driver and did nothing at all. + tenantRebuild(); + }; + + // --- recovery kits (runtime/RECOVERY.md, "The kit ceremony") ------------- + // + // A KIT IS A DEVICE, and that is the round's core ruling rather than + // an implementation detail: "the kit ceremony mints a dormant member + // device — a real leaf in the account's delegation graph, visible in + // the devices sheet under the user's own label, revocable like any + // device". So this view lists kits the way a devices list lists + // devices, and its revoke control is the devices sheet's revoke, + // because it IS the same mechanic — "a leaked phrase or file is + // answered by revoking the kit device … the same mechanic as a lost + // phone, because it IS the same thing". + // + // WHAT THIS VIEW NEVER DOES: render a phrase twice. The phrase exists + // in exactly one moment — the tick `createRecoveryKit` resolves — and + // there is no call that returns it again (client.ts says so). The + // display-once pane below is that moment; everything after it lists + // metadata and nothing else. + + /** THE CONFIRM-DISMISS PANE for a freshly minted phrase. + * + * NO TIMER, DELIBERATELY, and the reason is the whole design of this + * pane: a user copying ten words onto paper must not be racing a + * countdown. A phrase that vanished on a clock would produce exactly + * one outcome at scale — half-copied phrases, believed to be kits — + * and a half-copied kit is the "bad kit, quietly" failure the record + * spends its single-use ruling avoiding. The user says when they have + * it, and the announcement waits for that word too: announcing before + * the dismiss would put a sentence on the strip while the secret is + * still on screen being copied. + */ + const renderPhraseOnce = (body: HTMLElement, phrase: string, label: string) => { + const heading = document.createElement("p"); + heading.className = "cred-note"; + heading.textContent = "Write these words down, in this order, and keep them somewhere safe."; + + // VISOR PIXELS, and nothing else on the page ever sees this string: + // it came back over the port from the ceremony and is written into + // this node and into no other. It is not persisted, not logged, and + // there is no call that returns it a second time. + const words = document.createElement("p"); + words.id = "recovery-phrase"; + words.className = "recovery-phrase"; + words.textContent = phrase; + + const why = document.createElement("div"); + why.className = "hint"; + why.textContent = + "This is shown once and never again. With these words and access to your storage, " + + "this account can be brought back on a browser that has never seen it — which is " + + "also why anyone else who has them can do the same. It works once: restoring uses " + + "the kit up."; + + const done = document.createElement("button"); + done.type = "button"; + done.id = "recovery-phrase-done"; + done.textContent = "I have written it down"; + done.onclick = () => { + note("recovery:kit-shown"); + announce(`a recovery kit for this account is ready — you saved it as ${label}`); + body.replaceChildren(); + renderKits(body, boundStorage!); + }; + + body.append(heading, words, why, done); + }; + + /** The bound destination this kit view was entered from, kept so the + * display-once pane can hand it back to `renderKits` on dismiss. */ + let boundStorage: StoreBinding | null = null; + + const renderKits = (body: HTMLElement, storage: StoreBinding) => { + boundStorage = storage; + const heading = document.createElement("p"); + heading.className = "cred-note"; + // THE HONEST FLOOR, stated where the user is deciding whether to + // bother (RECOVERY.md, "The claim"): bucket + all devices lost = the + // account is gone. The kit is the storage's key, not a second copy + // of the account, and a user who thinks otherwise has been sold the + // wrong safety. + heading.textContent = + "A recovery kit brings this account back on a fresh browser when every device is " + + "gone. It is a key to your storage, not a copy of your account — if the storage " + + "goes too, nothing can bring it back."; + body.append(heading); + + const problem = document.createElement("div"); + problem.className = "hint"; + problem.id = "recovery-problem"; + problem.hidden = true; + + const stepNote = document.createElement("div"); + stepNote.className = "hint"; + stepNote.id = "recovery-step"; + + /** The guarantee note a revoke hands back, rendered as prose. + * + * PRIORITY OVER THE STATS TICK (the recorded UI finding for + * `storeRevoke`, whose note this is): the sentence describes what + * revocation does and does not guarantee, and a stats line + * repainting over it would replace the one thing the user needs to + * read with a number they do not. So it lands in its own node, and + * nothing in this view writes over it. + */ + const guarantee = document.createElement("p"); + guarantee.className = "cred-note"; + guarantee.id = "recovery-guarantee"; + guarantee.hidden = true; + + const list = document.createElement("div"); + list.id = "recovery-kits"; + + /** + * READ THE ACCOUNT'S KITS FROM OUTSIDE A CHAIN JOB — the ordinary + * case (a render, a user's click handler). + * + * The pair of readers below exists because `enqueue` MUST NOT BE + * NESTED (see its own note): the chain is one promise, so an + * `enqueue` issued from inside a running job queues BEHIND that job + * and can only run once it finishes — while the job is sitting there + * awaiting it. That is a permanent self-deadlock, and it takes the + * whole page's serialized chain with it, not just the caller. + * + * Making the two readers SEPARATE, NAMED THINGS is the fix rather + * than a comment on one reader: the question "am I already on the + * chain?" then has to be answered at every call site, in a word that + * is visible in the call itself. + */ + const readKitsQueued = () => enqueue(() => conn.recoveryKits()); + + /** THE SAME READ, FROM INSIDE A CHAIN JOB — already serialized by + * the job that encloses it, so it goes straight to the connection. + * Wrapping this one in `enqueue` is the deadlock described above. */ + const readKitsInJob = () => conn.recoveryKits(); + + /** Repaint the kit list from whichever reader the caller's position + * on the chain calls for (`readKitsQueued` / `readKitsInJob`). + * + * NO RETRY, AND NO GRACE PERIOD. An earlier revision had one, on the + * theory that a list read in the same breath as a create could beat + * the record it was reading — the file kind appeared to list nothing + * while the phrase kind listed correctly. That theory was WRONG and + * the asymmetry had nothing to do with timing: the phrase kind + * repaints from the display-once pane's dismiss click (off the + * chain), the file kind repainted from inside its own mint job (on + * it), and only the second one nested an `enqueue` and hung. The + * registry was correct all along and answers immediately. A grace + * period here would only be a place for the next such bug to hide. */ + const paintList = async (read: () => Promise) => { + let rows: RecoveryKit[]; + try { + rows = await read(); + } catch (e) { + list.replaceChildren(); + const oops = document.createElement("div"); + oops.className = "hint"; + oops.textContent = `could not read this account's kits: ${err(e)}`; + list.append(oops); + tenantRebuild(); + return; + } + list.replaceChildren(); + if (rows.length === 0) { + const none = document.createElement("div"); + none.className = "hint"; + none.id = "recovery-none"; + // AFTER A RESTORE THIS IS THE TRUE AND LOUD STATE (RECOVERY.md's + // "honest cost"): a window with no kit until the user mints a + // fresh one. + none.textContent = "This account has no recovery kit."; + list.append(none); + } + for (const kit of rows) { + const row = document.createElement("div"); + row.className = "device-row recovery-row"; + row.dataset.agent = hex(kit.agentId); + const what = document.createElement("span"); + // USER VOICE would be the kit's label — which this projection + // does not carry (`RecoveryKit` is {agentId, kind, created}), so + // the row says what it honestly knows: which kind, and when. + what.className = "recovery-what"; + what.textContent = kit.kind === "bucket" + ? "phrase kit, kept in your storage" + : "file kit, kept by you"; + const when = document.createElement("span"); + when.className = "device-when"; + when.textContent = `created ${new Date(Number(kit.created)).toLocaleString()}`; + const revoke = document.createElement("button"); + revoke.type = "button"; + revoke.className = "recovery-revoke"; + revoke.textContent = "Revoke"; + let armed = false; + revoke.onclick = () => { + // TWO CLICKS, as the reseal and forget-Google controls take + // them: revoking a kit is not undoable and the first click + // says what the second one will do. + if (!armed) { + armed = true; + revoke.textContent = "Yes — revoke this kit"; + tenantRebuild(); + return; + } + revoke.disabled = true; + problem.hidden = true; + void enqueue(async () => { + try { + const guaranteeNote = await conn.revokeRecoveryKit(kit.agentId); + note("recovery:kit-revoked"); + guarantee.textContent = guaranteeNote; + guarantee.hidden = false; + announce("that recovery kit is revoked — it cannot restore this account any more"); + // IN-JOB READER: this whole handler is one chain job (the + // revoke and the repaint belong together — a list that + // still showed the kit would be the sheet contradicting + // the guarantee note it just rendered), so the read must + // NOT re-enter `enqueue`. + await paintList(readKitsInJob); + } catch (e) { + revoke.disabled = false; + armed = false; + revoke.textContent = "Revoke"; + problem.textContent = err(e); + problem.hidden = false; + } + tenantRebuild(); + }); + }; + row.append(what, when, revoke); + list.append(row); + } + tenantRebuild(); + }; + + body.append(list); + // OFF-CHAIN READER: a render is not inside a job, so this one takes + // its turn on the chain like any other caller. + void paintList(readKitsQueued); + + // --- minting a new one -------------------------------------------------- + // + // BUCKET KITS ARE S3-ONLY AT THIS REV (RECOVERY.md, settled in T-A): + // the bucket kind needs an owner-tier PUT at a NAME the guest + // derives, and only S3 addresses objects by name. So on a + // Drive-bound account the phrase kind is not offered at all — and + // the sheet says why in one plain sentence rather than offering a + // control that would be refused by name a moment later. + const bucketKitsPossible = storage.kind === "s3"; + let mintKind: "bucket" | "file" = bucketKitsPossible ? "bucket" : "file"; + + const kindField = credField("What kind of kit?"); + if (bucketKitsPossible) { + for ( + const k of [ + { + value: "bucket" as const, + id: "recovery-kind-bucket", + label: "A phrase kit — ten words you write down; the kit itself lives in your bucket", + }, + { + value: "file" as const, + id: "recovery-kind-file", + label: "A file kit — a file you keep, opened by a passphrase you choose", + }, + ] + ) { + const line = document.createElement("div"); + line.className = "cred-field"; + const label = document.createElement("label"); + const radio = document.createElement("input"); + radio.type = "radio"; + radio.name = "recovery-kind"; + radio.id = k.id; + radio.value = k.value; + radio.checked = k.value === mintKind; + radio.onchange = () => { + mintKind = k.value; + fileFields.hidden = mintKind !== "file"; + tenantRebuild(); + }; + label.append(radio, document.createTextNode(` ${k.label}`)); + line.append(label); + kindField.append(line); + } + } else { + const only = document.createElement("div"); + only.className = "hint"; + only.id = "recovery-file-only"; + // ONE PLAIN SENTENCE, and it names the cause rather than the + // mechanism: the user does not need "objects addressed by name" to + // understand which kind they are getting. + only.textContent = + "This account syncs through Google Drive, where a kit cannot be filed under a name " + + "the phrase alone would find. So this is a file kit: you keep the file."; + kindField.append(only); + } + body.append(kindField); + + const labelInput = document.createElement("input"); + labelInput.type = "text"; + labelInput.id = "recovery-label"; + labelInput.autocomplete = "off"; + labelInput.value = "recovery kit"; + const labelField = credField( + "Call this kit", + "It is a device in your account, and this is the name it wears there.", + ); + labelField.append(labelInput); + body.append(labelField); + + // --- the file kind's passphrase, and the loud warning ------------------- + const fileFields = document.createElement("div"); + fileFields.hidden = mintKind !== "file"; + + // THE OWNER'S AMENDMENT, RENDERED (RECOVERY.md: "disallowing custody + // would be paternalism, so the ceremony WARNS LOUDLY instead"). All + // three of the record's sentences are here, and none of them is + // softened: + // + // 1. the passphrase's strength is the USER'S OWN — and the visor + // says so plainly instead of measuring it. A strength meter + // would be the visor pretending to a judgement it cannot make + // and, worse, would launder a weak choice into an approved one. + // 2. the file plus its passphrase open the WHOLE account. + // 3. the file is dead the day it is used or its device revoked. + const warn = document.createElement("div"); + warn.id = "recovery-file-warning"; + warn.className = "entry-problem recovery-warning"; + warn.textContent = + "Read this before you choose a passphrase. This file and its passphrase together open " + + "your whole account — everything in it, and the ability to write to it. How hard that " + + "passphrase is to guess is entirely your choice: this app does not judge it, does not " + + "measure it, and cannot protect you from a weak one. A file that is easy to open is an " + + "account that is easy to take. The file dies the day it is used, or the day you revoke " + + "it here — nothing else retires it."; + fileFields.append(warn); + + const kitPass = document.createElement("input"); + kitPass.type = MASKED.type; + kitPass.id = "recovery-file-pass"; + kitPass.autocomplete = "off"; + const kitPassField = credField("A passphrase for this file"); + kitPassField.append(kitPass); + const kitPass2 = document.createElement("input"); + kitPass2.type = MASKED.type; + kitPass2.id = "recovery-file-pass2"; + kitPass2.autocomplete = "off"; + // THE CONFIRM FIELD, and it is not ceremony here: a mistyped + // passphrase on a file kit is undiscoverable until the disaster, + // because nothing ever asks for it again until then. + const kitPass2Field = credField("And again, to be sure"); + kitPass2Field.append(kitPass2); + fileFields.append(kitPassField, kitPass2Field); + body.append(fileFields); + + const make = document.createElement("button"); + make.type = "button"; + make.id = "recovery-make"; + make.textContent = "Make a recovery kit"; + make.onclick = () => { + if (make.disabled) return; + const kind = mintKind; + const label = labelInput.value.trim() === "" ? "recovery kit" : labelInput.value.trim(); + // THE ONE MOMENT OF CLEARTEXT, as everywhere else on this page. + const pass = kitPass.value; + const pass2 = kitPass2.value; + kitPass.value = ""; + kitPass2.value = ""; + problem.hidden = true; + + if (kind === "file") { + if (pass === "") { + problem.textContent = "choose a passphrase for this file"; + problem.hidden = false; + tenantRebuild(); + return; + } + if (pass !== pass2) { + problem.textContent = "those two did not match — try again"; + problem.hidden = false; + tenantRebuild(); + return; + } + } + + make.disabled = true; + stepNote.textContent = "making your recovery kit…"; + tenantRebuild(); + void enqueue(async () => { + try { + const result = kind === "bucket" + ? await conn.createRecoveryKit({ kind: "bucket", label }) + : await conn.createRecoveryKit({ kind: "file", label, passphrase: pass }); + note("recovery:kit-created"); + stepNote.textContent = ""; + if (result.kind === "bucket") { + // DISPLAY ONCE, with the confirm-dismiss — see + // `renderPhraseOnce`. The announcement waits for the + // dismiss, deliberately. + body.replaceChildren(); + renderPhraseOnce(body, result.phrase, label); + tenantRebuild(); + return; + } + // THE FILE, DELIVERED AS A DOWNLOAD. A blob URL from this + // sheet's own button: the bytes came over the port, are + // written to no storage on the way past, and the object URL is + // revoked as soon as the click has been taken. + // + // A VISOR-VOICED FILENAME — the user's label plus the date, so + // a folder full of downloads still says which account and + // which day this one is. + const stampDate = new Date().toISOString().slice(0, 10); + const safe = label.replace(/[^a-zA-Z0-9 _-]/g, "").trim().replace(/\s+/g, "-") || + "recovery-kit"; + const blob = new Blob([result.bundle as BlobPart], { + type: "application/octet-stream", + }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${safe}-${stampDate}.polyvisor-kit`; + a.id = "recovery-download"; + a.textContent = "Download your recovery file"; + body.append(a); + a.click(); + setTimeout(() => URL.revokeObjectURL(url), 60_000); + note("recovery:kit-downloaded"); + announce( + "your recovery file is downloaded — keep it somewhere its passphrase is not " + + "written down beside it", + ); + // IN-JOB READER, for the reason spelled out on the readers + // above: this repaint runs inside the mint's own chain job. + // Re-entering `enqueue` here is what wedged the page — the + // read queued behind the very job that was awaiting it, so the + // list never painted AND every later call on the chain (the + // event drain, every driver call) queued behind a promise that + // would never settle. + await paintList(readKitsInJob); + // THE CEREMONY REOPENS. Unlike the phrase kind, which leaves + // for its display-once pane, the file kind finishes with the + // user still on this sheet — and an account may legitimately + // want a second kit (a kit per place the user keeps one). A + // control left dead would make the sheet a dead end reachable + // only by navigating out and back. + // + // A DOUBLE-CLICK CANNOT MINT TWICE BY ACCIDENT: the + // passphrase fields were cleared in the same tick the first + // mint read them, so a stray second press meets the ceremony's + // own "choose a passphrase" refusal rather than minting a kit + // nobody asked for. + make.disabled = false; + } catch (e) { + make.disabled = false; + stepNote.textContent = ""; + problem.textContent = err(e); + problem.hidden = false; + tenantRebuild(); + } + }); + }; + + const back = document.createElement("button"); + back.type = "button"; + back.id = "recovery-back"; + back.className = "hint"; + back.textContent = "back to storage"; + back.onclick = () => { + body.replaceChildren(); + renderBound(body, storage); + tenantRebuild(); + }; + + body.append(make, guarantee, stepNote, problem, back); + tenantRebuild(); }; // --- cross-page sync ------------------------------------------------------ @@ -3325,6 +4663,54 @@ async function startApp( throw new Error(err(e)); } }, + // THE FORK'S THIRD CHOICE — the recovery door on the surface a + // VIRGIN BROWSER actually lands on (entry.ts's `FirstRunHost.restore` + // carries the reasoning; in short, a browser with no devices never + // sees the picker, and that is precisely the browser a real recovery + // happens on). + // + // THIS ARM RELOADS, and the picker's does not. The difference is + // forced and worth stating: by the time the fork is on screen, + // `startApp` has ALREADY claimed the visor for the device that has + // no account — a colour is painted, a name may be. Restoring from + // here therefore cannot end with "the visor claims at the end", + // because the claim is behind us. A reload is the honest way back to + // the record's ordering: the restored device is anchored to this + // tab, so the next boot resumes it through `resolveDevice`'s anchor + // arm and claims from the profile the restore pulled — the same + // machinery, reached from the top. + // + // The device this fork belonged to is left in the index rather than + // swept: it is an account-less T0 device, which is exactly what the + // sweep already exists to collect, and destroying a namespace out + // from under a live worker on the way to a reload buys nothing. + restore: () => { + status("restoring from your recovery kit…"); + note("first-run:restore"); + return new Promise((settle) => { + mountRestore(visor, { + onRestored: (_conn, consumePending) => { + note("restored"); + // ACROSS THE RELOAD: a module variable cannot survive one, + // so the sentence rides sessionStorage and `startApp` drains + // it just after the claim on the other side. + try { + sessionStorage.setItem(RESTORE_NOTE_KEY, consumedKitSentence(consumePending)); + } catch { /* a storage-less browser loses the sentence, not the account */ } + settle(); + location.reload(); + }, + onAbandoned: () => { + // BACK TO THE FORK: an abandoned ceremony must land on a + // surface with something to do on it, and for an + // account-less device that surface is the fork. + status("ready"); + settle(); + entry = offerFirstRun(visor, us, announce, firstRunHost); + }, + }); + }); + }, }; // DOES THIS DEVICE ALREADY HOLD THE ACCOUNT? `us-profile-get` refuses @@ -3635,6 +5021,14 @@ async function startApp( appRunner: () => appRunner !== null, /** The storage sheet, entered the way a user enters it. */ openStorageSheet: () => openStorage(), + /** THE CONSUMED-KIT SENTENCE this boot announced, or "" — the one + * announcement in this page that a scenario cannot read off the + * strip, because `visor.announce` replaces and the restored boot has + * several other things to say in the seconds that follow + * (runtime/RECOVERY.md, "Single-use"). The trace marker + * `restore:announced` proves it reached the visor; this proves WHAT + * reached it. */ + restoreAnnouncement: () => restoreAnnouncedText, /** The device's own claim about where it syncs — `null` sealed or * unbound (`DeviceStatus.storage`'s own ambiguity; see rpc.ts). */ storageStatus: async () => (await conn.status()).storage, diff --git a/engine/guest/src/lib.rs b/engine/guest/src/lib.rs index 052d850..849958f 100644 --- a/engine/guest/src/lib.rs +++ b/engine/guest/src/lib.rs @@ -37,7 +37,9 @@ wit_bindgen::generate!({ mod pairing; mod persist; +mod recovery; mod usdoc; +mod wordlist; use std::cell::{Cell, RefCell}; use std::collections::{BTreeSet, HashMap, HashSet}; @@ -122,8 +124,8 @@ use subduction_keyhive::signed_message::SignedMessage; use subduction_keyhive::storage::MemoryKeyhiveStorage; use exports::polyvisor::engine::driver::{ - Guest as DriverGuest, PairAddState, PairJoinState, PairOffer, StoreConfig, UsDevice, UsEvent, - UsMark, UsPartition, UsProfile, UsStorage, + Guest as DriverGuest, PairAddState, PairJoinState, PairOffer, RecoveryKit, StoreConfig, + UsDevice, UsEvent, UsMark, UsPartition, UsProfile, UsStorage, }; use exports::polyvisor::tasks::tasks::{Guest as TasksGuest, Snapshot, TodoItem}; use polymorph::iroh::endpoint::{Endpoint, EndpointOptions, RecvStream, SendStream}; @@ -633,6 +635,11 @@ struct State { pair: pairing::PairState, /// The user-system partition (#36). us: usdoc::UsDoc, + /// The recovery kit THIS instance restored from, if any + /// (runtime/RECOVERY.md's consume path). `None` on every device that + /// booted the ordinary ways. See `recovery::RestoredKit` for why it + /// is instance memory and not checkpointed. + recovery: Option, fetches: u32, next_id: u32, } @@ -841,7 +848,20 @@ struct KpObject { struct KpPayload { /// (epoch, name-key) keychain: current epoch grants history names. name_keys: Vec<(u32, [u8; 32])>, - /// Devices whose oplogs/manifests to fetch (absent ones are skipped). + /// Devices whose oplogs/manifests to fetch (absent ones are skipped) + /// — THE HONEST AUTHOR SET, which since SYNC.md is no longer the + /// grantee set (runtime/RECOVERY.md, "`KpPayload.devices` fix"). + /// + /// Pre-SYNC.md, grantees WERE the authors, because every member got + /// a pickup. Post-SYNC.md an account's own devices are not granted + /// pickups — they read the chain out of the us-doc instead — so a + /// fresh account's us-doc K_p filled from grantees alone would name + /// no real author, and a recovery restore bootstrapping through that + /// pickup would pull from nobody. The list is therefore the UNION of + /// the account device directory (`usdoc::devices_list`) and the + /// grantees. Unchanged in meaning for the link tier, whose grantee + /// is an outside reader and whose author set is this account's + /// devices either way. devices: Vec<[u8; 32]>, } @@ -1261,11 +1281,48 @@ fn grantee_devices(doc: &[u8]) -> Result, String> { })? } +/// The device list a pickup carries: the account's own device directory +/// UNION this doc's grantees, dedup'd, order-stable (RECOVERY.md). +/// +/// Order is directory-first then grantees, each in its source's own +/// order, so two devices building this list from the same state produce +/// the same bytes — a payload that reordered per call would churn the +/// K_p object on every republish for nothing. +/// +/// A device WITHOUT an account contributes no directory half and this is +/// exactly the old behavior. A directory read that FAILS is not fatal +/// here: the grantee half is still a correct (if narrower) author set, +/// and losing the whole grant over a transient directory read would be +/// the worse trade. +async fn pickup_devices(doc: &[u8]) -> Result, String> { + let mut devices: Vec<[u8; 32]> = Vec::new(); + if usdoc::has_account()? { + match usdoc::devices_list().await { + Ok(rows) => { + for row in rows { + if let Ok(arr) = arr32(&row.agent_id, "account device") { + if !devices.contains(&arr) { + devices.push(arr); + } + } + } + } + Err(e) => eprintln!("[kp] account device directory unreadable ({e}); grantees only"), + } + } + for g in grantee_devices(doc)? { + if !devices.contains(&g) { + devices.push(g); + } + } + Ok(devices) +} + /// Publish one member's K_p: the name-key keychain + device list, sealed /// to a prekey DH (see `seal_to_member`). async fn publish_kp(st: &S3Cfg, kh: &Kh, doc: &[u8], member: &[u8]) -> Result<(), String> { let my_id_bytes = with_state(|s| s.my_peer.as_bytes().to_vec())?; - let devices = grantee_devices(doc)?; + let devices = pickup_devices(doc).await?; let payload = with_state(|s| { let b = s.buckets.get(doc).ok_or("no bucket state".to_string())?; Ok::<_, String>(KpPayload { @@ -1348,7 +1405,10 @@ async fn dbx_publish_pickup(cfg: &DbxCfg, kh: &Kh, doc: &[u8], member: &[u8]) -> let doc_link = dbx_ensure_doc_container(cfg, doc).await?; let payload = DbxPickup { doc_link, - devices: grantee_devices(doc)?, + // The same union `publish_kp` carries, for the reason recorded + // there: post-SYNC.md the grantee set is no longer the author + // set, and the two providers' bootstraps must not drift. + devices: pickup_devices(doc).await?, }; let obj = seal_to_member( kh, @@ -1574,7 +1634,10 @@ async fn gd_publish_pickup( let folder = gd_pickup_folder(cfg).await?; let payload = GdrivePickup { name_keys: doc_keychain(doc)?, - devices: grantee_devices(doc)?, + // The same union `publish_kp` carries (RECOVERY.md): this + // payload's own doc comment pins that the two providers' + // bootstraps cannot drift. + devices: pickup_devices(doc).await?, }; let obj = seal_to_member( kh, @@ -1788,6 +1851,39 @@ fn provider() -> Result { })? } +/// Resolve a `doc-id` from the bucket surface, where an EMPTY id names +/// THE ACCOUNT'S USER-SYSTEM DOCUMENT. +/// +/// WHY THIS EXISTS (a T-A interpretation call, flagged): RECOVERY.md's +/// restore reads the us-doc out of the bucket, and its kit ceremony ends +/// "the worker then flushes the us-doc and every named partition" — but +/// SYNC.md §3 scopes a sync cycle to the POINTER MAP, which does not +/// contain the us-doc, and parks "the us-doc through the bucket" +/// explicitly. So the design this track implements needs a way to NAME +/// the us-doc at the bucket surface, and none existed: the id is +/// deliberately not exposed (`us-*` hides it, usdoc.rs's `UsDoc::doc`). +/// +/// The smallest honest closure is a SENTINEL rather than a new function +/// or a leaked id: an empty doc-id was previously meaningless on every +/// arm (it derives object names from nothing and matches no partition), +/// so giving it a meaning takes nothing away, adds no id to the surface, +/// and lets the worker put the us-doc in its cycle without a second +/// call to keep in step with the first. +/// +/// REFUSES BY NAME on a device with no account: "flush the account +/// document" on a device that has none is a caller error, not an empty +/// success. +fn resolve_doc(doc_id: Vec) -> Result, String> { + if !doc_id.is_empty() { + return Ok(doc_id); + } + with_state(|s| s.us.doc.clone())?.ok_or_else(|| { + "empty doc-id names the account's user-system document, and this device has none \ + (user-create, pair, or restore first)" + .to_string() + }) +} + /// Where a flush writes. The providers differ in addressing and /// transport only — the blob CONTENTS (envelope bytes, op-stream blob, /// signed manifest) are produced by the same pipeline either way. @@ -3293,6 +3389,7 @@ fn finish_init( gd_folders: HashMap::new(), pair: pairing::PairState::default(), us: usdoc::UsDoc::default(), + recovery: None, fetches: 0, next_id: 0, }) @@ -3763,6 +3860,7 @@ impl DriverGuest for Component { } async fn bucket_flush(doc_id: Vec) -> Result { + let doc_id = resolve_doc(doc_id)?; ensure_bucket_state(&doc_id).await?; let (nk_current, epoch) = with_state(|s| { let b = s.buckets.get(&doc_id).expect("bucket state"); @@ -3851,6 +3949,7 @@ impl DriverGuest for Component { owner: Vec, pickup: Option, ) -> Result { + let doc_id = resolve_doc(doc_id)?; match provider()? { // S3 derives the K_p location from public ids; Dropbox // owner-tier pulls by path. Both ignore `pickup`. @@ -4047,6 +4146,52 @@ impl DriverGuest for Component { Ok(hex::encode(payload.verifying)) } + // --- account recovery (#11; runtime/RECOVERY.md) --- + // + // Thin delegations: the ceremony, the derivation and the restore + // ordering all live in `recovery.rs`, where the record's rationale + // is recorded beside them. + + async fn recovery_kit_create_bucket(label: String) -> Result { + recovery::kit_create_bucket(label).await + } + + async fn recovery_kit_create_file( + label: String, + passphrase: String, + ) -> Result, String> { + recovery::kit_create_file(label, passphrase).await + } + + async fn recovery_restore_bucket( + config: StoreConfig, + phrase: String, + device_name: String, + ) -> Result { + recovery::restore_bucket(config, phrase, device_name).await + } + + async fn recovery_restore_file( + config: StoreConfig, + bundle: Vec, + passphrase: String, + device_name: String, + ) -> Result { + recovery::restore_file(config, bundle, passphrase, device_name).await + } + + async fn recovery_consume() -> Result<(), String> { + recovery::consume().await + } + + async fn recovery_kits() -> Result, String> { + recovery::kits().await + } + + async fn recovery_kit_revoke(agent_id: Vec) -> Result { + recovery::kit_revoke(agent_id).await + } + // --- state persistence (#20 G5; see persist.rs for the layout and // --- the crash-consistency argument) --- diff --git a/engine/guest/src/recovery.rs b/engine/guest/src/recovery.rs new file mode 100644 index 0000000..9139a5e --- /dev/null +++ b/engine/guest/src/recovery.rs @@ -0,0 +1,990 @@ +//! Account recovery (#11), implementing runtime/RECOVERY.md. +//! +//! The claim: losing every device does not lose the account. A recovery +//! kit — a generated phrase, or a downloaded file plus its passphrase — +//! together with access to the account's storage bucket restores the +//! account on a fresh browser with NO LIVE PEER ANYWHERE. +//! +//! # Recovery is a DEVICE, not a resurrection +//! +//! The kit ceremony mints a dormant MEMBER DEVICE: a real leaf in the +//! account's delegation graph, enrolled through the ordinary +//! `usdoc::enroll_device` path, visible in the devices sheet, revocable +//! like any device. Its secrets exist only inside a sealed bundle. This +//! is not an export of a live device's identity, and the reason is +//! posture: a kept device's signing key is a non-extractable platform +//! handle, so an exportable kit from it would require a downgrade for +//! every device that wanted recovery coverage. A minted-for-export soft +//! identity leaves every real device's posture untouched. +//! +//! # Why the dormant leaf does not go stale +//! +//! The G5 finding stands — self-rotation secrets exist only in the +//! archive, so a bundle exported before its device's own authoring +//! cannot reach epochs that authoring created. The recovery device NEVER +//! AUTHORS between mint and restore, so its leaf never self-rotates, so +//! every later epoch reaches it through CGKA ops in the flushed oplogs, +//! addressed to a leaf it still holds. This is the proven G4 tablet path +//! with the tablet's browser replaced by a sealed blob. +//! +//! # The bootstrap +//! +//! SYNC.md moved the per-doc name chains INTO the us-doc, which is +//! exactly why a cold restore could not start: the us-doc's own chain is +//! inside the us-doc. `store_grant(us-doc, recovery-id)` closes the loop +//! with machinery that already exists — a K_p pickup carrying the +//! us-doc's name-key chain and the author device list, sealed to the +//! recovery device's contact-card prekeys, which ride the bundle. ONE +//! pickup bootstraps everything. + +use std::sync::Arc; + +use keyhive_core::listener::no_listener::NoListener; +use keyhive_core::store::ciphertext::memory::MemoryCiphertextStore; +use polymorph_webcrypto_guest::{hkdf, hkdf_sha2, DeriveOptions}; +use provider_gdrive::{gd_delete, gd_pickup_name}; +use provider_s3::{delete_object, get_object_unsigned, kp_location, put_object, S3Cfg}; +use serde::{Deserialize, Serialize}; + +use crate::exports::polyvisor::engine::driver::{ + Guest as DriverGuest, RecoveryKit, StoreConfig, UsStorage, + UsStorageGdrive, UsStorageS3, +}; +use crate::wordlist::EFF_SHORT; +use crate::{ + aead_from_raw, aead_open, aead_seal, argon2id_key, arr32, usdoc, with_state, BundleSlot, + Component, EngineFetch, EngineSigner, IdentityBundle, IdentityKey, Kh, KhStore, Provider, + SignerInner, StoreCfg, WebcryptoSigner, ARGON_M_KIB, ARGON_P, ARGON_T, CARD, T, +}; + +/// The two kit kinds, as the account's `recovery` record spells them. +pub(crate) const KIND_BUCKET: &str = "bucket"; +pub(crate) const KIND_FILE: &str = "file"; + +/// AAD for the recovery bundle's sealed payload. +/// +/// DOMAIN-SEPARATED FROM `b"identity-bundle"` DELIBERATELY. The two +/// payloads share the `IdentityBundle` container and the keyslot +/// machinery but decode as different structs, so a bundle of one kind +/// fed to the other kind's reader must fail at the AEAD rather than at +/// `bincode`, where a partial structural match is imaginable. +const RECOVERY_AAD: &[u8] = b"recovery-bundle"; + +/// The FIXED argon2id salt (RECOVERY.md, "Derivation, pinned"). +/// +/// Fixed BECAUSE the bucket object name must be derivable from the +/// phrase alone — there is nowhere to put a per-kit salt that a cold +/// restore could read before it has found the kit. The phrase's +/// GENERATED entropy (~103.4 bits) is the security here and argon2id is +/// depth; the brainwallet objection applies to human-chosen secrets, +/// which this slot never holds (see `kit_create_bucket`'s refusal to +/// accept one). +const ROOT_SALT: &[u8] = b"polyvisor-recovery-v1"; + +/// HKDF info strings, pinned by the record. +const INFO_NAME: &[u8] = b"polyvisor recovery name v1"; +const INFO_KEK: &[u8] = b"polyvisor recovery kek v1"; + +/// The bucket object-name prefix. Not secret — the provider sees the +/// object regardless, and the payload behind it is sealed under the +/// phrase-derived KEK. +const NAME_PREFIX: &str = "recovery/"; + +/// Words per generated phrase (RECOVERY.md: 10 × log2(1296) ≈ 103.4 +/// bits). +const PHRASE_WORDS: usize = 10; + +// --- the sealed payload --------------------------------------------------- + +/// The account's storage ADDRESSING snapshot, secret-free. +/// +/// A separate type from the WIT `us-storage` because WIT records are not +/// `Serialize`; the shapes are kept field-for-field so the conversion +/// cannot silently drop one. What is NOT here is the enforcement, and it +/// is inherited rather than restated: there is no token field and no +/// consent field in `us-storage` either, so no standing user credential +/// can ride a bundle even by accident. The S3 secret cannot appear even +/// in principle — it exists only as a non-extractable handle. +/// +/// It rides so that a FILE restore can pre-fill the destination fields +/// after unlock. The BUCKET kind cannot use it for its own fetch: +/// finding the bundle needs the destination first. +#[derive(Serialize, Deserialize)] +enum StorageSnapshot { + S3 { + endpoint: String, + bucket: String, + access_key: String, + }, + Gdrive { + root: String, + api_base: String, + space: String, + client_id: String, + client_secret: String, + }, +} + +impl StorageSnapshot { + fn from_wit(s: &UsStorage) -> Self { + match s { + UsStorage::S3(c) => StorageSnapshot::S3 { + endpoint: c.endpoint.clone(), + bucket: c.bucket.clone(), + access_key: c.access_key.clone(), + }, + UsStorage::Gdrive(c) => StorageSnapshot::Gdrive { + root: c.root.clone(), + api_base: c.api_base.clone(), + space: c.space.clone(), + client_id: c.client_id.clone(), + client_secret: c.client_secret.clone(), + }, + } + } + + #[allow(dead_code)] // the visor's pre-fill consumer is T-C's + fn to_wit(&self) -> UsStorage { + match self { + StorageSnapshot::S3 { + endpoint, + bucket, + access_key, + } => UsStorage::S3(UsStorageS3 { + endpoint: endpoint.clone(), + bucket: bucket.clone(), + access_key: access_key.clone(), + }), + StorageSnapshot::Gdrive { + root, + api_base, + space, + client_id, + client_secret, + } => UsStorage::Gdrive(UsStorageGdrive { + root: root.clone(), + api_base: api_base.clone(), + space: space.clone(), + client_id: client_id.clone(), + client_secret: client_secret.clone(), + }), + } + } +} + +/// What a recovery bundle carries. +/// +/// A NEW STRUCT, NOT AN EXTENSION OF `BundlePayload` — the ruling is +/// RECOVERY.md's compat floor made structural: `buckets.bin`-style +/// bincode is not self-describing, and every G5 bundle in the wild must +/// keep decoding. The two payloads share the container and the keyslots +/// and nothing else. +#[derive(Serialize, Deserialize)] +struct RecoveryPayload { + /// The recovery identity's 32-byte seed. A SOFT key by construction: + /// this identity is minted for export and has never been anything + /// else, which is the whole point (module header). + signing_key_seed: [u8; 32], + verifying: [u8; 32], + /// The throwaway keyhive's archive, bincoded. Signed by the recovery + /// key, which is what `try_from_archive`'s same-signer rule requires + /// at restore. + keyhive_archive: Vec, + /// The ENROLL CARD: the static events exported for the recovery + /// individual. Belt and suspenders against op-arrival-order wedges — + /// the flushed oplogs carry the same events, so this is redundancy, + /// not the only copy. + enroll_card: Vec, + /// The user-system partition id and the user group id: what + /// `usdoc::adopt` needs, and what a cold device has no other way to + /// learn. + us_partition: Vec, + user_group: Vec, + /// The GRANTING device's agent id — the `owner` component of the + /// K_p's location. Without it the restore cannot name the object + /// that bootstraps it. + granting_device: Vec, + /// The account's storage addressing at mint time, or `None` if the + /// account had no record (see `StorageSnapshot`). + storage: Option, + /// `"bucket"` or `"file"`. + kind: String, + /// The bucket object name (bucket kind only; empty for a file kit). + /// Carried so `recovery-consume` and `recovery-kit-revoke` can + /// delete the object without re-deriving it from a phrase nobody + /// kept. + object_name: String, +} + +/// What this instance restored from, held for `recovery-consume`. +/// +/// INSTANCE MEMORY ONLY, and deliberately not checkpointed: consume runs +/// in the same instance as the restore, right after the first checkpoint +/// and the content fan-out. A worker that died between the two loses the +/// consume, not the restore — and RECOVERY.md prices that exactly: "no +/// kit, loudly" is recoverable by a ceremony; the account's `recovery` +/// record still names the kit, so a later `recovery-kit-revoke` from any +/// device reaches the same end state. +#[derive(Clone)] +pub(crate) struct RestoredKit { + pub(crate) agent_id: Vec, + pub(crate) kind: String, + pub(crate) object_name: String, + pub(crate) us_partition: Vec, + pub(crate) granting_device: Vec, +} + +// --- derivation (RECOVERY.md, "Derivation, pinned") ----------------------- + +/// Entry normalization: trim, lowercase, collapse internal whitespace. +/// +/// Applied at BOTH ends — generation joins with single spaces, so a +/// generated phrase is already normalized and a re-typed one becomes it. +/// Anything else and a user who typed two spaces would derive a +/// different object name and be told, truthfully but uselessly, that +/// there is no kit at that name. +fn normalize_phrase(phrase: &str) -> String { + phrase + .trim() + .to_lowercase() + .split_whitespace() + .collect::>() + .join(" ") +} + +/// A fresh phrase: `PHRASE_WORDS` words drawn UNIFORMLY from the +/// embedded EFF short wordlist. +/// +/// Rejection sampling, not a modulus: `u32 % 1296` is biased toward the +/// first 1296 × (2^32 mod 1296 ≠ 0) words, and a biased phrase is a +/// weaker phrase in exactly the dimension the word count was chosen for. +fn generate_phrase() -> String { + let n = EFF_SHORT.len() as u32; + let limit = (u32::MAX / n) * n; + let mut words: Vec<&str> = Vec::with_capacity(PHRASE_WORDS); + while words.len() < PHRASE_WORDS { + let v: u32 = rand::random(); + if v >= limit { + continue; + } + words.push(EFF_SHORT[(v % n) as usize]); + } + words.join(" ") +} + +/// argon2id over the normalized phrase with the fixed context salt. +fn phrase_root(phrase: &str) -> Result<[u8; 32], String> { + argon2id_key( + &normalize_phrase(phrase), + ROOT_SALT, + ARGON_M_KIB, + ARGON_T, + ARGON_P, + ) +} + +/// HKDF-SHA-256(root, info) → 32 bytes, through the guest's webcrypto +/// port (`polymorph:webcrypto/hkdf` + `hkdf-sha2`), which is what the +/// engine already uses for every other symmetric primitive. +/// +/// Salt is EMPTY, i.e. RFC 5869's default of a hash-length zero block. +/// The extract step's salt is not carrying separation here — `info` is, +/// and the two derivations differ only there, which is precisely the +/// parameter RFC 5869 defines for it. +async fn hkdf32(root: &[u8; 32], info: &[u8]) -> Result<[u8; 32], String> { + let ikm = hkdf::import_ikm( + root.to_vec(), + DeriveOptions { + derive_bits: true, + derive_key: false, + }, + ) + .await + .map_err(|e| format!("hkdf import: {e}"))?; + let input = hkdf_sha2::prepare( + hkdf_sha2::Sha2Variant::Sha256, + &ikm, + Vec::new(), + info.to_vec(), + ) + .await + .map_err(|e| format!("hkdf prepare: {e}"))?; + let bits = input + .derive_bits(Some(256)) + .await + .map_err(|e| format!("hkdf derive: {e}"))?; + arr32(&bits, "hkdf output") +} + +/// The bucket object name and the slot KEK, from one phrase. +async fn derive_bucket_kit(phrase: &str) -> Result<(String, [u8; 32]), String> { + let root = phrase_root(phrase)?; + let name = hkdf32(&root, INFO_NAME).await?; + let kek = hkdf32(&root, INFO_KEK).await?; + Ok((format!("{NAME_PREFIX}{}", hex::encode(name)), kek)) +} + +// --- the config-parameterized store, for a stateless fetch --------------- + +/// S3 addressing straight out of a `store-config`, with NO ENGINE STATE +/// CONSULTED. +/// +/// This is the ordering the bucket restore turns on: the bundle must be +/// fetched before `finish_init` has run, so `crate::store()` — which +/// reads `State` — cannot be used and the config travels as a parameter +/// instead (engine.wit's note on `recovery-restore-bucket`). The S3 +/// helpers were already shaped for it: they take an explicit `S3Cfg`, +/// and `EngineFetch` counts fetches through a `let _ = with_state(..)` +/// that tolerates an uninitialized engine. +fn s3_from_config(config: &StoreConfig) -> Result { + match config { + StoreConfig::S3(c) => Ok(S3Cfg { + endpoint: c.endpoint.trim_end_matches('/').to_string(), + bucket: c.bucket.clone(), + access: c.access_key.clone(), + }), + // CONTRACT: RECOVERY.md describes the bucket kit provider- + // neutrally, but the object it needs is an owner-tier PUT at a + // NAME the guest derives, and only S3 addresses objects by name. + // Dropbox and Drive resolve ids through a folder walk, so a + // phrase-derived name is not a location there without a design + // decision this track has no ruling for. Refused BY NAME rather + // than silently mis-stored; the FILE kit works on every provider + // because it stores no object at all. + StoreConfig::Dropbox(_) => Err( + "recovery bucket kits are S3-only at this rev: a phrase-derived object NAME is not \ + a location on Dropbox (id-addressed). Use a file kit." + .into(), + ), + StoreConfig::Gdrive(_) => Err( + "recovery bucket kits are S3-only at this rev: a phrase-derived object NAME is not \ + a location on Google Drive (id-addressed). Use a file kit." + .into(), + ), + } +} + +/// The same refusal, from the bound store rather than a parameter. +fn s3_from_state() -> Result { + with_state(|s| match s.store.as_ref() { + Some(StoreCfg::S3(c)) => Ok(S3Cfg { + endpoint: c.endpoint.clone(), + bucket: c.bucket.clone(), + access: c.access.clone(), + }), + Some(_) => Err( + "recovery bucket kits are S3-only at this rev (see `s3_from_config`); use a file kit" + .to_string(), + ), + None => Err("no store bound (init-store first)".to_string()), + })? +} + +/// Which keyslot the ceremony seals the bundle key under. +/// +/// ONE SLOT PER KIT, and the kind decides which. The G5 bundle can carry +/// both because a device file is a have-and-know artifact either way; +/// a recovery bundle cannot, because the two kinds have OPPOSITE +/// exposure rules (RECOVERY.md's table) and a bundle carrying both slots +/// would be as weak as its weaker one. +enum Slot { + /// The BUCKET kind: a GENERATED secret, never a human-chosen one. + /// The exposure rule made structural — a replicated artifact gets a + /// generated-secret slot only. This is the brainwallet/LastPass + /// lesson: nothing the system replicates may be crackable via human + /// memory. + Generated([u8; 32]), + /// The FILE kind: the user's own passphrase (argon2id, random + /// per-file salt riding the slot — PERSISTENCE.md's table, + /// unchanged). Sanctioned because custody makes it have+know. + Passphrase(String), +} + +// --- the kit ceremony (RECOVERY.md, "The kit ceremony") ------------------- + +/// Everything the ceremony produces before the kind's own step: the +/// enrolled recovery device and the payload it will be sealed into, +/// minus the two fields only the kind knows. +struct Minted { + recovery_id: Vec, + payload: RecoveryPayload, +} + +/// Steps 1–3 of the record's ceremony, shared by both kinds. +/// +/// The ORDER is the record's, step for step, and it is load-bearing: +/// the contact card must be ingested before enrollment (the delegation +/// is issued to a principal the account holds), enrollment must precede +/// the K_p (the pickup carries a device list the enrollment writes), and +/// the archive must be taken before any of it (the leaf the bundle +/// carries is the never-authored one). +async fn mint(label: &str) -> Result { + // Both kinds REFUSE BY NAME without a bound store and without an + // account. A kit with no bucket restores nothing — content + // rehydrates from the bucket — and a kit with no account has no + // membership to mint. + if with_state(|s| s.store.is_none())? { + return Err( + "no store bound: a recovery kit without a bucket restores nothing (init-store first)" + .into(), + ); + } + if !usdoc::has_account()? { + return Err( + "no account on this device: a recovery kit is a MEMBER DEVICE, and there is no \ + account to enrol it into (user-create or pair first)" + .into(), + ); + } + + // 1. The recovery identity and its THROWAWAY keyhive. `contact_card` + // mints the prekeys the K_p is sealed to; the archive is taken + // immediately after, so what the bundle carries is a keyhive that + // has never authored anything (module header: dormancy). + let seed_key = ed25519_dalek::SigningKey::generate(&mut rand::rngs::OsRng); + let seed = seed_key.to_bytes(); + let verifying = seed_key.verifying_key(); + let rsigner = WebcryptoSigner(std::rc::Rc::new(SignerInner { + key: IdentityKey::Soft(Box::new(seed_key)), + verifying, + sign_count: std::cell::Cell::new(0), + })); + let rstore: KhStore = MemoryCiphertextStore::new(); + let rkh: Kh = Kh::generate(rsigner, rstore, NoListener, rand::rngs::OsRng) + .await + .map_err(|e| format!("recovery keyhive generate: {e:?}"))?; + let card = rkh + .contact_card() + .await + .map_err(|e| format!("recovery contact card: {e:?}"))?; + let card_bytes = bincode::serialize(&card).map_err(|e| e.to_string())?; + let archive_bytes = bincode::serialize(&rkh.clone().into_archive().await) + .map_err(|e| format!("archive serialize: {e}"))?; + + // 2. Enrollment, through the EXISTING path — admin membership, the + // deliberate epoch rotation, the devices entry (the us-doc's walk + // anchor), `anchor_data_partitions`. Reused, not reimplemented: a + // second enrollment path is a second place for PAIRING.md §2's + // ordering to rot. The kit appears in the devices sheet under the + // user's own label, which is what makes revoking it the same + // gesture as revoking a lost phone. + // + // No endpoint id: a dormant device is nowhere, and writing an + // empty one would turn a silence into an assertion (usdoc's + // `device_entry` contract). + let recovery_id = crate::ingest_contact_card(card_bytes).await?; + let (user_group, enroll_card, us_partition) = + usdoc::enroll_device(&recovery_id, label, &[]).await?; + + // 3. The K_p: the bootstrap object, with the device-list union fix + // (`crate::pickup_devices`). This is the ONE pickup that lets a + // cold restore start at all. + Component::store_grant(us_partition.clone(), recovery_id.clone()).await?; + + let granting_device = crate::own_agent_id()?; + let storage = usdoc::storage_get() + .await? + .as_ref() + .map(StorageSnapshot::from_wit); + + // 6. SCRUB. `rkh` and the signer holding the seed are dropped here, + // at the end of this scope; the seed itself survives only inside + // `payload`, which the caller seals and drops. HONEST CLAIM, and + // it is the honest one because this is wasm: there is no + // `mlock`, no guarantee the allocator does not keep the pages, + // and no way to prove a copy was not made. Dropping is what the + // platform offers, so dropping is what is claimed. + drop(rkh); + + Ok(Minted { + recovery_id, + payload: RecoveryPayload { + signing_key_seed: seed, + verifying: verifying.to_bytes(), + keyhive_archive: archive_bytes, + enroll_card, + us_partition, + user_group, + granting_device, + storage, + // Filled by the kind's own arm below. + kind: String::new(), + object_name: String::new(), + }, + }) +} + +/// Seal a payload into the `IdentityBundle` container under one keyslot. +/// +/// The container and the keyslot machinery are G5's, verbatim; only the +/// AAD and the payload type differ (see `RECOVERY_AAD`). +async fn seal_bundle( + label: &str, + payload: &RecoveryPayload, + slot: &Slot, +) -> Result, String> { + let bundle_key: [u8; 32] = rand::random(); + let aead = aead_from_raw(&bundle_key).await?; + let sealed = aead_seal( + &aead, + RECOVERY_AAD, + &bincode::serialize(payload).map_err(|e| e.to_string())?, + ) + .await?; + + let slot = match slot { + Slot::Generated(kek) => { + let slot_aead = aead_from_raw(kek).await?; + BundleSlot::Secret { + label: "recovery-phrase".into(), + wrapped: aead_seal(&slot_aead, b"bundle-slot", &bundle_key).await?, + } + } + Slot::Passphrase(pass) => { + let salt: [u8; 16] = rand::random(); + let slot_key = argon2id_key(pass, &salt, ARGON_M_KIB, ARGON_T, ARGON_P)?; + let slot_aead = aead_from_raw(&slot_key).await?; + BundleSlot::Passphrase { + salt, + m_cost_kib: ARGON_M_KIB, + t_cost: ARGON_T, + p_cost: ARGON_P, + wrapped: aead_seal(&slot_aead, b"bundle-slot", &bundle_key).await?, + } + } + }; + + bincode::serialize(&IdentityBundle { + label: label.to_string(), + created: crate::now_ms_u64() / 1000, + slots: vec![slot], + sealed, + }) + .map_err(|e| e.to_string()) +} + +/// Open a recovery bundle with exactly the material the kind provides. +/// +/// One slot, one key: unlike `identity-import`, which tries every slot +/// it has material for, a recovery bundle carries exactly one and the +/// caller knows which kind it holds. A miss is the SAME refusal either +/// way — "unlock failed" — because distinguishing "wrong passphrase" +/// from "wrong kind of bundle" tells an attacker which of the two they +/// got right. +async fn open_bundle(bundle: &[u8], slot: &Slot) -> Result { + let bundle: IdentityBundle = + bincode::deserialize(bundle).map_err(|e| format!("bad recovery bundle: {e}"))?; + let mut bundle_key: Option<[u8; 32]> = None; + for s in &bundle.slots { + let opened = match (s, slot) { + (BundleSlot::Secret { wrapped, .. }, Slot::Generated(kek)) => { + let slot_aead = aead_from_raw(kek).await?; + aead_open(&slot_aead, b"bundle-slot", wrapped).await.ok() + } + ( + BundleSlot::Passphrase { + salt, + m_cost_kib, + t_cost, + p_cost, + wrapped, + }, + Slot::Passphrase(pass), + ) => { + let slot_key = argon2id_key(pass, salt, *m_cost_kib, *t_cost, *p_cost)?; + let slot_aead = aead_from_raw(&slot_key).await?; + aead_open(&slot_aead, b"bundle-slot", wrapped).await.ok() + } + _ => None, + }; + if let Some(k) = opened { + bundle_key = Some(arr32(&k, "bundle key")?); + break; + } + } + let bundle_key = bundle_key.ok_or("unlock failed: no keyslot opened")?; + let aead = aead_from_raw(&bundle_key).await?; + bincode::deserialize(&aead_open(&aead, RECOVERY_AAD, &bundle.sealed).await?) + .map_err(|e| format!("recovery payload decode: {e}")) +} + +/// Flush the account document, so the kit is valid THE MOMENT the +/// ceremony reports success (RECOVERY.md, kit ceremony step 6). +/// +/// Not an optimization: a restore reads the account out of the bucket, +/// so an enrollment that lived only in this device's memory would +/// produce a kit whose device the account does not yet acknowledge +/// anywhere the restore can see. Every path here that MUTATES the +/// account document ends with this, for the same reason. +/// +/// (The ongoing cadence is the worker's; see `crate::resolve_doc` for +/// how the us-doc became nameable at the bucket surface at all.) +async fn publish_account(us_partition: &[u8]) -> Result<(), String> { + Component::bucket_flush(us_partition.to_vec()).await.map(|s| { + eprintln!("[recovery] account flushed: {s}"); + }) +} + +/// `recovery-kit-create-bucket`: mint, seal under the phrase-derived +/// KEK, upload at the phrase-derived name, record, return the phrase. +pub(crate) async fn kit_create_bucket(label: String) -> Result { + // The provider refusal comes FIRST, before anything is minted: a kit + // ceremony that enrolled a device and then discovered it could not + // store the bundle would leave a member device nobody can restore. + let st = s3_from_state()?; + let phrase = generate_phrase(); + let (name, kek) = derive_bucket_kit(&phrase).await?; + + let mut minted = mint(&label).await?; + minted.payload.kind = KIND_BUCKET.to_string(); + minted.payload.object_name = name.clone(); + let bytes = seal_bundle(&label, &minted.payload, &Slot::Generated(kek)).await?; + + // Owner tier: the account writes its own bucket. The object is + // readable by name-secrecy alone, which is what a cold restore has + // to work with — and is exactly why the slot may only ever hold a + // GENERATED secret. + put_object(&st, &EngineFetch, &EngineSigner, &name, bytes).await?; + + usdoc::recovery_put(&minted.recovery_id, KIND_BUCKET, &name, crate::now_ms_u64()).await?; + publish_account(&minted.payload.us_partition).await?; + Ok(phrase) +} + +/// `recovery-kit-create-file`: mint, seal under the user's passphrase, +/// return the bytes. No object is stored, so this arm works on every +/// provider. +pub(crate) async fn kit_create_file( + label: String, + passphrase: String, +) -> Result, String> { + if passphrase.is_empty() { + return Err("a file kit needs a passphrase: it is the only thing wrapping it".into()); + } + let mut minted = mint(&label).await?; + minted.payload.kind = KIND_FILE.to_string(); + let bytes = seal_bundle(&label, &minted.payload, &Slot::Passphrase(passphrase)).await?; + usdoc::recovery_put(&minted.recovery_id, KIND_FILE, "", crate::now_ms_u64()).await?; + publish_account(&minted.payload.us_partition).await?; + Ok(bytes) +} + +// --- restore (RECOVERY.md, "Restore") ------------------------------------ + +/// The shared restore, for both kinds. +/// +/// THE ORDERING IS THE DESIGN. Every step below either needs engine +/// state that the previous one created, or must happen before state +/// exists at all: +/// +/// 1. FETCH (bucket kind) — before `finish_init`, through the +/// config-parameterized helpers, because finding the bundle needs the +/// destination and the destination is inside the account the bundle +/// unlocks. +/// 2. OPEN + CHECK — seed against verifying key, so a tampered bundle +/// fails here rather than as an unexplained signature error later. +/// 3. `try_from_archive` + `finish_init` — `identity-import`'s pattern, +/// with the recovery signer (the archive's own signer, which +/// `try_from_archive` requires). +/// 4. APPLY THE STORE CONFIG — as `init-store` does, now that there is +/// state to hold it. +/// 5. INGEST THE ENROLL CARD — the delegation that makes this device a +/// member, ahead of the pull that would also carry it. +/// 6. ADOPT the us partition, then `bucket-pull(us, granting-device, +/// none)`, which takes the PICKUP FORK because `account_sibling` +/// reads a still-empty local device directory and correctly answers +/// no. One pickup, and the account's state is in hand. +/// 7. RENAME this device's own entry to the ceremony's `device-name` — +/// the kit's label gives way to the user's word for the machine it +/// became. +/// +/// The CONTENT fan-out over the pointer map is deliberately NOT here: it +/// is the worker's existing pull machinery, and duplicating it in the +/// guest would put a second, divergent copy of the account pull path in +/// the tree. +async fn restore( + config: StoreConfig, + payload: RecoveryPayload, + device_name: String, +) -> Result { + // Consistency before trust: a seed that does not match its recorded + // verifying key means a corrupt or forged bundle, and every later + // failure would be a less legible version of this one. + let sk = ed25519_dalek::SigningKey::from_bytes(&payload.signing_key_seed); + let verifying = ed25519_dalek::VerifyingKey::from_bytes(&payload.verifying) + .map_err(|e| format!("bad verifying key: {e:?}"))?; + if sk.verifying_key() != verifying { + return Err("recovery bundle inconsistent: seed does not match verifying key".into()); + } + let signer = WebcryptoSigner(std::rc::Rc::new(SignerInner { + key: IdentityKey::Soft(Box::new(sk)), + verifying, + sign_count: std::cell::Cell::new(0), + })); + + let archive: keyhive_core::archive::Archive = + bincode::deserialize(&payload.keyhive_archive) + .map_err(|e| format!("recovery archive decode: {e}"))?; + #[allow(clippy::arc_with_non_send_sync)] // upstream API shape; single-threaded wasm + let csprng = Arc::new(futures::lock::Mutex::new(rand::rngs::OsRng)); + let ciphertexts: KhStore = MemoryCiphertextStore::new(); + let kh = Kh::try_from_archive( + &archive, + signer.clone(), + ciphertexts.clone(), + NoListener, + csprng, + ) + .await + .map_err(|e| format!("recovery archive restore: {e:?}"))?; + let card = kh + .contact_card() + .await + .map_err(|e| format!("contact card: {e:?}"))?; + CARD.with(|c| *c.borrow_mut() = Some(card)); + crate::finish_init(signer, verifying, kh, ciphertexts)?; + + // The store, as `init-store` would apply it. Addressing only; the + // credentials are the wired instances' and never rode the bundle. + Component::init_store(config).await?; + + // The enroll card: belt and suspenders (the oplogs carry the same + // events). A card that resolves nothing yet is not an error — this + // instance holds no context — which is why the pending count is + // logged rather than refused. + let pending = crate::ingest_static_card(payload.enroll_card.clone()).await?; + if pending > 0 { + eprintln!("[recovery] enroll card: {pending} event(s) pending until the pull lands"); + } + + usdoc::adopt(&payload.us_partition, &payload.user_group).await?; + + // THE BOOTSTRAP PULL. `pickup` is `none`: S3 derives the K_p + // location from the (doc, owner, member) triple and ignores it. + let summary = Component::bucket_pull( + payload.us_partition.clone(), + payload.granting_device.clone(), + None, + ) + .await?; + eprintln!("[recovery] us bootstrap: {summary}"); + + // The devices entry exists (the ceremony's `enroll_device` wrote it + // under the kit's label), so this is a rename, not a creation — and + // `device_rename` refuses rather than creating one, for the same + // reason `device_endpoint_put` does: two devices `put_object`-ing a + // fresh entry under one key is an automerge conflict whose loser's + // fields vanish. + if !device_name.is_empty() { + usdoc::device_rename(&crate::own_agent_id()?, &device_name).await?; + } + + with_state(|s| { + s.recovery = Some(RestoredKit { + agent_id: s.my_peer.as_bytes().to_vec(), + kind: payload.kind.clone(), + object_name: payload.object_name.clone(), + us_partition: payload.us_partition.clone(), + granting_device: payload.granting_device.clone(), + }); + })?; + + Ok(hex::encode(payload.verifying)) +} + +/// `recovery-restore-bucket`. +pub(crate) async fn restore_bucket( + config: StoreConfig, + phrase: String, + device_name: String, +) -> Result { + if crate::STATE.with(|s| s.borrow().is_some()) { + return Err("already initialized".into()); + } + let st = s3_from_config(&config)?; + let (name, kek) = derive_bucket_kit(&phrase).await?; + // A WRONG PHRASE derives a different name and finds nothing. The + // refusal is the same one a CONSUMED kit gets, and that is the + // design rather than a coincidence: the kit's absence is the only + // fact either case establishes. + let bytes = get_object_unsigned(&st, &EngineFetch, &name) + .await? + .ok_or("no recovery kit at this name (wrong phrase, or the kit was already used)")?; + let payload = open_bundle(&bytes, &Slot::Generated(kek)).await?; + restore(config, payload, device_name).await +} + +/// `recovery-restore-file`. +pub(crate) async fn restore_file( + config: StoreConfig, + bundle: Vec, + passphrase: String, + device_name: String, +) -> Result { + if crate::STATE.with(|s| s.borrow().is_some()) { + return Err("already initialized".into()); + } + // NO PROVIDER CHECK HERE, deliberately: a file kit stores no object, + // so every provider the bucket surface supports can host the account + // it restores. The destination is validated where it always is, by + // `init-store` inside `restore`. + let payload = open_bundle(&bundle, &Slot::Passphrase(passphrase)).await?; + restore(config, payload, device_name).await +} + +// --- consume (RECOVERY.md, "Single-use, consumed at restore") ------------ + +/// A delete that treats ABSENCE AS SUCCESS. +/// +/// The idempotency contract engine.wit states for `recovery-consume`: +/// the embedder retries this on the flush cadence's backoff loop, so a +/// retry after partial success must not fail on an object the previous +/// attempt already removed. S3 answers a delete of a missing key with +/// 204 anyway; the 404 arm is for stores that do not. +async fn delete_gone_is_fine(st: &S3Cfg, name: &str) -> Result<(), String> { + match delete_object(st, &EngineFetch, &EngineSigner, name).await { + Ok(()) => Ok(()), + Err(e) if e.contains(": 404") || e.contains(": 204") => Ok(()), + Err(e) => Err(e), + } +} + +/// Delete the restored device's OWN pickup object — the second half of +/// single-use, and the only thing enforcing it for a file kit (the file +/// itself is in the user's custody and cannot be deleted). +/// +/// FORKED ON THE PROVIDER, exactly as `store_revoke`'s pickup deletion +/// is. This is not the same operation as `store_revoke`, though, and +/// consume must NOT call it: `store_revoke` rotates the name-key epoch +/// and strikes the agent from `grantees`, which is the semantics for +/// throwing a member out. A device consuming its OWN kit is not being +/// thrown out — it goes on being a full member, authoring under the +/// current epoch — so borrowing that path would rotate the account's +/// chain for nothing and mark the live device as a revoked grantee. +/// +/// ABSENCE IS SUCCESS on every arm, which is the retry contract: S3 via +/// `delete_gone_is_fine`, Drive because `gd_delete` resolves the name +/// first and answers `Ok` when nothing is there (and accepts 404 for the +/// already-deleted race). +/// +/// EXECUTABLE COVERAGE, honestly placed: this battery runs against MinIO +/// only, so the S3 arm below is the one `just recover` exercises. The +/// GDRIVE arm's executable coverage lands with T-B's devstore rows +/// against the fake-Drive harness — standing up a fake Drive inside the +/// native acts rig to cover it here would duplicate that harness for one +/// delete. +async fn delete_own_pickup(kit: &RestoredKit) -> Result<(), String> { + match crate::provider()? { + Provider::S3 => { + let st = s3_from_state()?; + let name = kp_location(&kit.us_partition, &kit.granting_device, &kit.agent_id).await?; + delete_gone_is_fine(&st, &name).await + } + Provider::Gdrive => { + let cfg = crate::gd()?; + let folder = crate::gd_pickup_folder(&cfg).await?; + let name = + gd_pickup_name(&kit.us_partition, &kit.granting_device, &kit.agent_id).await?; + gd_delete(&cfg, &EngineFetch, &folder, &name).await + } + // CONTRACT: RECOVERY.md says the K_p is deleted for BOTH kinds + // and says nothing about providers. The Dropbox pickup DELETE + // itself is one call (`dbx_delete` on `dbx_pickup_path`), but + // the idempotency half is not mechanical: `dbx_delete` surfaces + // `path/not_found` as an error, and teaching it absence-as- + // success means editing `providers/dropbox/store`, outside this + // track — and guessing at that error's exact shape without a + // harness to check it against would be inventing the contract + // rather than implementing it. Since a non-idempotent consume on + // the embedder's backoff loop is a permanent retry spin, the + // honest answer is to REFUSE BY NAME here. Nothing is lost that + // was working: the worker's v1 providers are S3 and Drive, and a + // Dropbox-bound account can still revoke the kit device from the + // devices sheet, which is the same end state by the other lever. + Provider::Dropbox => Err( + "recovery-consume is not wired for Dropbox at this rev: its pickup delete has no \ + absence-as-success path, and a consume that cannot be retried would spin on the \ + flush backoff loop. Revoke the kit device from the devices sheet instead." + .into(), + ), + } +} + +/// `recovery-consume`. +pub(crate) async fn consume() -> Result<(), String> { + let kit = with_state(|s| s.recovery.clone())? + .ok_or("this instance did not restore from a recovery kit")?; + + // 1. The bundle object, for a bucket kit. S3 BY CONSTRUCTION and not + // by assumption: bucket kits refuse to mint on any other provider + // (`s3_from_config`), so a non-empty `object_name` can only have + // been written by the S3 arm. A file kit's is empty, which is why + // this reads the field rather than the provider. + if kit.kind == KIND_BUCKET && !kit.object_name.is_empty() { + delete_gone_is_fine(&s3_from_state()?, &kit.object_name).await?; + } + + // 2. The pickup, whichever provider holds it. This is what makes a + // second restore refuse cleanly at the us bootstrap — a 404, + // never a fork. + delete_own_pickup(&kit).await?; + + // 3. The account's record, and the flush that publishes it. The + // device ENTRY stays: the restored device is a real member and + // goes on being one — what is consumed is the kit, not the + // device it became. + usdoc::recovery_clear(&kit.agent_id).await?; + Component::bucket_flush(kit.us_partition.clone()).await?; + Ok(()) +} + +// --- the kit registry ---------------------------------------------------- + +/// `recovery-kits`: a projection of the account's `recovery` map. +pub(crate) async fn kits() -> Result, String> { + Ok(usdoc::recovery_list() + .await? + .into_iter() + .map(|(agent_id, row)| RecoveryKit { + agent_id, + kind: row.kind, + created: row.created, + }) + .collect()) +} + +/// `recovery-kit-revoke`: a leaked phrase or file is answered by +/// revoking the kit device, because it IS a device. +pub(crate) async fn kit_revoke(agent_id: Vec) -> Result { + let _ = arr32(&agent_id, "agent id")?; + let us_partition = with_state(|s| s.us.doc.clone())? + .ok_or("no account on this device (nothing to revoke a kit from)")?; + let us_partition_for_flush = us_partition.clone(); + let row = usdoc::recovery_get(&agent_id) + .await? + .ok_or("no recovery kit recorded for this agent")?; + + // 1. Membership: the real revocation. Docs containing the user group + // drop CGKA leaves for individuals no longer reachable, and the + // devices entry is annotated for the sheet. + usdoc::device_revoke(agent_id.clone()).await?; + + // 2. The bucket tier: K_p deleted (cooperative now) and the + // name-key epoch rotated (hard forward). The note this returns is + // the guarantee class the UI renders. + let note = Component::store_revoke(us_partition, agent_id.clone()).await?; + + // 3. The bundle object, for a bucket kit. A file kit has none, and + // its bytes are in the user's custody — which is what the + // revocation above is the answer to. + if row.kind == KIND_BUCKET && !row.name.is_empty() { + delete_gone_is_fine(&s3_from_state()?, &row.name).await?; + } + + // 4. The record. The kit is gone from the account's list; the + // revoked DEVICE stays in the devices sheet, as every revoked + // device does. + usdoc::recovery_clear(&agent_id).await?; + publish_account(&us_partition_for_flush).await?; + Ok(note) +} diff --git a/engine/guest/src/usdoc.rs b/engine/guest/src/usdoc.rs index 7f5475c..353fc90 100644 --- a/engine/guest/src/usdoc.rs +++ b/engine/guest/src/usdoc.rs @@ -147,6 +147,30 @@ const ENROLLED: &str = "_enrolled"; /// forces otherwise — `snapshot` reads the families it announces and /// this key is not one of them. const BUCKET_CHAINS: &str = "bucket-chains"; +/// THE ACCOUNT'S LIVE RECOVERY KITS (runtime/RECOVERY.md, step 5 of the +/// kit ceremony). `agent id (hex) -> { kind, name, created }`, a flat +/// top-level map modelled on `PARTITIONS`: ADDITIVE, so a document +/// written before this key existed simply has no `recovery` entry and +/// every read below turns a missing map into an empty list rather than +/// an error. +/// +/// IT LIVES IN THE ACCOUNT so that ANY device can revoke or supersede a +/// kit — the devices sheet is the interface, and a kit that only its +/// minting device could see would be unrevocable from the device you +/// still have, which is the case recovery exists for. +/// +/// WHAT IS IN IT IS NOT SECRET. `kind` and `created` are metadata; the +/// bucket object NAME is not secret material either — the provider sees +/// the object regardless, and the payload behind it is sealed under the +/// phrase-derived KEK. The PHRASE is nowhere: it is displayed once in +/// visor pixels and persisted by nothing, here least of all. +/// +/// NO NEW `us-event` CASE, for the reason `us-partition-put` gives: +/// consumers poll, and a fresh arm through every adapter's exhaustive +/// match buys nothing nobody edge-triggers on. The devices map already +/// announces — a kit IS a device — so a kit's arrival and its revocation +/// are announced as `device-added` / `device-revoked` already. +const RECOVERY: &str = "recovery"; fn map_at(am: &AutoCommit, key: &str) -> Option { match am.get(ROOT, key) { @@ -1477,6 +1501,139 @@ pub(crate) async fn device_endpoint_put(endpoint: Vec) -> Result<(), String> .await } +/// Rename one device's entry — the restore ceremony's last write +/// (RECOVERY.md: "the kit's label gives way to the user's word for the +/// machine it became"). +/// +/// CONTRACT, the same one `device_endpoint_put` states and for the same +/// reason: an ABSENT entry is REFUSED rather than created. The devices +/// map is keyed by agent id, and two devices concurrently +/// `put_object`-ing a fresh map under one key is an automerge conflict +/// whose loser's fields vanish. A restore reaching this point has +/// already pulled the account state that contains its own entry, so an +/// absent one means the pull did not land — which is worth an error, not +/// a silently half-built device record. +pub(crate) async fn device_rename(agent: &[u8], name: &str) -> Result<(), String> { + let key = hex::encode(agent); + let name = name.to_string(); + write(move |am| { + let devices = map_at(am, DEVICES).ok_or("no devices map")?; + let d = child_map(am, &devices, &key) + .ok_or("no devices entry for this device (the account state has not arrived)")?; + am.put(&d, "name", name.as_str()) + .map_err(|e| format!("device name: {e}"))?; + Ok(()) + }) + .await +} + +// --- the account's recovery kits (RECOVERY.md) --------------------------- + +/// One row of the `recovery` map, as the document holds it. +pub(crate) struct RecoveryRow { + /// `"bucket"` or `"file"`. + pub(crate) kind: String, + /// The bucket object name; empty for a file kit. + pub(crate) name: String, + pub(crate) created: u64, +} + +fn read_recovery(am: &AutoCommit) -> Vec<(String, RecoveryRow)> { + let Some(kits) = map_at(am, RECOVERY) else { + // ADDITIVE: no map is an empty list, never an error. + return Vec::new(); + }; + let mut out = Vec::new(); + for key in am.keys(&kits) { + let Some(k) = child_map(am, &kits, &key) else { + continue; + }; + out.push(( + key.to_string(), + RecoveryRow { + kind: get_str(am, &k, "kind").unwrap_or_default(), + name: get_str(am, &k, "name").unwrap_or_default(), + created: get_u64(am, &k, "created").unwrap_or(0), + }, + )); + } + out +} + +/// Record a freshly minted kit. +pub(crate) async fn recovery_put( + agent: &[u8], + kind: &str, + name: &str, + created: u64, +) -> Result<(), String> { + let key = hex::encode(agent); + let kind = kind.to_string(); + let name = name.to_string(); + write(move |am| { + let kits = match map_at(am, RECOVERY) { + Some(k) => k, + None => am + .put_object(ROOT, RECOVERY, ObjType::Map) + .map_err(|e| format!("recovery map: {e}"))?, + }; + let k = match child_map(am, &kits, &key) { + Some(k) => k, + None => am + .put_object(&kits, &key, ObjType::Map) + .map_err(|e| format!("recovery entry: {e}"))?, + }; + am.put(&k, "kind", kind.as_str()) + .map_err(|e| format!("kit kind: {e}"))?; + am.put(&k, "name", name.as_str()) + .map_err(|e| format!("kit name: {e}"))?; + am.put(&k, "created", created as i64) + .map_err(|e| format!("kit created: {e}"))?; + Ok(()) + }) + .await +} + +/// Every live kit, oldest first (ties broken by agent id, so two +/// devices reading the same document list them in the same order). +pub(crate) async fn recovery_list() -> Result, RecoveryRow)>, String> { + pump().await?; + let mut out: Vec<(Vec, RecoveryRow)> = read_us(read_recovery)? + .into_iter() + .filter_map(|(key, row)| hex::decode(&key).ok().map(|raw| (raw, row))) + .collect(); + out.sort_by(|a, b| a.1.created.cmp(&b.1.created).then_with(|| a.0.cmp(&b.0))); + Ok(out) +} + +/// One kit's row, or `None` when the account does not name it. +pub(crate) async fn recovery_get(agent: &[u8]) -> Result, String> { + let key = hex::encode(agent); + Ok(recovery_list() + .await? + .into_iter() + .find(|(raw, _)| hex::encode(raw) == key) + .map(|(_, row)| row)) +} + +/// Forget a kit: consumed at restore, or revoked from the devices sheet. +/// +/// IDEMPOTENT — a missing entry is success, which is what +/// `recovery-consume`'s retry contract requires. `delete` on an absent +/// key is a no-op in automerge, and an absent MAP is nothing to delete +/// from. +pub(crate) async fn recovery_clear(agent: &[u8]) -> Result<(), String> { + let key = hex::encode(agent); + write(move |am| { + let Some(kits) = map_at(am, RECOVERY) else { + return Ok(()); + }; + let _ = am.delete(&kits, key.as_str()); + Ok(()) + }) + .await +} + pub(crate) async fn device_revoke(agent_id: Vec) -> Result<(), String> { let _ = arr32(&agent_id, "agent id")?; let group = with_state(|s| s.us.user_group.clone())? diff --git a/engine/guest/src/wordlist.rs b/engine/guest/src/wordlist.rs new file mode 100644 index 0000000..c005065 --- /dev/null +++ b/engine/guest/src/wordlist.rs @@ -0,0 +1,187 @@ +//! The EFF SHORT WORDLIST #1 (1296 words), embedded. +//! +//! Source: +//! ("EFF's New Wordlists for Random Passphrases", Joseph Bonneau, 2016), +//! licensed CC-BY-3.0 by the Electronic Frontier Foundation. The dice +//! numbering column of the upstream file is dropped; the words are kept +//! in the upstream (alphabetical) order, so index `i` here is the +//! upstream entry `i`. +//! +//! WHY EMBEDDED AND WHY THIS LIST (runtime/RECOVERY.md, "Derivation, +//! pinned"): the recovery phrase is GENERATED IN THE GUEST so that one +//! authority owns both the format and the derivation, and the bucket +//! object name derives from the phrase alone — which makes the exact +//! word set part of the wire contract, not a UI detail. 1296 words is +//! 2^10.34 per word, so the pinned 10 words carry ~103.4 bits. +//! +//! The words are short, unambiguous when typed, and free of prefix +//! collisions in the first three characters, which is what makes +//! transcription from screen to keyboard survivable. + +/// The 1296 words, upstream order. Selection is UNIFORM over this array +/// (see `recovery::generate_phrase`, which rejects the biased tail of +/// the random range rather than taking a modulus). +pub(crate) const EFF_SHORT: [&str; 1296] = [ + "acid", "acorn", "acre", "acts", "afar", "affix", "aged", "agent", + "agile", "aging", "agony", "ahead", "aide", "aids", "aim", "ajar", + "alarm", "alias", "alibi", "alien", "alike", "alive", "aloe", "aloft", + "aloha", "alone", "amend", "amino", "ample", "amuse", "angel", "anger", + "angle", "ankle", "apple", "april", "apron", "aqua", "area", "arena", + "argue", "arise", "armed", "armor", "army", "aroma", "array", "arson", + "art", "ashen", "ashes", "atlas", "atom", "attic", "audio", "avert", + "avoid", "awake", "award", "awoke", "axis", "bacon", "badge", "bagel", + "baggy", "baked", "baker", "balmy", "banjo", "barge", "barn", "bash", + "basil", "bask", "batch", "bath", "baton", "bats", "blade", "blank", + "blast", "blaze", "bleak", "blend", "bless", "blimp", "blink", "bloat", + "blob", "blog", "blot", "blunt", "blurt", "blush", "boast", "boat", + "body", "boil", "bok", "bolt", "boned", "boney", "bonus", "bony", + "book", "booth", "boots", "boss", "botch", "both", "boxer", "breed", + "bribe", "brick", "bride", "brim", "bring", "brink", "brisk", "broad", + "broil", "broke", "brook", "broom", "brush", "buck", "bud", "buggy", + "bulge", "bulk", "bully", "bunch", "bunny", "bunt", "bush", "bust", + "busy", "buzz", "cable", "cache", "cadet", "cage", "cake", "calm", + "cameo", "canal", "candy", "cane", "canon", "cape", "card", "cargo", + "carol", "carry", "carve", "case", "cash", "cause", "cedar", "chain", + "chair", "chant", "chaos", "charm", "chase", "cheek", "cheer", "chef", + "chess", "chest", "chew", "chief", "chili", "chill", "chip", "chomp", + "chop", "chow", "chuck", "chump", "chunk", "churn", "chute", "cider", + "cinch", "city", "civic", "civil", "clad", "claim", "clamp", "clap", + "clash", "clasp", "class", "claw", "clay", "clean", "clear", "cleat", + "cleft", "clerk", "click", "cling", "clink", "clip", "cloak", "clock", + "clone", "cloth", "cloud", "clump", "coach", "coast", "coat", "cod", + "coil", "coke", "cola", "cold", "colt", "coma", "come", "comic", + "comma", "cone", "cope", "copy", "coral", "cork", "cost", "cot", + "couch", "cough", "cover", "cozy", "craft", "cramp", "crane", "crank", + "crate", "crave", "crawl", "crazy", "creme", "crepe", "crept", "crib", + "cried", "crisp", "crook", "crop", "cross", "crowd", "crown", "crumb", + "crush", "crust", "cub", "cult", "cupid", "cure", "curl", "curry", + "curse", "curve", "curvy", "cushy", "cut", "cycle", "dab", "dad", + "daily", "dairy", "daisy", "dance", "dandy", "darn", "dart", "dash", + "data", "date", "dawn", "deaf", "deal", "dean", "debit", "debt", + "debug", "decaf", "decal", "decay", "deck", "decor", "decoy", "deed", + "delay", "denim", "dense", "dent", "depth", "derby", "desk", "dial", + "diary", "dice", "dig", "dill", "dime", "dimly", "diner", "dingy", + "disco", "dish", "disk", "ditch", "ditzy", "dizzy", "dock", "dodge", + "doing", "doll", "dome", "donor", "donut", "dose", "dot", "dove", + "down", "dowry", "doze", "drab", "drama", "drank", "draw", "dress", + "dried", "drift", "drill", "drive", "drone", "droop", "drove", "drown", + "drum", "dry", "duck", "duct", "dude", "dug", "duke", "duo", + "dusk", "dust", "duty", "dwarf", "dwell", "eagle", "early", "earth", + "easel", "east", "eaten", "eats", "ebay", "ebony", "ebook", "echo", + "edge", "eel", "eject", "elbow", "elder", "elf", "elk", "elm", + "elope", "elude", "elves", "email", "emit", "empty", "emu", "enter", + "entry", "envoy", "equal", "erase", "error", "erupt", "essay", "etch", + "evade", "even", "evict", "evil", "evoke", "exact", "exit", "fable", + "faced", "fact", "fade", "fall", "false", "fancy", "fang", "fax", + "feast", "feed", "femur", "fence", "fend", "ferry", "fetal", "fetch", + "fever", "fiber", "fifth", "fifty", "film", "filth", "final", "finch", + "fit", "five", "flag", "flaky", "flame", "flap", "flask", "fled", + "flick", "fling", "flint", "flip", "flirt", "float", "flock", "flop", + "floss", "flyer", "foam", "foe", "fog", "foil", "folic", "folk", + "food", "fool", "found", "fox", "foyer", "frail", "frame", "fray", + "fresh", "fried", "frill", "frisk", "from", "front", "frost", "froth", + "frown", "froze", "fruit", "gag", "gains", "gala", "game", "gap", + "gas", "gave", "gear", "gecko", "geek", "gem", "genre", "gift", + "gig", "gills", "given", "giver", "glad", "glass", "glide", "gloss", + "glove", "glow", "glue", "goal", "going", "golf", "gong", "good", + "gooey", "goofy", "gore", "gown", "grab", "grain", "grant", "grape", + "graph", "grasp", "grass", "grave", "gravy", "gray", "green", "greet", + "grew", "grid", "grief", "grill", "grip", "grit", "groom", "grope", + "growl", "grub", "grunt", "guide", "gulf", "gulp", "gummy", "guru", + "gush", "gut", "guy", "habit", "half", "halo", "halt", "happy", + "harm", "hash", "hasty", "hatch", "hate", "haven", "hazel", "hazy", + "heap", "heat", "heave", "hedge", "hefty", "help", "herbs", "hers", + "hub", "hug", "hula", "hull", "human", "humid", "hump", "hung", + "hunk", "hunt", "hurry", "hurt", "hush", "hut", "ice", "icing", + "icon", "icy", "igloo", "image", "ion", "iron", "islam", "issue", + "item", "ivory", "ivy", "jab", "jam", "jaws", "jazz", "jeep", + "jelly", "jet", "jiffy", "job", "jog", "jolly", "jolt", "jot", + "joy", "judge", "juice", "juicy", "july", "jumbo", "jump", "junky", + "juror", "jury", "keep", "keg", "kept", "kick", "kilt", "king", + "kite", "kitty", "kiwi", "knee", "knelt", "koala", "kung", "ladle", + "lady", "lair", "lake", "lance", "land", "lapel", "large", "lash", + "lasso", "last", "latch", "late", "lazy", "left", "legal", "lemon", + "lend", "lens", "lent", "level", "lever", "lid", "life", "lift", + "lilac", "lily", "limb", "limes", "line", "lint", "lion", "lip", + "list", "lived", "liver", "lunar", "lunch", "lung", "lurch", "lure", + "lurk", "lying", "lyric", "mace", "maker", "malt", "mama", "mango", + "manor", "many", "map", "march", "mardi", "marry", "mash", "match", + "mate", "math", "moan", "mocha", "moist", "mold", "mom", "moody", + "mop", "morse", "most", "motor", "motto", "mount", "mouse", "mousy", + "mouth", "move", "movie", "mower", "mud", "mug", "mulch", "mule", + "mull", "mumbo", "mummy", "mural", "muse", "music", "musky", "mute", + "nacho", "nag", "nail", "name", "nanny", "nap", "navy", "near", + "neat", "neon", "nerd", "nest", "net", "next", "niece", "ninth", + "nutty", "oak", "oasis", "oat", "ocean", "oil", "old", "olive", + "omen", "onion", "only", "ooze", "opal", "open", "opera", "opt", + "otter", "ouch", "ounce", "outer", "oval", "oven", "owl", "ozone", + "pace", "pagan", "pager", "palm", "panda", "panic", "pants", "panty", + "paper", "park", "party", "pasta", "patch", "path", "patio", "payer", + "pecan", "penny", "pep", "perch", "perky", "perm", "pest", "petal", + "petri", "petty", "photo", "plank", "plant", "plaza", "plead", "plot", + "plow", "pluck", "plug", "plus", "poach", "pod", "poem", "poet", + "pogo", "point", "poise", "poker", "polar", "polio", "polka", "polo", + "pond", "pony", "poppy", "pork", "poser", "pouch", "pound", "pout", + "power", "prank", "press", "print", "prior", "prism", "prize", "probe", + "prong", "proof", "props", "prude", "prune", "pry", "pug", "pull", + "pulp", "pulse", "puma", "punch", "punk", "pupil", "puppy", "purr", + "purse", "push", "putt", "quack", "quake", "query", "quiet", "quill", + "quilt", "quit", "quota", "quote", "rabid", "race", "rack", "radar", + "radio", "raft", "rage", "raid", "rail", "rake", "rally", "ramp", + "ranch", "range", "rank", "rant", "rash", "raven", "reach", "react", + "ream", "rebel", "recap", "relax", "relay", "relic", "remix", "repay", + "repel", "reply", "rerun", "reset", "rhyme", "rice", "rich", "ride", + "rigid", "rigor", "rinse", "riot", "ripen", "rise", "risk", "ritzy", + "rival", "river", "roast", "robe", "robin", "rock", "rogue", "roman", + "romp", "rope", "rover", "royal", "ruby", "rug", "ruin", "rule", + "runny", "rush", "rust", "rut", "sadly", "sage", "said", "saint", + "salad", "salon", "salsa", "salt", "same", "sandy", "santa", "satin", + "sauna", "saved", "savor", "sax", "say", "scale", "scam", "scan", + "scare", "scarf", "scary", "scoff", "scold", "scoop", "scoot", "scope", + "score", "scorn", "scout", "scowl", "scrap", "scrub", "scuba", "scuff", + "sect", "sedan", "self", "send", "sepia", "serve", "set", "seven", + "shack", "shade", "shady", "shaft", "shaky", "sham", "shape", "share", + "sharp", "shed", "sheep", "sheet", "shelf", "shell", "shine", "shiny", + "ship", "shirt", "shock", "shop", "shore", "shout", "shove", "shown", + "showy", "shred", "shrug", "shun", "shush", "shut", "shy", "sift", + "silk", "silly", "silo", "sip", "siren", "sixth", "size", "skate", + "skew", "skid", "skier", "skies", "skip", "skirt", "skit", "sky", + "slab", "slack", "slain", "slam", "slang", "slash", "slate", "slaw", + "sled", "sleek", "sleep", "sleet", "slept", "slice", "slick", "slimy", + "sling", "slip", "slit", "slob", "slot", "slug", "slum", "slurp", + "slush", "small", "smash", "smell", "smile", "smirk", "smog", "snack", + "snap", "snare", "snarl", "sneak", "sneer", "sniff", "snore", "snort", + "snout", "snowy", "snub", "snuff", "speak", "speed", "spend", "spent", + "spew", "spied", "spill", "spiny", "spoil", "spoke", "spoof", "spool", + "spoon", "sport", "spot", "spout", "spray", "spree", "spur", "squad", + "squat", "squid", "stack", "staff", "stage", "stain", "stall", "stamp", + "stand", "stank", "stark", "start", "stash", "state", "stays", "steam", + "steep", "stem", "step", "stew", "stick", "sting", "stir", "stock", + "stole", "stomp", "stony", "stood", "stool", "stoop", "stop", "storm", + "stout", "stove", "straw", "stray", "strut", "stuck", "stud", "stuff", + "stump", "stung", "stunt", "suds", "sugar", "sulk", "surf", "sushi", + "swab", "swan", "swarm", "sway", "swear", "sweat", "sweep", "swell", + "swept", "swim", "swing", "swipe", "swirl", "swoop", "swore", "syrup", + "tacky", "taco", "tag", "take", "tall", "talon", "tamer", "tank", + "taper", "taps", "tarot", "tart", "task", "taste", "tasty", "taunt", + "thank", "thaw", "theft", "theme", "thigh", "thing", "think", "thong", + "thorn", "those", "throb", "thud", "thumb", "thump", "thus", "tiara", + "tidal", "tidy", "tiger", "tile", "tilt", "tint", "tiny", "trace", + "track", "trade", "train", "trait", "trap", "trash", "tray", "treat", + "tree", "trek", "trend", "trial", "tribe", "trick", "trio", "trout", + "truce", "truck", "trump", "trunk", "try", "tug", "tulip", "tummy", + "turf", "tusk", "tutor", "tutu", "tux", "tweak", "tweet", "twice", + "twine", "twins", "twirl", "twist", "uncle", "uncut", "undo", "unify", + "union", "unit", "untie", "upon", "upper", "urban", "used", "user", + "usher", "utter", "value", "vapor", "vegan", "venue", "verse", "vest", + "veto", "vice", "video", "view", "viral", "virus", "visa", "visor", + "vixen", "vocal", "voice", "void", "volt", "voter", "vowel", "wad", + "wafer", "wager", "wages", "wagon", "wake", "walk", "wand", "wasp", + "watch", "water", "wavy", "wheat", "whiff", "whole", "whoop", "wick", + "widen", "widow", "width", "wife", "wifi", "wilt", "wimp", "wind", + "wing", "wink", "wipe", "wired", "wiry", "wise", "wish", "wispy", + "wok", "wolf", "womb", "wool", "woozy", "word", "work", "worry", + "wound", "woven", "wrath", "wreck", "wrist", "xerox", "yahoo", "yam", + "yard", "year", "yeast", "yelp", "yield", "yo-yo", "yodel", "yoga", + "yoyo", "yummy", "zebra", "zero", "zesty", "zippy", "zone", "zoom", +]; diff --git a/engine/guest/wit/engine.wit b/engine/guest/wit/engine.wit index 2a1f3a5..23a20cf 100644 --- a/engine/guest/wit/engine.wit +++ b/engine/guest/wit/engine.wit @@ -208,6 +208,17 @@ interface driver { /// signed manifest. Owner tier: rides `store-owner-fetch` (plus /// `store-signer` on S3). An instance wired without write authority /// fails with the wired instance's own refusal. + /// + /// AN EMPTY `doc-id` NAMES THE ACCOUNT'S USER-SYSTEM DOCUMENT. + /// SYNC.md §3 scoped a sync cycle to the pointer map and parked "the + /// us-doc through the bucket"; RECOVERY.md unparks it — a cold + /// restore reads the account out of the bucket, so the account + /// document has to BE in the bucket, and the worker's cycle needs a + /// way to say so. The id itself stays hidden (the `us-*` surface + /// never exposes it), so the sentinel is the naming: an empty id was + /// meaningless on every arm before this, which is what makes giving + /// it a meaning additive. On a device with no account it REFUSES BY + /// NAME rather than answering an empty success. bucket-flush: async func(doc-id: list) -> result; /// Pull: op stream into keyhive, chunks into the sedimentree, then @@ -218,6 +229,9 @@ interface driver { /// derives the K_p location instead and ignores it. Google Drive is /// OWNER TIER ONLY and REFUSES a `pickup` BY NAME (DRIVE.md §1): a /// link-tier pull is not a thing this provider has. + /// + /// An EMPTY `doc-id` names the account's user-system document, as it + /// does for `bucket-flush` — same sentinel, same reason. bucket-pull: async func(doc-id: list, owner-id: list, pickup: option) -> result; // --- the identity bundle (#20 G5; #11's persistence slice) --- @@ -246,6 +260,168 @@ interface driver { /// the bundled partition. Returns the identity id, hex. identity-import: async func(bundle: list, passphrase: option, secret: option>) -> result; + // --- account recovery (#11; runtime/RECOVERY.md) --- + // + // "Losing every device does not lose the account." The kit ceremony + // mints a DORMANT MEMBER DEVICE — a real leaf in the account's + // delegation graph, visible in the devices sheet, revocable like any + // device — whose secrets exist only inside a sealed bundle. Restore + // boots that device. It is NOT an export of a live device's identity: + // a kept device's signing key is a non-extractable platform handle, + // and an exportable kit from it would downgrade the posture of every + // device that wanted recovery coverage. + // + // WHY THE DORMANT LEAF STAYS VALID INDEFINITELY (the G5 staleness + // finding, answered): self-rotation secrets exist only in the + // archive, so a bundle exported before its device's own authoring + // cannot reach epochs that authoring created. The recovery device + // NEVER AUTHORS between mint and restore, so its leaf never + // self-rotates, so every later epoch reaches it through CGKA ops in + // the flushed oplogs, addressed to a leaf it still holds. No refresh + // daemon exists because none is needed. + // + // THE BOOTSTRAP is `store-grant(us-doc, recovery-id)`: SYNC.md moved + // the per-doc name chains INTO the us-doc, so a cold restore cannot + // start from the us-doc's own chain. The K_p pickup carries that + // chain and the author device list, sealed to the recovery device's + // contact-card prekeys, which ride the bundle. ONE pickup bootstraps + // everything. + // + // BOTH KINDS REQUIRE A BOUND STORE at creation (a kit without a + // bucket restores nothing — content rehydrates from the bucket) and + // storage credentials at restore. CREDENTIALS NEVER RIDE BUNDLES: + // the S3 secret is a non-extractable handle with no bytes to carry, + // and OAuth tokens are device-scoped by DRIVE.md's ruling. + + /// One live recovery kit, as the account's `recovery` record holds + /// it — a projection, not the kit itself: nothing here unlocks + /// anything. `kind` is `"bucket"` or `"file"`. + record recovery-kit { + agent-id: list, + kind: string, + created: u64, + } + + /// Mint a BUCKET kit and return its recovery phrase — 10 words from + /// the embedded EFF short wordlist, ~103.4 bits. + /// + /// THE PHRASE IS RETURNED ONCE AND PERSISTED NOWHERE. It is + /// generated in-guest (single authority for format and derivation), + /// displayed once in visor pixels, and never written to the + /// document, the checkpoint, or the bucket. + /// + /// EXPOSURE RULE, and it is why this arm takes no user secret: the + /// bundle is REPLICATED (it sits in the bucket), so its slot may + /// hold a GENERATED secret only. The brainwallet/LastPass lesson — + /// nothing the system replicates may be crackable via human memory. + /// + /// Derivation, pinned (RECOVERY.md): root = + /// argon2id(normalized phrase, salt = `polyvisor-recovery-v1`); the + /// object name is `recovery/` + hex(HKDF-SHA-256(root, info = + /// "polyvisor recovery name v1")); the slot KEK is + /// HKDF-SHA-256(root, info = "polyvisor recovery kek v1"). The salt + /// is FIXED because the name must derive from the phrase alone; the + /// phrase's generated entropy is the security and argon2id is depth. + /// + /// REFUSES BY NAME with no bound store or no account: a kit without + /// a bucket restores nothing, and a kit without an account has no + /// membership to mint. + recovery-kit-create-bucket: async func(label: string) -> result; + + /// Mint a FILE kit and return the sealed bundle bytes for download. + /// + /// The owner's amendment (RECOVERY.md, "Two kit kinds"): disallowing + /// custody would be paternalism, so the ceremony WARNS LOUDLY + /// instead. Custody makes this have+know, which is what sanctions + /// the `passphrase` keyslot (argon2id, random per-file salt) for a + /// file the user holds — PERSISTENCE.md's table, unchanged. The + /// passphrase's strength is the user's own. + /// + /// The file cannot be deleted at restore, so single-use is enforced + /// by the K_p instead: without it a second restore refuses cleanly + /// at the us bootstrap — a 404, never a fork. + recovery-kit-create-file: async func(label: string, passphrase: string) -> result, string>; + + /// Restore an account from a BUCKET kit onto an UNINITIALIZED engine + /// (instead of `init`, `identity-import` or `state-resume`). + /// Returns the restored identity id, hex. + /// + /// THE CONFIG IS A PARAMETER, not `init-store` state, and the + /// ordering is the reason: finding the bundle needs the destination + /// FIRST, and the destination cannot be read out of the account + /// document that the bundle is what unlocks. So the fetch runs + /// through config-parameterized helpers BEFORE any engine state + /// exists, and the same config is applied as `init-store` would once + /// it does. (This is also why the bucket ceremony asks the user for + /// destination AND credentials, where the file ceremony asks for + /// credentials only: the file IS the bundle.) + /// + /// `device-name` is the user's own word for the machine this became. + /// The kit's label gives way to it in the devices sheet at the end + /// of the restore. + /// + /// A WRONG PHRASE derives a different name and finds nothing: the + /// refusal is "no recovery kit at this name", never a partial + /// restore. A CONSUMED kit refuses identically, which is what makes + /// double-restore structurally impossible. + recovery-restore-bucket: async func(config: store-config, phrase: string, device-name: string) -> result; + + /// Restore from a FILE kit: the same ceremony with the bundle handed + /// over instead of fetched. A wrong passphrase is a clean slot + /// failure ("unlock failed"), indistinguishable from any other + /// keyslot miss. + recovery-restore-file: async func(config: store-config, bundle: list, passphrase: string, device-name: string) -> result; + + /// CONSUME the kit this instance restored from: delete the bundle + /// object (bucket kind), delete the K_p (both kinds), clear the + /// account's `recovery` record, flush the us-doc. + /// + /// Called by the embedder AFTER the content fan-out and the first + /// checkpoint succeed — not before: a consume that raced the restore + /// would burn the kit for a restore that had not landed. + /// + /// WHY RESTORE CONSUMES (RECOVERY.md, "Single-use"): (1) dormancy is + /// what makes the kit trustworthy and restore ENDS it — the restored + /// device authors, its leaf rotates, and the bundle is permanently + /// behind the device it claims to restore; a reusable kit would need + /// a silent background re-exporter whose failure is invisible until + /// the disaster it exists for. (2) Double-restore is an IDENTITY + /// FORK: two live instances of one identity clobber each other's + /// keyed oplog/manifest names, and SYNC.md requires the + /// single-writer-per-name invariant. (3) The phrase is spent at + /// restore time, on the machine and at the moment least favorable to + /// secret hygiene. + /// + /// IDEMPOTENT BY CONTRACT: absence is success. A retry after partial + /// success must not error on an object that is already gone — the + /// embedder retries this on the flush cadence's backoff loop, and a + /// consume failure never blocks or undoes the restore. + /// + /// The pickup deletion FORKS ON THE PROVIDER, as `store-revoke`'s + /// does — but this is not a revocation and does not rotate the + /// name-key epoch: a device consuming its OWN kit goes on being a + /// full member. NOT WIRED FOR DROPBOX at this rev and it refuses by + /// name there, because that provider's pickup delete has no + /// absence-as-success path and a non-retryable consume would spin on + /// the backoff loop forever. A Dropbox-bound account reaches the + /// same end state through `recovery-kit-revoke`. + recovery-consume: async func() -> result<_, string>; + + /// The account's live kits. Any device can read them; the devices + /// sheet renders each kit beside the device it minted. + recovery-kits: async func() -> result, string>; + + /// Revoke a kit: the same mechanic as a lost phone, because it IS + /// the same thing — a leaked phrase or file is answered by revoking + /// the kit device. Membership revocation (which rotates every doc + /// the user group reaches), plus `store-revoke` on the us-doc (K_p + /// deleted, name-key epoch rotated), plus deletion of the bundle + /// object for a bucket kit, plus clearing the account's record. + /// + /// Returns the same human-readable guarantee note `store-revoke` + /// does — the blobstore draft's guarantee class, as prose for now. + recovery-kit-revoke: async func(agent-id: list) -> result; + // --- state persistence (#20 G5; runtime/PERSISTENCE.md "Engine // --- contract additions") --- // diff --git a/engine/host/src/eff_short_wordlist.txt b/engine/host/src/eff_short_wordlist.txt new file mode 100644 index 0000000..4c8baa4 --- /dev/null +++ b/engine/host/src/eff_short_wordlist.txt @@ -0,0 +1,1296 @@ +acid +acorn +acre +acts +afar +affix +aged +agent +agile +aging +agony +ahead +aide +aids +aim +ajar +alarm +alias +alibi +alien +alike +alive +aloe +aloft +aloha +alone +amend +amino +ample +amuse +angel +anger +angle +ankle +apple +april +apron +aqua +area +arena +argue +arise +armed +armor +army +aroma +array +arson +art +ashen +ashes +atlas +atom +attic +audio +avert +avoid +awake +award +awoke +axis +bacon +badge +bagel +baggy +baked +baker +balmy +banjo +barge +barn +bash +basil +bask +batch +bath +baton +bats +blade +blank +blast +blaze +bleak +blend +bless +blimp +blink +bloat +blob +blog +blot +blunt +blurt +blush +boast +boat +body +boil +bok +bolt +boned +boney +bonus +bony +book +booth +boots +boss +botch +both +boxer +breed +bribe +brick +bride +brim +bring +brink +brisk +broad +broil +broke +brook +broom +brush +buck +bud +buggy +bulge +bulk +bully +bunch +bunny +bunt +bush +bust +busy +buzz +cable +cache +cadet +cage +cake +calm +cameo +canal +candy +cane +canon +cape +card +cargo +carol +carry +carve +case +cash +cause +cedar +chain +chair +chant +chaos +charm +chase +cheek +cheer +chef +chess +chest +chew +chief +chili +chill +chip +chomp +chop +chow +chuck +chump +chunk +churn +chute +cider +cinch +city +civic +civil +clad +claim +clamp +clap +clash +clasp +class +claw +clay +clean +clear +cleat +cleft +clerk +click +cling +clink +clip +cloak +clock +clone +cloth +cloud +clump +coach +coast +coat +cod +coil +coke +cola +cold +colt +coma +come +comic +comma +cone +cope +copy +coral +cork +cost +cot +couch +cough +cover +cozy +craft +cramp +crane +crank +crate +crave +crawl +crazy +creme +crepe +crept +crib +cried +crisp +crook +crop +cross +crowd +crown +crumb +crush +crust +cub +cult +cupid +cure +curl +curry +curse +curve +curvy +cushy +cut +cycle +dab +dad +daily +dairy +daisy +dance +dandy +darn +dart +dash +data +date +dawn +deaf +deal +dean +debit +debt +debug +decaf +decal +decay +deck +decor +decoy +deed +delay +denim +dense +dent +depth +derby +desk +dial +diary +dice +dig +dill +dime +dimly +diner +dingy +disco +dish +disk +ditch +ditzy +dizzy +dock +dodge +doing +doll +dome +donor +donut +dose +dot +dove +down +dowry +doze +drab +drama +drank +draw +dress +dried +drift +drill +drive +drone +droop +drove +drown +drum +dry +duck +duct +dude +dug +duke +duo +dusk +dust +duty +dwarf +dwell +eagle +early +earth +easel +east +eaten +eats +ebay +ebony +ebook +echo +edge +eel +eject +elbow +elder +elf +elk +elm +elope +elude +elves +email +emit +empty +emu +enter +entry +envoy +equal +erase +error +erupt +essay +etch +evade +even +evict +evil +evoke +exact +exit +fable +faced +fact +fade +fall +false +fancy +fang +fax +feast +feed +femur +fence +fend +ferry +fetal +fetch +fever +fiber +fifth +fifty +film +filth +final +finch +fit +five +flag +flaky +flame +flap +flask +fled +flick +fling +flint +flip +flirt +float +flock +flop +floss +flyer +foam +foe +fog +foil +folic +folk +food +fool +found +fox +foyer +frail +frame +fray +fresh +fried +frill +frisk +from +front +frost +froth +frown +froze +fruit +gag +gains +gala +game +gap +gas +gave +gear +gecko +geek +gem +genre +gift +gig +gills +given +giver +glad +glass +glide +gloss +glove +glow +glue +goal +going +golf +gong +good +gooey +goofy +gore +gown +grab +grain +grant +grape +graph +grasp +grass +grave +gravy +gray +green +greet +grew +grid +grief +grill +grip +grit +groom +grope +growl +grub +grunt +guide +gulf +gulp +gummy +guru +gush +gut +guy +habit +half +halo +halt +happy +harm +hash +hasty +hatch +hate +haven +hazel +hazy +heap +heat +heave +hedge +hefty +help +herbs +hers +hub +hug +hula +hull +human +humid +hump +hung +hunk +hunt +hurry +hurt +hush +hut +ice +icing +icon +icy +igloo +image +ion +iron +islam +issue +item +ivory +ivy +jab +jam +jaws +jazz +jeep +jelly +jet +jiffy +job +jog +jolly +jolt +jot +joy +judge +juice +juicy +july +jumbo +jump +junky +juror +jury +keep +keg +kept +kick +kilt +king +kite +kitty +kiwi +knee +knelt +koala +kung +ladle +lady +lair +lake +lance +land +lapel +large +lash +lasso +last +latch +late +lazy +left +legal +lemon +lend +lens +lent +level +lever +lid +life +lift +lilac +lily +limb +limes +line +lint +lion +lip +list +lived +liver +lunar +lunch +lung +lurch +lure +lurk +lying +lyric +mace +maker +malt +mama +mango +manor +many +map +march +mardi +marry +mash +match +mate +math +moan +mocha +moist +mold +mom +moody +mop +morse +most +motor +motto +mount +mouse +mousy +mouth +move +movie +mower +mud +mug +mulch +mule +mull +mumbo +mummy +mural +muse +music +musky +mute +nacho +nag +nail +name +nanny +nap +navy +near +neat +neon +nerd +nest +net +next +niece +ninth +nutty +oak +oasis +oat +ocean +oil +old +olive +omen +onion +only +ooze +opal +open +opera +opt +otter +ouch +ounce +outer +oval +oven +owl +ozone +pace +pagan +pager +palm +panda +panic +pants +panty +paper +park +party +pasta +patch +path +patio +payer +pecan +penny +pep +perch +perky +perm +pest +petal +petri +petty +photo +plank +plant +plaza +plead +plot +plow +pluck +plug +plus +poach +pod +poem +poet +pogo +point +poise +poker +polar +polio +polka +polo +pond +pony +poppy +pork +poser +pouch +pound +pout +power +prank +press +print +prior +prism +prize +probe +prong +proof +props +prude +prune +pry +pug +pull +pulp +pulse +puma +punch +punk +pupil +puppy +purr +purse +push +putt +quack +quake +query +quiet +quill +quilt +quit +quota +quote +rabid +race +rack +radar +radio +raft +rage +raid +rail +rake +rally +ramp +ranch +range +rank +rant +rash +raven +reach +react +ream +rebel +recap +relax +relay +relic +remix +repay +repel +reply +rerun +reset +rhyme +rice +rich +ride +rigid +rigor +rinse +riot +ripen +rise +risk +ritzy +rival +river +roast +robe +robin +rock +rogue +roman +romp +rope +rover +royal +ruby +rug +ruin +rule +runny +rush +rust +rut +sadly +sage +said +saint +salad +salon +salsa +salt +same +sandy +santa +satin +sauna +saved +savor +sax +say +scale +scam +scan +scare +scarf +scary +scoff +scold +scoop +scoot +scope +score +scorn +scout +scowl +scrap +scrub +scuba +scuff +sect +sedan +self +send +sepia +serve +set +seven +shack +shade +shady +shaft +shaky +sham +shape +share +sharp +shed +sheep +sheet +shelf +shell +shine +shiny +ship +shirt +shock +shop +shore +shout +shove +shown +showy +shred +shrug +shun +shush +shut +shy +sift +silk +silly +silo +sip +siren +sixth +size +skate +skew +skid +skier +skies +skip +skirt +skit +sky +slab +slack +slain +slam +slang +slash +slate +slaw +sled +sleek +sleep +sleet +slept +slice +slick +slimy +sling +slip +slit +slob +slot +slug +slum +slurp +slush +small +smash +smell +smile +smirk +smog +snack +snap +snare +snarl +sneak +sneer +sniff +snore +snort +snout +snowy +snub +snuff +speak +speed +spend +spent +spew +spied +spill +spiny +spoil +spoke +spoof +spool +spoon +sport +spot +spout +spray +spree +spur +squad +squat +squid +stack +staff +stage +stain +stall +stamp +stand +stank +stark +start +stash +state +stays +steam +steep +stem +step +stew +stick +sting +stir +stock +stole +stomp +stony +stood +stool +stoop +stop +storm +stout +stove +straw +stray +strut +stuck +stud +stuff +stump +stung +stunt +suds +sugar +sulk +surf +sushi +swab +swan +swarm +sway +swear +sweat +sweep +swell +swept +swim +swing +swipe +swirl +swoop +swore +syrup +tacky +taco +tag +take +tall +talon +tamer +tank +taper +taps +tarot +tart +task +taste +tasty +taunt +thank +thaw +theft +theme +thigh +thing +think +thong +thorn +those +throb +thud +thumb +thump +thus +tiara +tidal +tidy +tiger +tile +tilt +tint +tiny +trace +track +trade +train +trait +trap +trash +tray +treat +tree +trek +trend +trial +tribe +trick +trio +trout +truce +truck +trump +trunk +try +tug +tulip +tummy +turf +tusk +tutor +tutu +tux +tweak +tweet +twice +twine +twins +twirl +twist +uncle +uncut +undo +unify +union +unit +untie +upon +upper +urban +used +user +usher +utter +value +vapor +vegan +venue +verse +vest +veto +vice +video +view +viral +virus +visa +visor +vixen +vocal +voice +void +volt +voter +vowel +wad +wafer +wager +wages +wagon +wake +walk +wand +wasp +watch +water +wavy +wheat +whiff +whole +whoop +wick +widen +widow +width +wife +wifi +wilt +wimp +wind +wing +wink +wipe +wired +wiry +wise +wish +wispy +wok +wolf +womb +wool +woozy +word +work +worry +wound +woven +wrath +wreck +wrist +xerox +yahoo +yam +yard +year +yeast +yelp +yield +yo-yo +yodel +yoga +yoyo +yummy +zebra +zero +zesty +zippy +zone +zoom diff --git a/engine/host/src/main.rs b/engine/host/src/main.rs index d831785..936ab86 100644 --- a/engine/host/src/main.rs +++ b/engine/host/src/main.rs @@ -46,6 +46,7 @@ mod bindings { } mod pairing_acts; +mod recover_acts; mod resume_acts; use bindings::exports::polyvisor::engine::driver::{Guest as Driver, S3Config, StoreConfig}; @@ -558,8 +559,28 @@ async fn main() -> Result<()> { }; return pairing_scenarios(&engine, &component, &linker, &make_store, relay, &probe).await; } + if acts == "recover" { + // The recovery battery (#11; runtime/RECOVERY.md's T-A gate). + // Its own bucket, named for this process, for the reason the + // other two take one: the acts assert over the WHOLE key set + // (bundle objects present, then exactly two objects gone at + // consume), so a co-tenant's leftovers would be counted as ours. + // + // NO RELAY IS USED AT ALL — deliberately. The claim under test + // is "restores on a fresh browser with no live peer anywhere", + // and an act with a peer available could not tell a bucket-only + // bootstrap from a wire-assisted one. + let probe = resume_acts::S3Probe { + endpoint: s3.endpoint.clone(), + bucket: format!("pm-recover-{}", std::process::id()), + access: s3.access.clone(), + secret: (*egress.secret).clone(), + http: reqwest::Client::new(), + }; + return recover_scenarios(&component, &linker, &make_store, &probe).await; + } if acts != "full" { - bail!("unknown act set {acts} (want `full`, `pairing` or `resume`)"); + bail!("unknown act set {acts} (want `full`, `pairing`, `resume` or `recover`)"); } let mut store = make_store(&[]); @@ -583,6 +604,62 @@ async fn main() -> Result<()> { .await? } +/// The recovery act set (#11; runtime/RECOVERY.md). +/// +/// SEVEN INSTANCES, one store. Component instances do not share linear +/// memory, so every "fresh browser" below is genuinely fresh: an +/// uninitialized engine whose only inputs are the store config and the +/// kit material the act hands it. The account device is the only one +/// that ever calls `init`. +async fn recover_scenarios( + component: &Component, + linker: &Linker, + make_store: &StoreFactory<'_>, + probe: &resume_acts::S3Probe, +) -> Result<()> { + let mut store = make_store(&[]); + let account = bindings::Engine::instantiate_async(&mut store, component, linker).await?; + let restored = bindings::Engine::instantiate_async(&mut store, component, linker).await?; + let double = bindings::Engine::instantiate_async(&mut store, component, linker).await?; + let wrong_phrase = bindings::Engine::instantiate_async(&mut store, component, linker).await?; + let file_wrong_pass = + bindings::Engine::instantiate_async(&mut store, component, linker).await?; + let file_restored = bindings::Engine::instantiate_async(&mut store, component, linker).await?; + let file_double = bindings::Engine::instantiate_async(&mut store, component, linker).await?; + println!("instantiated the account device + 6 fresh restore shells"); + + let probe = probe.clone(); + let outcome = store + .run_concurrent(async move |acc| { + recover_acts::recover_act( + acc, + account, + recover_acts::Shells { + restored, + double, + wrong_phrase, + file_wrong_pass, + file_restored, + file_double, + }, + &probe, + ) + .await + }) + .await? + .map_err(|e| e.to_string()); + match outcome { + Ok(()) => { + println!("\nrecovery acts: ALL GREEN"); + Ok(()) + } + Err(e) => { + println!("\nrecovery acts: FAILED: {e}"); + bail!("the recovery act set failed") + } + } +} + /// Builds a store with the given WASI environment and, optionally, a /// preopened state root (see `make_store_in`). type StateStoreFactory<'a> = dyn Fn(&[(&str, &str)], Option<&Path>) -> Store + 'a; diff --git a/engine/host/src/recover_acts.rs b/engine/host/src/recover_acts.rs new file mode 100644 index 0000000..4a7d90c --- /dev/null +++ b/engine/host/src/recover_acts.rs @@ -0,0 +1,621 @@ +//! The recovery acts (#11; runtime/RECOVERY.md's T-A gate). +//! +//! "Losing every device does not lose the account", made executable. +//! Every claim the record makes about the recovery kit is asserted here +//! against a REAL MinIO bucket and a REAL keyhive delegation graph: +//! +//! 1. The ceremony mints a member DEVICE — it shows up in the account's +//! device directory and in the kit registry, and its bundle is an +//! object in the bucket at an opaque name. +//! 2. A post-kit REVOCATION EPOCH is crossed before the restore. This +//! is the CGKA catch-up claim, and it is the whole reason the record +//! dares to call the dormant leaf non-stale: the kit's leaf never +//! self-rotates, so every later epoch reaches it through CGKA ops in +//! the flushed oplogs. An act that restored across no epoch boundary +//! would assert nothing about that. +//! 3. A FRESH ENGINE with no live peer anywhere restores from phrase + +//! credentials alone and holds the account's content INCLUDING the +//! writes that happened after the kit was minted. +//! 4. The restored device AUTHORS and the original sees it: a real +//! member, not a read-only replica. +//! 5. CONSUME removes the artifacts, and the removal is observed from +//! the bucket and from the other device's copy of the account. +//! 6. DOUBLE RESTORE refuses — by the missing bundle for a bucket kit, +//! by the missing K_p for a file kit. Both are asserted as error +//! CLASSES, never as exact strings: the wording is the engine's to +//! improve, the class is the contract. +//! 7. Wrong passphrase and wrong phrase are clean refusals. +//! +//! # No relay, no peer, deliberately +//! +//! Nothing in this battery dials anything. That is the point of the +//! feature: the bucket is the only channel, and a restore that quietly +//! depended on a live sibling would pass an act that had one. +//! +//! # Secret material never appears in an assertion +//! +//! Object names are opaque hex derived from name-keys; the evidence is +//! set membership and counts over them. The phrase crosses this file +//! because the host is standing in for the user's eyes and fingers, and +//! it is compared against the wordlist and then used, never printed. + +use std::time::Instant; + +use wasmtime::component::Accessor; +use wasmtime::{bail, format_err, Result}; + +use crate::bindings::exports::polyvisor::engine::driver::{ + Guest as Driver, S3Config, StoreConfig, UsProfile, +}; +use crate::bindings::exports::polyvisor::tasks::tasks::Guest as Tasks; +use crate::resume_acts::S3Probe; +use crate::Ctx; + +/// The EFF short wordlist, embedded HOST-SIDE TOO. +/// +/// Duplication with the guest's `wordlist.rs`, and it is the deliberate +/// kind: an act that took the word set from the same array the generator +/// draws from could not tell a correct phrase from a phrase drawn out of +/// a corrupted list. The two copies come from the same upstream file +/// (, +/// CC-BY-3.0, EFF), and a drift between them fails act 1 — which is the +/// behaviour wanted. +const WORDLIST: &str = include_str!("eff_short_wordlist.txt"); + +/// A DECOY PHRASE for the wrong-phrase act: ten real wordlist words that +/// are not the generated ones. Obviously synthetic (the first ten words +/// of the list, in order) so nobody mistakes it for captured material. +const DECOY_PHRASE: &str = "acid acorn acre acts afar affix aged agile aging agony"; + +/// The file kit's passphrase in these acts. Obviously synthetic and +/// labelled as such: nothing here is a credential anybody holds. +const FILE_PASSPHRASE: &str = "test-file-kit-passphrase-0001"; +const WRONG_PASSPHRASE: &str = "test-file-kit-passphrase-9999"; + +fn ok(label: &str, t: Instant) { + println!("[{:>9.2?}] {label}", t.elapsed()); +} + +macro_rules! step { + ($label:expr, $call:expr) => {{ + let t = Instant::now(); + let out = $call + .await? + .map_err(|e| format_err!("{}: {e}", $label))?; + println!("[{:>9.2?}] {}", t.elapsed(), $label); + out + }}; +} + +/// The recovery bundle's object-name prefix (guest `recovery.rs`). The +/// only thing about a bundle object the host can recognise — its name is +/// otherwise a hash of a phrase-derived key. +const NAME_PREFIX: &str = "recovery/"; + +fn bundle_objects(keys: &[String]) -> Vec<&String> { + keys.iter().filter(|k| k.starts_with(NAME_PREFIX)).collect() +} + +/// Titles of a todo snapshot, sorted — the comparable shape. +fn titles(items: &[crate::TodoItem]) -> Vec { + let mut out: Vec = items.iter().map(|i| i.title.clone()).collect(); + out.sort(); + out +} + +/// Assert an error is of a CLASS, by a substring the engine's refusal is +/// built around. Never the whole string: the wording is free to improve. +fn refused(what: &str, e: &str, class: &str) -> Result<()> { + if !e.contains(class) { + bail!("{what}: refused, but not as the {class:?} class: {e}"); + } + println!("[ refused] {what}: {e}"); + Ok(()) +} + +/// The six FRESH BROWSERS this battery needs. +/// +/// Each is a distinct component instance — separate linear memory, empty +/// guest state — which is what makes "a fresh browser with no live peer +/// anywhere" an honest description of them. Grouped in a record because +/// six of anything in a parameter list stops being readable, not because +/// they share anything: they never meet. +pub(crate) struct Shells { + /// Restores the bucket kit and becomes a real member. + pub(crate) restored: crate::bindings::Engine, + /// Tries the SAME phrase again after the kit is consumed. + pub(crate) double: crate::bindings::Engine, + /// Tries a phrase that was never a kit's. + pub(crate) wrong_phrase: crate::bindings::Engine, + /// Tries the file kit with the wrong passphrase. + pub(crate) file_wrong_pass: crate::bindings::Engine, + /// Restores the file kit. + pub(crate) file_restored: crate::bindings::Engine, + /// Tries the same file bytes again after that kit is consumed. + pub(crate) file_double: crate::bindings::Engine, +} + +/// The engine's whole recovery surface, act by act. +#[allow(clippy::too_many_lines)] +pub(crate) async fn recover_act( + acc: &Accessor, + account: crate::bindings::Engine, + shells: Shells, + probe: &S3Probe, +) -> Result<()> { + let Shells { + restored, + double, + wrong_phrase, + file_wrong_pass, + file_restored, + file_double, + } = shells; + let a: &Driver = account.polyvisor_engine_driver(); + let at: &Tasks = account.polyvisor_tasks_tasks(); + + let store_cfg = || { + StoreConfig::S3(S3Config { + endpoint: probe.endpoint.clone(), + bucket: probe.bucket.clone(), + access_key: probe.access.clone(), + }) + }; + + // === act 1: the ceremony ============================================== + + let a_id_hex = step!("account.init", a.call_init(acc, false)); + let a_id = hex::decode(&a_id_hex).map_err(|e| format_err!("{e}"))?; + step!("account.init-store(s3)", a.call_init_store(acc, store_cfg())); + step!("account.ensure-bucket", a.call_ensure_bucket(acc)); + let group = step!( + "account.user-create", + a.call_user_create( + acc, + UsProfile { + display_name: "Recovered Rose".to_string(), + hue: 300, + icon: None, + }, + ) + ); + let tasks = step!("account.create-partition", a.call_create_partition(acc)); + // DELEGATED TO THE USER GROUP, before the seal. This is the account + // shape every consumer builds (the group is what pairing and the kit + // ceremony both add devices to), and the ordering is the one + // engine.wit pins: BeeKEM adds are not retroactive, so the doc's + // first epoch must already cover its intended readership. A + // partition delegated to the founding DEVICE instead would be + // unreadable by the restored kit, which is the whole point of + // asserting content equality here. + step!( + "account.kh-add-member(tasks, user group, edit)", + a.call_kh_add_member(acc, tasks.clone(), group.clone(), "edit".to_string()) + ); + step!( + "account.seal-partition", + a.call_seal_partition(acc, tasks.clone()) + ); + step!( + "account.us-partition-put(tasks)", + a.call_us_partition_put(acc, "tasks".to_string(), tasks.clone()) + ); + for title in ["buy milk", "write the recovery act"] { + step!( + format!("account.tasks.add({title})"), + at.call_add(acc, title.to_string()) + ); + } + step!( + "account.bucket-flush(us)", + a.call_bucket_flush(acc, Vec::new()) + ); + step!( + "account.bucket-flush(tasks)", + a.call_bucket_flush(acc, tasks.clone()) + ); + + let phrase = step!( + "account.recovery-kit-create-bucket", + a.call_recovery_kit_create_bucket(acc, "the recovery kit".to_string()) + ); + + // The phrase's SHAPE is the contract (RECOVERY.md, "Derivation, + // pinned"): 10 words, every one of them from the EFF short wordlist, + // normalized (single spaces, lowercase) so that what is displayed is + // exactly what re-derives the name. + let words: Vec<&str> = phrase.split(' ').collect(); + if words.len() != 10 { + bail!("recovery phrase has {} words, expected 10", words.len()); + } + let list: std::collections::HashSet<&str> = + WORDLIST.lines().map(str::trim).filter(|w| !w.is_empty()).collect(); + if list.len() != 1296 { + bail!("the embedded wordlist has {} entries, expected 1296", list.len()); + } + for w in &words { + if !list.contains(w) { + bail!("recovery phrase contains {w:?}, which is not in the EFF short wordlist"); + } + } + if phrase != phrase.trim().to_lowercase() { + bail!("recovery phrase is not normalized (trim + lowercase)"); + } + ok("act 1: phrase is 10 normalized words, all from the EFF short wordlist", Instant::now()); + + let keys = probe.keys().await?; + let bundles = bundle_objects(&keys); + if bundles.len() != 1 { + bail!( + "expected exactly one recovery bundle object in the bucket, found {}", + bundles.len() + ); + } + ok("act 1: the sealed bundle is an object in the bucket", Instant::now()); + + let devices = step!("account.us-devices-list", a.call_us_devices_list(acc)); + if devices.len() != 2 { + bail!( + "expected the founding device + the kit device in the directory, got {}", + devices.len() + ); + } + let kit_id = devices + .iter() + .find(|d| d.agent_id != a_id) + .ok_or_else(|| format_err!("the kit device is not in the account's directory"))? + .agent_id + .clone(); + let kits = step!("account.recovery-kits", a.call_recovery_kits(acc)); + if kits.len() != 1 || kits[0].agent_id != kit_id || kits[0].kind != "bucket" { + bail!("recovery-kits does not name exactly the bucket kit just minted: {kits:?}"); + } + ok("act 1: the kit is a DEVICE in the directory and a row in the registry", Instant::now()); + + // === act 2: a post-kit revocation epoch, and revoke-a-live-kit ========= + // + // Two claims in one beat. A SECOND bucket kit is minted and then + // REVOKED, which (a) forces a fresh epoch on every doc the user group + // reaches plus a name-key rotation on the us-doc — the boundary the + // first kit must later catch up across — and (b) is itself the + // revoke-a-live-kit assertion: bundle object gone, kit device flagged + // revoked, registry row gone. + + step!( + "account.tasks.add(after the kit was minted)", + at.call_add(acc, "after the kit was minted".to_string()) + ); + + let _second_kit_phrase = step!( + "account.recovery-kit-create-bucket(second kit)", + a.call_recovery_kit_create_bucket(acc, "the doomed kit".to_string()) + ); + let devices = step!("account.us-devices-list", a.call_us_devices_list(acc)); + let doomed = devices + .iter() + .find(|d| d.agent_id != a_id && d.agent_id != kit_id) + .ok_or_else(|| format_err!("the second kit device is not in the directory"))? + .agent_id + .clone(); + let keys = probe.keys().await?; + if bundle_objects(&keys).len() != 2 { + bail!("expected two bundle objects with two live kits"); + } + + let note = step!( + "account.recovery-kit-revoke(second kit)", + a.call_recovery_kit_revoke(acc, doomed.clone()) + ); + if note.is_empty() { + bail!("recovery-kit-revoke returned no guarantee note"); + } + let keys = probe.keys().await?; + let bundles = bundle_objects(&keys); + if bundles.len() != 1 { + bail!( + "revoke should have deleted exactly the revoked kit's bundle; {} remain", + bundles.len() + ); + } + let devices = step!("account.us-devices-list", a.call_us_devices_list(acc)); + if !devices.iter().any(|d| d.agent_id == doomed && d.revoked) { + bail!("the revoked kit device is not flagged revoked in the directory"); + } + let kits = step!("account.recovery-kits", a.call_recovery_kits(acc)); + if kits.len() != 1 || kits[0].agent_id != kit_id { + bail!("the revoked kit is still in the registry: {kits:?}"); + } + ok("act 2: revoking a live kit removed bundle + registry row and flagged the device", Instant::now()); + + step!( + "account.bucket-flush(us) [post-rotation]", + a.call_bucket_flush(acc, Vec::new()) + ); + step!( + "account.bucket-flush(tasks) [post-rotation]", + a.call_bucket_flush(acc, tasks.clone()) + ); + let want_titles = titles( + &step!("account.tasks.items", at.call_items(acc)).items, + ); + if want_titles.len() != 3 { + bail!("expected 3 todos on the account device, got {want_titles:?}"); + } + ok("act 2: a revocation epoch was crossed AFTER the kit was minted, and flushed", Instant::now()); + + // === act 3: a wrong phrase finds nothing ============================== + + let w: &Driver = wrong_phrase.polyvisor_engine_driver(); + match w + .call_recovery_restore_bucket( + acc, + store_cfg(), + DECOY_PHRASE.to_string(), + "never".to_string(), + ) + .await? + { + Ok(id) => bail!("a wrong phrase restored an account: {id}"), + // The derived name simply misses. The refusal cannot distinguish + // "wrong phrase" from "already consumed", and that is the design: + // absence is the only fact either case establishes. + Err(e) => refused("act 3: wrong phrase", &e, "no recovery kit at this name")?, + } + + // === act 4: the restore =============================================== + + let r: &Driver = restored.polyvisor_engine_driver(); + let rt: &Tasks = restored.polyvisor_tasks_tasks(); + + let restored_id_hex = step!( + "restored.recovery-restore-bucket", + r.call_recovery_restore_bucket( + acc, + store_cfg(), + phrase.clone(), + "the restored laptop".to_string(), + ) + ); + if restored_id_hex != hex::encode(&kit_id) { + bail!( + "restore booted the wrong identity: {restored_id_hex} != {}", + hex::encode(&kit_id) + ); + } + ok("act 4: the restored instance IS the kit device", Instant::now()); + + // The account state arrived through ONE K_p pickup and the bucket. + let profile = step!("restored.us-profile-get", r.call_us_profile_get(acc)); + if profile.display_name != "Recovered Rose" || profile.hue != 300 { + bail!("the restored profile is not the account's: {profile:?}"); + } + let devices = step!("restored.us-devices-list", r.call_us_devices_list(acc)); + if devices.len() != 3 { + bail!("the restored device sees {} devices, expected 3", devices.len()); + } + if !devices + .iter() + .any(|d| d.agent_id == kit_id && d.name == "the restored laptop") + { + bail!("the restore did not rename its own devices entry: {devices:?}"); + } + ok("act 4: profile + device directory match the account (and the kit was renamed)", Instant::now()); + + // THE WORKER'S FAN-OUT, driven here: the pointer map times the device + // directory. Deliberately not in the guest (RECOVERY.md: the content + // fan-out is the worker's existing pull machinery). + let pointers = step!("restored.us-partitions", r.call_us_partitions(acc)); + if !pointers.iter().any(|p| p.name == "tasks" && p.id == tasks) { + bail!("the restored device did not learn the account's tasks pointer: {pointers:?}"); + } + for p in &pointers { + step!( + format!("restored.adopt-partition({})", p.name), + r.call_adopt_partition(acc, p.id.clone()) + ); + let summary = step!( + format!("restored.bucket-pull({}, account)", p.name), + r.call_bucket_pull(acc, p.id.clone(), a_id.clone(), None) + ); + println!(" {}: {summary}", p.name); + } + + let got = titles(&step!("restored.tasks.items", rt.call_items(acc)).items); + if got != want_titles { + bail!("restored content differs from the account's:\n want {want_titles:?}\n got {got:?}"); + } + if !got.iter().any(|t| t == "after the kit was minted") { + bail!("the restore did not catch up across the post-kit epoch: {got:?}"); + } + ok("act 4: content equal to the account's, INCLUDING the post-kit write", Instant::now()); + + // === act 5: the restored device is a real member ====================== + + step!( + "restored.tasks.add(from the restored device)", + rt.call_add(acc, "from the restored device".to_string()) + ); + step!( + "restored.bucket-flush(tasks)", + r.call_bucket_flush(acc, tasks.clone()) + ); + step!( + "account.bucket-pull(tasks, restored)", + a.call_bucket_pull(acc, tasks.clone(), kit_id.clone(), None) + ); + let seen = titles(&step!("account.tasks.items", at.call_items(acc)).items); + if !seen.iter().any(|t| t == "from the restored device") { + bail!("the account never saw the restored device's write: {seen:?}"); + } + ok("act 5: the restored device authored and the account read it", Instant::now()); + + // === act 6: consume =================================================== + + let before = probe.keys().await?; + step!("restored.recovery-consume", r.call_recovery_consume(acc)); + let after = probe.keys().await?; + if !bundle_objects(&after).is_empty() { + bail!("consume left a bundle object in the bucket: {:?}", bundle_objects(&after)); + } + // The K_p is an opaque hash like every other object, so it is + // identified by DIFFERENCE rather than by name: exactly two objects + // went away (the bundle and the K_p) and nothing else did. + let gone: Vec<&String> = before.iter().filter(|k| !after.contains(k)).collect(); + if gone.len() != 2 { + bail!("consume removed {} objects, expected exactly 2 (bundle + K_p): {gone:?}", gone.len()); + } + ok("act 6: consume removed the bundle and the K_p, and nothing else", Instant::now()); + + // Seen from the RESTORED device itself: its own registry read must + // agree with the consume it just performed (found disagreeing on the + // solo page — this pins where the disagreement lives). + let own = step!("restored.recovery-kits (own view)", r.call_recovery_kits(acc)); + if !own.is_empty() { + bail!("the restored device still lists the kit it consumed: {own:?}"); + } + ok("act 6: the restored device's own registry agrees with its consume", Instant::now()); + + // IDEMPOTENT: the retry the embedder's backoff loop will make. + step!( + "restored.recovery-consume (retry: absence is success)", + r.call_recovery_consume(acc) + ); + + // Observed from the OTHER device, through the account: the registry + // row is gone everywhere, not just locally. + step!( + "account.bucket-pull(us, restored)", + a.call_bucket_pull(acc, Vec::new(), kit_id.clone(), None) + ); + let kits = step!("account.recovery-kits", a.call_recovery_kits(acc)); + if !kits.is_empty() { + bail!("the consumed kit is still in the account's registry: {kits:?}"); + } + ok("act 6: the account's kit registry is empty, seen from the other device", Instant::now()); + + // === act 7: double restore refuses ==================================== + + let dbl: &Driver = double.polyvisor_engine_driver(); + match dbl + .call_recovery_restore_bucket( + acc, + store_cfg(), + phrase.clone(), + "the fork that must not be".to_string(), + ) + .await? + { + Ok(id) => bail!("a consumed bucket kit restored a SECOND instance of {id} — an identity fork"), + Err(e) => refused("act 7: double restore (bucket)", &e, "no recovery kit at this name")?, + } + + // === act 8: the file kit ============================================== + + let bundle = step!( + "account.recovery-kit-create-file", + a.call_recovery_kit_create_file( + acc, + "the downloaded kit".to_string(), + FILE_PASSPHRASE.to_string(), + ) + ); + if bundle.is_empty() { + bail!("the file kit returned no bytes"); + } + // A file kit stores NO object: its single-use enforcement is the K_p. + let keys = probe.keys().await?; + if !bundle_objects(&keys).is_empty() { + bail!("a file kit wrote a bundle object into the bucket"); + } + ok("act 8: the file kit returned bytes and stored no object", Instant::now()); + // The registry must name the file kit — revocability is the record's + // stated answer to a leaked kit, and an unlisted kit cannot be + // revoked (found unasserted by T-C's sheet, which saw file kits + // silently missing). + let kits = step!("account.recovery-kits [file kit]", a.call_recovery_kits(acc)); + if kits.len() != 1 || kits[0].kind != "file" { + bail!("recovery-kits does not name exactly the file kit just minted: {kits:?}"); + } + step!( + "account.bucket-flush(us) [file kit]", + a.call_bucket_flush(acc, Vec::new()) + ); + step!( + "account.bucket-flush(tasks) [file kit]", + a.call_bucket_flush(acc, tasks.clone()) + ); + let want_titles = titles(&step!("account.tasks.items", at.call_items(acc)).items); + + let fw: &Driver = file_wrong_pass.polyvisor_engine_driver(); + match fw + .call_recovery_restore_file( + acc, + store_cfg(), + bundle.clone(), + WRONG_PASSPHRASE.to_string(), + "never".to_string(), + ) + .await? + { + Ok(id) => bail!("a wrong passphrase opened the file kit: {id}"), + Err(e) => refused("act 8: wrong passphrase", &e, "unlock failed")?, + } + + let f: &Driver = file_restored.polyvisor_engine_driver(); + let ft: &Tasks = file_restored.polyvisor_tasks_tasks(); + let file_id_hex = step!( + "file-restored.recovery-restore-file", + f.call_recovery_restore_file( + acc, + store_cfg(), + bundle.clone(), + FILE_PASSPHRASE.to_string(), + "the restored tablet".to_string(), + ) + ); + let pointers = step!("file-restored.us-partitions", f.call_us_partitions(acc)); + for p in &pointers { + step!( + format!("file-restored.adopt-partition({})", p.name), + f.call_adopt_partition(acc, p.id.clone()) + ); + step!( + format!("file-restored.bucket-pull({}, account)", p.name), + f.call_bucket_pull(acc, p.id.clone(), a_id.clone(), None) + ); + } + let got = titles(&step!("file-restored.tasks.items", ft.call_items(acc)).items); + if got != want_titles { + bail!("file-kit restore content differs:\n want {want_titles:?}\n got {got:?}"); + } + ok( + &format!("act 8: the file kit restored {} with the account's content", &file_id_hex[..8]), + Instant::now(), + ); + + step!( + "file-restored.recovery-consume", + f.call_recovery_consume(acc) + ); + + // The FILE cannot be deleted — the user holds it — so single-use is + // carried by the K_p alone. A second restore therefore refuses one + // step later than the bucket kind's does: it unlocks fine and then + // finds no pickup at the us bootstrap. A 404, never a fork. + let fd: &Driver = file_double.polyvisor_engine_driver(); + match fd + .call_recovery_restore_file( + acc, + store_cfg(), + bundle, + FILE_PASSPHRASE.to_string(), + "the second fork that must not be".to_string(), + ) + .await? + { + Ok(id) => bail!("a consumed file kit restored a SECOND instance of {id} — an identity fork"), + Err(e) => refused("act 8: double restore (file)", &e, "kp missing")?, + } + + Ok(()) +} diff --git a/engine/justfile b/engine/justfile index 2cc8ed7..d8d70b0 100644 --- a/engine/justfile +++ b/engine/justfile @@ -227,3 +227,43 @@ pair: compose relay-bin minio --acts pairing --relay http://127.0.0.1:3340 \ --endpoint http://127.0.0.1:9000 \ --access minioadmin --secret minioadmin + +# The RECOVERY.md T-A acts (#11): the recovery kit ceremony, the restore +# on a fresh engine, consume, double-restore refusal, and revocation. +# +# NO RELAY IS DIALLED and none is needed — which is the whole claim under +# test: "restores on a fresh browser with no live peer anywhere". The +# relay is still a recipe dependency so the binary is present and the +# recipe's shape matches `pair`/`resume`; nothing here connects to it. +# +# MinIO is the only channel. Own bucket, named for the host pid, and the +# same fixed-port guard `pair`/`resume` carry: the acts assert over the +# WHOLE key set (bundle objects present, then exactly two objects gone at +# consume), so probing a co-tenant's server would pass for the wrong +# reason. +recover: compose relay-bin minio + #!/usr/bin/env bash + set -euo pipefail + data="$(mktemp -d /tmp/pm-recover-minio.XXXXXX)" + MINIO_ROOT_USER=minioadmin MINIO_ROOT_PASSWORD=minioadmin \ + .deps/minio server "$data" --address 127.0.0.1:9000 --quiet & + minio_pid=$! + # Ordered teardown, as in `pair`/`resume`: MinIO is still writing into + # $data when it is signalled, so removing the tree without waiting + # loses the race often enough to fail an otherwise-passing recipe on + # its own cleanup. + trap 'kill $minio_pid 2>/dev/null || true; wait $minio_pid 2>/dev/null || true; rm -rf "$data" 2>/dev/null || true' EXIT + for _ in $(seq 1 50); do + if curl -sf http://127.0.0.1:9000/minio/health/live >/dev/null 2>&1; then break; fi + sleep 0.2 + done + if ! kill -0 "$minio_pid" 2>/dev/null; then + echo "minio did not survive startup on 127.0.0.1:9000 (port already in use?)" >&2 + echo "the recovery acts would have probed someone else's server; refusing" >&2 + exit 1 + fi + sleep 0.5 + cargo run --release -p engine-host -- target/composed.wasm \ + --acts recover \ + --endpoint http://127.0.0.1:9000 \ + --access minioadmin --secret minioadmin diff --git a/runtime/RECOVERY.md b/runtime/RECOVERY.md new file mode 100644 index 0000000..f38c024 --- /dev/null +++ b/runtime/RECOVERY.md @@ -0,0 +1,319 @@ +# Recovery: the account outlives its last device + +The design record for the round that closes SYNC.md's parked item — +account bootstrap without a live peer — and #11's recovery-path body. +Rulings settled 2026-08-25 (discussion with the project owner; the +file-kit amendment is the owner's). Sibling records: +[PERSISTENCE.md](./PERSISTENCE.md) (the device store, the KEK ladder, +the recorded PRF dual-eval seam), [SYNC.md](./SYNC.md) (the name-chain +account state and the account pull path this design bootstraps into), +[STORAGE-EGRESS.md](./STORAGE-EGRESS.md) (the worker's seams the restore +ceremony rides). Where this document and reality disagree, report the +friction, do not edit around it. + +## The claim + +Losing every device does not lose the account. A recovery kit — a +generated phrase, or a downloaded file plus its passphrase — together +with access to the account's storage bucket restores the account on a +fresh browser with **no live peer anywhere**. The bucket is already the +account's durable half (SYNC.md); this round gives it a durable key. + +Honest floor, stated: bucket + all devices lost = the account is gone. +No copies exist anywhere by construction; that is what local-first with +an E2E bucket means. The kit is the bucket's key, not a second bucket. + +## The core ruling: recovery is a DEVICE, not a resurrection + +The kit ceremony mints a **dormant member device** — a real leaf in the +account's delegation graph, visible in the devices sheet under the +user's own label, revocable like any device. Its secrets exist only +inside a sealed bundle (in the bucket, or in the user's downloaded +file). Restore boots that device. + +Why not export the live device's own identity (the G5 bundle's shape): + +- **Platform posture survives.** A kept device's signing key is a + non-extractable platform handle — that is the point of it. An + exportable kit from that device would require a posture downgrade for + every device that wants recovery coverage. A minted-for-export soft + identity leaves every real device's posture untouched. +- **Dormancy kills staleness.** The G5 finding stands: self-rotation + secrets exist only in the archive — a bundle exported before its + device's own authoring cannot reach epochs that authoring created. + The recovery device NEVER AUTHORS between mint and restore, so its + leaf never self-rotates, so the bundle's archived leaf secrets stay + valid indefinitely: every later epoch reaches it through CGKA ops in + the flushed oplogs, addressed to a leaf it still holds. This is the + proven G4 tablet path (enrolled, offline through rotations, + bucket-bootstrapped) with the tablet's browser replaced by a sealed + blob. No refresh daemon exists because none is needed. +- **Its own revocation story comes free.** A leaked phrase or file is + answered by revoking the kit device in the devices sheet — the same + mechanic as a lost phone, because it IS the same thing. + +## The bootstrap: K_p answers the us-doc chicken-and-egg + +SYNC.md moved the per-doc name chains into the us-doc (account state) +and removed pickups between account siblings — which is exactly why a +cold restore could not start: the us-doc's own chain is inside the +us-doc. The kit ceremony closes the loop with machinery that already +exists: `store-grant(us-doc, recovery-id)` writes the recovery device a +**K_p pickup** — the non-account-reader bootstrap object, carrying the +us-doc's name-key chain and the author device list, sealed to the +recovery device's contact-card prekeys (which ride the bundle). + +Restore order: import bundle → adopt the us partition (id and user +group ride the bundle) → `bucket-pull(us, owner, none)` takes the +pickup fork (`account_sibling` reads the still-empty local directory +and correctly answers no) → chain + authors in hand → us-doc content +applies → the account pull path (SYNC.md §2) now works for every +partition in the pointer map. ONE pickup bootstraps everything. + +**`KpPayload.devices` fix, riding along.** `publish_kp` fills the +payload's device list from `grantees` — pre-SYNC.md that was the author +set, because every member got a pickup. Post-SYNC.md account devices +are not granted pickups, so a fresh account's us-doc K_p would name no +real author and the restore would pull from nobody. The payload's +device list becomes the union of the account device directory +(`usdoc::devices_list`) and the grantees — the honest author set for +account docs, unchanged in meaning for the link tier. + +**The us-doc through the bucket, unparked (settled in T-A).** SYNC.md +scoped the flush/pull cycle to the pointer map and parked the us-doc; +this round unparks it. The WIT surface: an EMPTY `doc-id` on +`bucket-flush`/`bucket-pull` names the account's user-system document +(previously meaningless on every arm; the us id itself stays hidden). +The worker's flush/pull cycle MUST include it — the engine flushes the +us-doc only at the moments it controls (kit create, revoke, consume), +and a restore can only be as fresh as the last us flush. + +## Two kit kinds, one mechanism + +| | bucket kit | file kit | +|---|---|---| +| artifact | sealed bundle at a phrase-derived bucket name | sealed bundle as a downloaded file | +| unlock | generated recovery phrase (~10 diceware words, 100+ bits) | user-chosen passphrase (argon2id, random salt) | +| exposure rule | replicated ⇒ generated-secret slot ONLY (the brainwallet/LastPass lesson: nothing the system replicates is crackable via human memory) | custody makes it have+know — the passphrase slot is sanctioned for a file the user holds (PERSISTENCE.md's table, unchanged) | +| finds the bundle | name derives from the phrase — nothing else needed | the file IS the bundle | +| single-use enforcement | bundle object + K_p deleted at restore | K_p deleted at restore (the file cannot be deleted; without the K_p a second restore refuses cleanly at the us bootstrap — a 404, never a fork) | + +**Bucket kits are S3-only at this rev (settled in T-A).** The bucket +kind needs an owner-tier PUT at a NAME the guest derives, and only S3 +addresses objects by name — Dropbox and Drive resolve ids through a +folder walk, so a phrase-derived name is not a location there without +a design decision this round does not make. Refused by name at both +create and restore; the FILE kit stores no object and works on every +provider, so no provider loses recovery coverage. Drive bucket kits +are PARKED, not precluded. One more provider edge, recorded: +`recovery-consume` forks by provider (S3 and Drive arms exist; +Dropbox refuses by name because its pickup delete has no +absence-as-success path and a non-idempotent consume would spin the +retry loop — `recovery-kit-revoke` from the devices sheet is the +equivalent end state there). + +The file kit is the owner's amendment: disallowing custody would be +paternalism, so the ceremony WARNS LOUDLY instead — the passphrase's +strength is the user's own; the file plus its passphrase open the +account; the file is dead the day it is used or its device revoked. +Both kinds still require a bound store at creation (a kit without a +bucket restores nothing — content rehydrates from the bucket) and +storage credentials at restore (credentials never ride bundles: the S3 +secret is a non-extractable handle with no bytes to carry, and OAuth +tokens are device-scoped by DRIVE.md's ruling). + +## Single-use, consumed at restore + +Restore consumes the kit: the bundle object (bucket kind) and the K_p +(both kinds) are deleted after the restore fully succeeds, the us-doc +kit record is cleared, and the visor announces "your recovery kit was +used — create a new one". Rationale, recorded in full on the #11 +thread and compressed here: + +1. **Dormancy is what makes the kit trustworthy, and restore ends it.** + The restored device authors; its leaf rotates; the bundle is now + permanently behind the device it claims to restore. A reusable kit + therefore needs a silent background re-exporter of the crown-jewels + artifact whose failure is invisible until the disaster it exists + for. Single-use plus a cheap re-mint ceremony reaches the same end + state through an explicit, announced, user-witnessed act. +2. **Double-restore is an identity fork.** Two live instances of one + identity clobber each other's keyed oplog/manifest names — the + single-writer-per-name invariant SYNC.md requires preserving. + Consumption makes the fork structurally impossible. +3. **The phrase is spent at restore time.** Recovery happens on the + machine and at the moment least favorable to secret hygiene. With + consumption, a phrase passively captured during the ceremony is + worthless afterward. + +The honest cost: a window with NO kit after restore, until the user +mints a fresh one. "No kit, loudly" is recoverable by a ceremony; +"bad kit, quietly" is discovered at the disaster. Consume failures +(unreachable bucket at the end of a restore) never block the restore: +they announce and retry on the flush cadence's backoff loop. + +**The consume-checkpoint discipline (settled in T-B's revision, pinned +by devstore row 64 with a negative control).** The restore's FIRST +checkpoint deliberately precedes the consume — a crash between them +burns the kit with nothing durable, a lockout on a last device. But a +consume that outlives its checkpoint is RESURRECTED by the next worker +respawn: internal driver calls bypass the mutation-armed checkpoint +debounce (which dispatches client requests only), and the consume's +flushed clear sits under the device's OWN keyed names, which the pull +fan-out self-filters — durable in the bucket, permanently invisible to +its author. So every successful consume is followed by a second +checkpoint, `consumePending` clears only after it lands, and the same +rule covers the ceremony's other internal mutations: kit CREATE and +REVOKE checkpoint explicitly too (a respawn forgetting a kit whose +phrase the user just wrote down, or resurrecting a revocation the +provider already executed, are the same stranding). The scheduler's +own bucket-state mutations stay un-checkpointed on purpose: that state +self-heals from the account document and the next pull's manifests, +and the cost is one duplicate upload, never an unrecoverable fact. + +## The kit ceremony (account device, guest-side) + +1. Mint a fresh soft Ed25519 identity — the recovery identity — and a + THROWAWAY keyhive for it: contact card (prekeys minted), then + archive it immediately. The archive is tiny and signed by the + recovery key, which is what `try_from_archive`'s same-signer rule + requires at restore. +2. Ingest the recovery contact card into the account keyhive; enroll + through the existing `enroll_device` path — admin membership, the + deliberate epoch rotation, the devices entry (walk anchor for the + us-doc), `anchor_data_partitions` (walk anchors everywhere else). + The kit appears in the devices sheet under the user's label. +3. `store-grant(us-doc, recovery-id)` — the K_p, with the fixed device + list. +4. Build the bundle (payload below); seal under the kind's slot; + bucket kind uploads at the derived name (owner tier), file kind + returns the bytes for download. +5. Record the kit in the us-doc (`recovery` map: agent-id-hex → + {kind, bucket object name or empty, created}) — any account device + can then revoke or supersede it; the object NAME is not secret + material (the provider sees the object regardless; the payload is + sealed under the phrase-derived KEK). +6. Scrub the recovery seed and the throwaway keyhive from memory. The + worker then flushes the us-doc and every named partition, so the + kit is valid the moment the ceremony reports success. + +Bundle payload (extending G5's `BundlePayload`): recovery seed + +verifying key, the recovery keyhive archive, the ENROLL CARD (the +static events exported for the recovery individual — belt and +suspenders against op-arrival-order wedges; the oplogs carry the same +events), the us partition id, the user group id, the granting device's +agent id (the K_p location's owner component), and the account's +storage ADDRESSING snapshot (the us-storage record's secret-free +shape) so a file restore can pre-fill the destination fields after +unlock. The bucket kind cannot use the snapshot for the fetch itself — +finding the bundle needs the destination first — so its ceremony asks +for destination and credentials, as the file kind's asks for +credentials only. + +## Restore (fresh browser, worker-driven) + +Fresh T0 device namespace; engine instantiated with NO init; the +storage ceremony binds the store (the existing bindStore path — S3 +escrow or the Drive OAuth popup); then the guest restore: derive (or +receive) the bundle → open the slot → verify seed/verifying +consistency → `try_from_archive` with the recovery signer → ingest the +bundled enroll card → adopt the us partition → K_p pull → the account +pull fan-out (pointer map × device directory) → the restored device +writes its own devices entry (the ceremony's device name — the kit's +label gives way to the user's word for the machine it became) → first +checkpoint → consume. The visor claims at the end: colour, name, icon +arrive from the pulled profile — unseal-as-login's anti-spoofing +property holds for restore too, and nothing personal renders before +the account state is genuinely in hand. + +The restored device runs SEED posture (its identity came from a +bundle; the checkpoint carries it, DEK-sealed, exactly as the existing +seed back-compat path does). Recorded honestly: that is one notch +below platform posture, and the platform-posture migration for a +restored device is PARKED with #11's rotation design. + +## Derivation, pinned + +Phrase: 10 words, uniform from the EFF short wordlist (1296 words, +CC-BY — attribution in source), ~103.4 bits; generated IN-GUEST +(single authority for format and derivation), displayed once in visor +pixels, never persisted anywhere; normalization at entry is trim + +lowercase + collapse-internal-whitespace. + +- root = argon2id(normalized phrase, salt = fixed context string + `polyvisor-recovery-v1`, the existing spike-scale params). The salt + is fixed BECAUSE the name must be derivable from the phrase alone; + the phrase's generated entropy is the security, argon2id is depth + (the brainwallet objection applies to human-chosen secrets, which + this slot never holds). +- bucket object name = `recovery/` + hex(HKDF-SHA-256(root, + info = "polyvisor recovery name v1")). +- slot secret (KEK) = HKDF-SHA-256(root, + info = "polyvisor recovery kek v1") into the existing + `BundleSlot::Secret` machinery. + +File kit: the existing `BundleSlot::Passphrase` (argon2id, random +per-file salt riding the slot), unchanged. + +## Threat model deltas (for #1) + +- The provider sees: one more member leaf's worth of CGKA ops, a K_p + object, and (bucket kind) one sealed bundle object at an + opaque name — metadata inside the declared non-goal. +- The phrase/file+passphrase is a full-account credential (read AND + write once restored). The kit device's revocability is the answer to + leakage; the devices sheet is the interface. +- A phrase-holder can also DENY: restore consumes the kit, and a + malicious restore both takes the account state and burns the kit. + Not new authority — the same holder could already read everything — + but the denial edge is recorded. +- The restore ceremony types the secret into page script: the same + exposure class as the passphrase unseal rung, priced identically + (PERSISTENCE.md's trust sentence). +- Storage rebind strands kits in the old bucket (K_p and bundle do not + migrate). RECORDED CAVEAT: the storage ceremony's copy tells the + user to re-mint kits after a destination change; no migration + machinery. + +## Parked, explicitly + +The PRF second-input slot on the bucket kit (the dual-eval seam, +PERSISTENCE.md — recorded, not built); kit auto-refresh of any kind; +account-wide kit migration on storage rebind; multiple restores as +enrollment (pairing owns that); platform-posture migration for +restored devices; escrow spectra (social/provider) — #11's original +deferral, unchanged. + +## Tracks and gates + +- **T-A (engine, first — it defines the WIT):** the recovery surface + (`recovery-kit-create` bucket/file arms, `recovery-restore` + bucket/file arms, `recovery-kit-revoke`), the throwaway-keyhive mint + + enroll + K_p grant, the payload extension, the derivation, the + consume path, the `KpPayload.devices` union fix, the us-doc + `recovery` map. Gate: a NEW `just recover` acts battery — bucket-kit + and file-kit restores against MinIO with content equality asserted; + a post-kit revocation epoch crossed before restore (the CGKA + catch-up claim made executable); wrong phrase and wrong passphrase + refused as clean slot failures; double-restore refused at the + missing K_p; consumed artifacts verified gone; `just pair`, + `just resume`, clippy stay green. +- **T-B (worker/runtime):** the restore bring-up mode (fresh + namespace, defer-init, bindStore-then-restore sequencing), the kit + RPC (phrase crossing once, scrubbed, never persisted — the + passphrase rung's discipline), consume-retry on the backoff loop, + post-restore checkpoint + schedule arming. Gate: devstore rows — + kit create/restore round-trip against the harness store; the phrase + appears NOWHERE in IndexedDB/localStorage/OPFS after the ceremony + (scan row); double-restore refusal typed; consume-failure announced + and retried. +- **T-C (solo page + visor):** the kit ceremony sheets (kind choice, + phrase display-once with confirm, file download + passphrase with + the loud-warning copy), the picker's "Restore from recovery…" path + (destination + credentials, progress, claim-at-end), kit management + in the devices/storage surface (list from the us `recovery` map, + revoke). Gate: e2e — `solo-recovery` creates a kit, captures the + phrase from the sheet, DESTROYS the browser context, restores in a + virgin context from phrase + re-entered credentials, and finds the + todos; a file-kit variant covers download/upload restore; the + existing suite stays green. diff --git a/runtime/device-store/client.ts b/runtime/device-store/client.ts index 5cb57aa..7244661 100644 --- a/runtime/device-store/client.ts +++ b/runtime/device-store/client.ts @@ -32,7 +32,7 @@ // type-only and still erase. import { fromCloneable } from "@polyengine/protocol"; -import type { Driver, Tasks } from "../engine.ts"; +import type { Driver, RecoveryKit, Tasks } from "../engine.ts"; import { adoptAnchor, setAnchor } from "./anchor.ts"; import { createDevice, @@ -52,7 +52,10 @@ import { type OauthStartResult, type OauthStartSpec, type PromoteOptions, + type RecoveryKitResult, + type RecoveryKitSpec, type ResealOptions, + type RestoreSpec, type Req, type Res, type StoreBinding, @@ -69,7 +72,10 @@ export type { OauthStartResult, OauthStartSpec, PromoteOptions, + RecoveryKitResult, + RecoveryKitSpec, ResealOptions, + RestoreSpec, StoreBinding, /** The worker's sync schedule, as `DeviceStatus.sync` carries it * (SYNC.md §3). Re-exported beside `StoreBinding` for the same reason @@ -224,6 +230,73 @@ export interface DeviceConnection { * (`eraseKeystore`), not to one device's unbind. */ unbindStore(): Promise; + /** + * OPEN A FRESH NAMESPACE FOR A RESTORE, WITHOUT INITING AN ENGINE. + * + * Only needed on the path where a ceremony has to run BETWEEN the DEK + * and the restore — the Google Drive consent, which seals tokens under + * the DEK and therefore refuses on a sealed device. The S3 path needs + * nothing here: its credential ceremony is page-side + * (`putSigningKey`) and keyed by destination origin, so `restore()` + * alone is the whole thing. + * + * Refused with `code: "bad-destination"` on a namespace that already + * holds a device: a restore is a NEW device, never an overwrite. + */ + restorePrepare(opts?: UnsealOptions): Promise; + /** + * RESTORE AN ACCOUNT FROM A RECOVERY KIT ONTO THIS FRESH DEVICE + * (RECOVERY.md, "Restore"). + * + * The worker validates the binding with the ordinary fail-at-bind + * rules, brings the engine up from the KIT instead of from `init`, + * runs the account pull fan-out (the us-doc first, then the pointer + * map × the device directory), takes a first checkpoint, and only then + * consumes the kit. + * + * WHAT IT DOES NOT DO: promote. The restored device is T0 — keeping + * the machine you restored on is a separate decision the user makes + * afterwards (PERSISTENCE.md's try-then-keep). + * + * A CONSUME FAILURE DOES NOT FAIL THIS CALL. The kit is retried on the + * flush cadence's backoff loop and reported through + * `status().sync` — `consumePending`, plus the ordinary + * `flushFailures`/`lastError` a stalled sync shows. + * + * THE SECRET CROSSES ONCE. See `RecoveryKitInput` in rpc.ts for + * exactly what that promise is worth. + * + * Refusals: `"bad-destination"` when the namespace already holds a + * device or the destination is unusable, `"no-credential"` for a + * missing escrow or consent, and the guest's own — "no recovery kit at + * this name" for a wrong (or already-used) phrase, "unlock failed" for + * a wrong file passphrase — as branded `ComponentException`s. + */ + restore(spec: RestoreSpec): Promise; + /** + * MINT A RECOVERY KIT (RECOVERY.md, "The kit ceremony"). + * + * Requires an unsealed, store-bound, account-holding device; each of + * those refusals is the GUEST's and arrives as a branded engine error. + * A bucket kit additionally refuses by name off S3 — the phrase-derived + * NAME is not a location on a provider that resolves ids through a + * folder walk, and the file kit covers those providers instead. + * + * The result is handed back ONCE: a phrase to display in visor pixels, + * or bytes to download. Neither is persisted anywhere by the device + * store, and there is no call that returns either of them again. + * + * When it resolves, the kit is VALID: the ceremony ends with the + * account document and every named partition flushed. + */ + createRecoveryKit(spec: RecoveryKitSpec): Promise; + /** The account's live kits, for the devices sheet. A projection of the + * us-doc's `recovery` map — nothing here unlocks anything. */ + recoveryKits(): Promise; + /** Revoke a kit — the same mechanic as a lost phone, because it IS the + * same thing. Resolves with the human-readable guarantee note the UI + * renders, exactly as `storeRevoke` does. */ + revokeRecoveryKit(agentId: Uint8Array): Promise; /** * BEGIN THE GOOGLE CONSENT — and note which half of it this is. * @@ -555,6 +628,14 @@ export async function connectDevice(spec: ConnectSpec): Promise send("host", "bindStore", [binding]) as Promise, unbindStore: () => send("host", "unbindStore", []) as Promise, + restorePrepare: (opts?: UnsealOptions) => + send("host", "restorePrepare", [opts ?? {}]) as Promise, + restore: (spec: RestoreSpec) => send("host", "restore", [spec]) as Promise, + createRecoveryKit: (spec: RecoveryKitSpec) => + send("host", "createRecoveryKit", [spec]) as Promise, + recoveryKits: () => send("host", "recoveryKits", []) as Promise, + revokeRecoveryKit: (agentId: Uint8Array) => + send("host", "revokeRecoveryKit", [agentId]) as Promise, oauthStart: (spec: OauthStartSpec) => send("host", "oauthStart", [spec]) as Promise, oauthComplete: (code: string, state: string) => diff --git a/runtime/device-store/rpc.ts b/runtime/device-store/rpc.ts index 2e72891..b4059f2 100644 --- a/runtime/device-store/rpc.ts +++ b/runtime/device-store/rpc.ts @@ -290,6 +290,13 @@ export const DRIVER_METHODS = [ "usDeviceRevoke", "usDeviceEndpointPut", "usEvents", + "recoveryKitCreateBucket", + "recoveryKitCreateFile", + "recoveryRestoreBucket", + "recoveryRestoreFile", + "recoveryConsume", + "recoveryKits", + "recoveryKitRevoke", "stats", ] as const; @@ -364,6 +371,12 @@ export const READONLY_METHODS: ReadonlySet = new Set([ // debounce entirely. Treated as a read, deliberately, and recorded // here rather than left to be inferred. "usEvents", + // `recoveryKits` is a pure read of the account's `recovery` map — a + // projection that unlocks nothing and changes nothing. Every OTHER + // `recovery*` method is deliberately absent: they mint or revoke a + // member device, write the us-doc and delete bucket objects, which is + // as much a mutation as anything else on this surface. + "recoveryKits", // tasks — reads "partition", "revision", @@ -601,6 +614,26 @@ export interface SyncStatus { /** The most recent background failure, as a sentence for a human, or * null when the last cycle in each direction succeeded. */ lastError: string | null; + /** + * A RESTORE'S KIT IS STILL WAITING TO BE CONSUMED (RECOVERY.md, + * "Single-use"): the restore fully succeeded and `recoveryConsume()` + * has not yet. + * + * A CONSUME FAILURE NEVER BLOCKS OR UNDOES A RESTORE — an unreachable + * bucket at the end of a restore is exactly the moment least able to + * afford a refusal — so it is retried on the flush direction's own + * backoff loop, which is why the failure ALSO shows up as + * `flushFailures` and a `lastError` sentence and reaches the + * announcement threshold like any other stalled sync. This flag is + * the one thing that count cannot say: WHAT is still outstanding, so + * a sheet can name it ("your recovery kit has not been retired yet") + * rather than reporting a generic sync failure. + * + * It is a boolean and nothing else. The kit's name, its object and + * its phrase are not derivable from it, and none of them may ever + * appear on this type. + */ + consumePending: boolean; } @@ -655,6 +688,96 @@ export interface OauthStartResult { authorizeUrl: string; } +/** + * WHICH KIT IS BEING PRESENTED, and it is the ONE PLACE a recovery + * secret crosses this wire (RECOVERY.md, "Restore"; the threat-model + * line that prices it: "the restore ceremony types the secret into page + * script — the same exposure class as the passphrase unseal rung"). + * + * IT CROSSES ONCE. The worker hands the value straight to the guest and + * drops its own reference; nothing here is persisted, logged, or echoed + * back in `status()`. That is the same promise `UnsealOptions.passphrase` + * carries and it is worth no more than that promise is: a string in a + * page's heap is a string in a page's heap, and neither side can scrub + * the other's realm. + * + * The FILE arm's `bundle` is not secret on its own — it is sealed under + * the passphrase beside it — but it is treated identically, because + * "the file plus its passphrase open the account" and the two travel + * together here. + */ +export type RecoveryKitInput = + | { kind: "bucket"; phrase: string } + | { kind: "file"; bundle: Uint8Array; passphrase: string }; + +/** + * WHAT A RESTORE NEEDS, and why it is a HOST METHOD rather than an + * `AttachSpec` variant (the track's structural decision, recorded here + * because rpc.ts is where the wire is described). + * + * The restore is a BRING-UP MODE: the engine is born from the kit + * instead of from `init`, so it cannot ride `unseal`, which inits. Two + * shapes could carry it — a variant on `AttachSpec`, or a method of its + * own — and the method wins on both counts that matter: + * + * * SECRET LIFETIME. `AttachSpec` is REMEMBERED: worker.ts keeps it in + * `attached` for the life of the global (first attach wins, and the + * artifacts are re-read from it). A phrase on that record would rest + * in worker memory long after the ceremony it belonged to. A method + * argument lives for one call. + * * WHEN IT IS VALID. Attach happens at CONNECT, before any ceremony + * — before the DEK exists, and before the Drive consent a gdrive + * restore needs. A method can be refused precisely when it is wrong + * ("this namespace already holds a device") instead of being + * accepted early and discovered late. + * + * The corollary is the two-stage shape below: `restorePrepare` opens the + * device (mints the DEK) WITHOUT initing an engine, which is what gives + * the page a window to run the Drive consent — that ceremony seals + * tokens under the DEK, so it needs one, and the S3 arm needs no such + * stage because its escrow is page-side and keyed by origin. + */ +export interface RestoreSpec { + /** Where the restored account's bucket is. Validated with `bindStore`'s + * own fail-at-bind discipline (STORAGE-EGRESS.md §4) BEFORE anything + * is fetched: a restore that discovered a missing credential halfway + * through would leave a half-born device. */ + binding: StoreBinding; + /** The kit, and the secret that opens it. See `RecoveryKitInput`. */ + kit: RecoveryKitInput; + /** The user's own word for the machine this became. The kit's label + * gives way to it in the devices sheet at the end of the restore + * (engine.wit's `recovery-restore-bucket`). */ + deviceName: string; + /** The seal choices for the fresh namespace, exactly as `unseal` + * takes them. Ignored when `restorePrepare` already opened the + * device. */ + unseal?: UnsealOptions; +} + +/** + * WHICH KIND OF KIT TO MINT (RECOVERY.md, "The kit ceremony"). + * + * `label` is the name the kit's DEVICE wears in the devices sheet — it + * is a real leaf in the delegation graph, so it is labelled like one. + * The `file` arm's `passphrase` is the user's own choice and its + * strength is the user's own; the ceremony that collects it warns + * loudly, which is the owner's amendment and the visor's job. It + * crosses this wire under `RecoveryKitInput`'s discipline. + */ +export type RecoveryKitSpec = + | { kind: "bucket"; label: string } + | { kind: "file"; label: string; passphrase: string }; + +/** + * What a kit ceremony hands back: the phrase for a bucket kit (displayed + * once in visor pixels, never persisted), the sealed bytes for a file + * kit (downloaded by the user, never persisted here). + */ +export type RecoveryKitResult = + | { kind: "bucket"; phrase: string } + | { kind: "file"; bundle: Uint8Array }; + /** Everything a picker or a strip needs to know, and nothing secret. */ export interface DeviceStatus { deviceId: string; @@ -774,6 +897,14 @@ export type HostMethod = | "status" | "bindStore" | "unbindStore" + // --- account recovery (RECOVERY.md; T-B) --- + /** Open a fresh namespace WITHOUT initing an engine — the restore + * path's first stage. See `RestoreSpec`. */ + | "restorePrepare" + | "restore" + | "createRecoveryKit" + | "recoveryKits" + | "revokeRecoveryKit" | "oauthStart" | "oauthComplete" | "forgetOauth" diff --git a/runtime/device-store/worker.ts b/runtime/device-store/worker.ts index ede71d7..c6c8b3d 100644 --- a/runtime/device-store/worker.ts +++ b/runtime/device-store/worker.ts @@ -53,6 +53,8 @@ import { type Engine, newEngine, type PersistDir, + type RecoveryKit, + type StoreConfig, type StoreSign, type UsPartition, } from "../engine.ts"; @@ -118,7 +120,11 @@ import { type OauthStartSpec, type PromoteOptions, READONLY_METHODS, + type RecoveryKitInput, + type RecoveryKitResult, + type RecoveryKitSpec, type ResealOptions, + type RestoreSpec, hostCodeOf, hostErrorOf, type Req, @@ -1197,7 +1203,7 @@ async function oauthComplete(code: string, state: string): Promise // THE SPACE THE CONSENT WAS ASKED FOR, sealed with the tokens it // bought: it is what the scope above was chosen from, so it is the // only honest record of what this consent actually permits, and - // `bindGdrive` refuses a binding that disagrees with it. + // `settleGdrive` refuses a binding that disagrees with it. space: spec.space, obtainedAt: Date.now(), }; @@ -1303,9 +1309,68 @@ async function bindStore(binding: StoreBinding): Promise { if (!dek || !engine) { throw new SealError("no-rung", "the device is sealed; open it before binding storage"); } - if (binding?.kind === "gdrive") { - return await bindGdrive(binding, dek, engine); - } + const stored = await settleBinding(binding, dek); + // A THROW FROM HERE LEAVES THE BINDING SEALED AND THE GRANT ARMED + // while the live instance still has no addressing — self-consistent + // rather than half-open (the seams refuse or the engine does, and + // nothing writes anywhere unintended), and the next bring-up repairs + // it by re-applying the same config. Rolling the seal back instead + // would throw away a binding the user correctly entered because one + // engine call failed. + await engine.driver.initStore(storeConfigOf(stored)); + // A DESTINATION EXISTS AGAIN, SO THE SCHEDULE DOES. `clearGrant` stops + // it at every unbind/reseal/erase, so a bind is the matching arm: a + // device that has just been pointed at a bucket should sync on its own + // without waiting for a reload. At the ORDINARY cadence, never the + // boot pull's — see `rearmSyncSchedule`. + rearmSyncSchedule(); + return await status(); +} + +/** + * THE `initStore` CONFIG FOR A SETTLED BINDING — one spelling, used by + * `bindStore`, by every bring-up's re-apply, and by the restore (where + * it is a PARAMETER of `recovery-restore-*` rather than a call, because + * finding the bundle needs the destination before any engine state + * exists — engine.wit's `recovery-restore-bucket`). + * + * ADDRESSING ONLY, on every arm. The gdrive arm carries no credential + * at all, not even a public identifier; the S3 arm carries the access + * key, which is a public identifier that travels in the Authorization + * header in clear. + */ +function storeConfigOf(b: StoreBinding): StoreConfig { + return b.kind === "gdrive" + ? { + kind: "gdrive", + // The space rides on the sealed binding, so every re-apply + // restores the SAME space the bind chose — a default here would + // silently move the store. + value: { root: b.root, apiBase: b.apiBase, space: b.space }, + } + : { + kind: "s3", + value: { endpoint: b.endpoint, bucket: b.bucket, accessKey: b.accessKey }, + }; +} + +/** + * VALIDATE A BINDING AND MAKE IT THIS DEVICE'S, minus the engine. + * + * The fail-at-bind half of `bindStore`, factored out because the RESTORE + * needs exactly it and nothing else: a restore has no engine to + * `initStore` (that is the point — the config is a parameter of the + * restore call), but it must run the same refusals, in the same order, + * before it fetches anything. Two copies of the destination checks would + * be two places for the escrow rules to drift. + * + * Everything fallible and cheap is checked first, and the binding is + * PERSISTED before the grant is armed — a bind that survives the answer + * but not the disk would come back unbound at the next unseal, which is + * the confusing direction. + */ +async function settleBinding(binding: StoreBinding, key: CryptoKey): Promise { + if (binding?.kind === "gdrive") return await settleGdrive(binding, key); if (binding?.kind !== "s3") { // The two arms this host binds are S3 and Google Drive. DROPBOX is // still parked for the worker and the reason is unchanged @@ -1361,30 +1426,14 @@ async function bindStore(binding: StoreBinding): Promise { bucket: binding.bucket, accessKey: binding.accessKey, }; - await sealedPut(ns, dek, STORE_BINDING_KEY, new TextEncoder().encode(JSON.stringify(stored))); + await sealedPut(ns, key, STORE_BINDING_KEY, new TextEncoder().encode(JSON.stringify(stored))); await applyBinding(stored); - // A THROW FROM HERE LEAVES THE BINDING SEALED AND THE GRANT ARMED - // while the live instance still has no addressing — self-consistent - // rather than half-open (the seams refuse or the engine does, and - // nothing writes anywhere unintended), and the next bring-up repairs - // it by re-applying the same config. Rolling the seal back instead - // would throw away a binding the user correctly entered because one - // engine call failed. - await engine.driver.initStore({ - kind: "s3", - value: { endpoint: stored.endpoint, bucket: stored.bucket, accessKey: stored.accessKey }, - }); - // A DESTINATION EXISTS AGAIN, SO THE SCHEDULE DOES. `clearGrant` stops - // it at every unbind/reseal/erase, so a bind is the matching arm: a - // device that has just been pointed at a bucket should sync on its own - // without waiting for a reload. At the ORDINARY cadence, never the - // boot pull's — see `rearmSyncSchedule`. - rearmSyncSchedule(); - return await status(); + return stored; } /** - * BIND THIS DEVICE TO A DRIVE FOLDER (DRIVE.md §5). + * VALIDATE A DRIVE BINDING AND MAKE IT THIS DEVICE'S (DRIVE.md §5) — + * the gdrive arm of `settleBinding`, and the same shape of thing. * * The refusals mirror the S3 arm's one-for-one, because they are the * same rule wearing this provider's vocabulary: everything that can be @@ -1406,11 +1455,10 @@ async function bindStore(binding: StoreBinding): Promise { * other space is a consent to a different permission * entirely. */ -async function bindGdrive( +async function settleGdrive( binding: Extract, key: CryptoKey, - live: Engine, -): Promise { +): Promise { if (binding.root.trim() === "" || binding.clientId.trim() === "") { throw new StoreError("bad-destination", "a Drive binding needs a root folder and a client id"); } @@ -1459,17 +1507,7 @@ async function bindGdrive( }; await sealedPut(ns, key, STORE_BINDING_KEY, new TextEncoder().encode(JSON.stringify(stored))); await applyBinding(stored); - // Addressing only, exactly like every other arm (DRIVE.md §2): the - // guest gets no credential here, not even a public identifier. The - // space is addressing too — it picks the root parent the strategy - // creates under, and nothing else. - await live.driver.initStore({ - kind: "gdrive", - value: { root: stored.root, apiBase: stored.apiBase, space: stored.space }, - }); - // The S3 arm's reason verbatim. - rearmSyncSchedule(); - return await status(); + return stored; } /** @@ -1640,8 +1678,16 @@ function deviceIdentityFragment(): DeviceIdentityFragment { * THE RESUME IDIOM IS THE ENGINE'S, verbatim (engine.ts:100-113): call * `stateResume()` FIRST and only `init` when it answers `false`. `false` * is "nothing to resume" and is the fresh-boot path, never an error. + * + * WITH A `restore` PLAN THERE IS NEITHER (RECOVERY.md, "Restore"): the + * engine is born from the kit, so `stateResume()`'s answer becomes a + * guard and `init` never runs. Everything AFTER the guest's restore — + * the pull fan-out, the first checkpoint, the consume — belongs to + * `restore()` below rather than here, because none of it is bring-up: + * it is the ceremony's own tail, and it needs the published engine and + * the schedule that this function's callers arm. */ -async function bringUpEngine(): Promise { +async function bringUpEngine(restore?: RestorePlan): Promise { if (engine) return; requireJspi(); if (!attached) throw new Error("device-store: the host was never attached (no engine artifacts)"); @@ -1687,6 +1733,53 @@ async function bringUpEngine(): Promise { ); resumed = await e.driver.stateResume(); + if (restore) { + // THE RESTORE BRING-UP (RECOVERY.md, "Restore"): the engine is born + // from the KIT, so there is neither a resume nor an `init` here. + // + // `stateResume()` still ran, and its answer is a GUARD rather than a + // step: a namespace with something to resume is not a fresh device, + // and restoring over it would strand whatever it held behind an + // identity that no longer matches the manifest. `restore()`'s + // pre-checks refuse that case before we ever get here; this is the + // last line of it, checked against the engine's own answer rather + // than against our bookkeeping. + if (resumed) { + throw new StoreError( + "bad-destination", + "this namespace already holds a device: a restore needs a fresh one", + ); + } + // THE CONFIG IS A PARAMETER, NOT `initStore` STATE, and the ordering + // is the reason (engine.wit's `recovery-restore-bucket`): finding + // the bundle needs the destination FIRST, and the destination cannot + // be read out of the account document the bundle is what unlocks. So + // the guest fetches through config-parameterized helpers before any + // engine state exists and applies the same config as `init-store` + // would once it does — which is why the `if (binding)` re-apply + // below is skipped on this path rather than merely redundant. + const agent = restore.kit.kind === "bucket" + ? await e.driver.recoveryRestoreBucket( + storeConfigOf(restore.binding), + restore.kit.phrase, + restore.deviceName, + ) + : await e.driver.recoveryRestoreFile( + storeConfigOf(restore.binding), + restore.kit.bundle, + restore.kit.passphrase, + restore.deviceName, + ); + // The fresh-init path's write, for the fresh-init path's reason: an + // agent id is a public key, the sweep and the picker read it before + // anything is open, and the SYNC SCHEDULER needs it in order to tell + // this device apart from its siblings in the account directory + // (`pullCycle`'s self-filter). A restored device that never recorded + // it would fan out pulls against ITSELF. + await ns.put("meta", AGENT_KEY, agent); + engine = e; + return; + } if (!resumed) { // The bringup `solo` shape (demo/host/bringup.ts:57-64): a fresh // device needs an identity and a partition before `tasks` has @@ -1729,24 +1822,7 @@ async function bringUpEngine(): Promise { // its address. A device therefore returns to its bucket on every // unseal with no page-side state and nothing re-entered (§3). if (binding) { - await e.driver.initStore( - binding.kind === "gdrive" - ? { - kind: "gdrive", - // The space rides on the sealed binding, so the re-apply - // restores the SAME space the bind chose — a bring-up that - // defaulted here would silently move the store. - value: { root: binding.root, apiBase: binding.apiBase, space: binding.space }, - } - : { - kind: "s3", - value: { - endpoint: binding.endpoint, - bucket: binding.bucket, - accessKey: binding.accessKey, - }, - }, - ); + await e.driver.initStore(storeConfigOf(binding)); } engine = e; @@ -1760,6 +1836,387 @@ async function bringUpEngine(): Promise { } } +// --- the restore bring-up (RECOVERY.md, "Restore") -------------------------- +// +// A FRESH DEVICE NAMESPACE WHOSE ENGINE IS BORN FROM A KIT. The whole of +// what makes it different from an ordinary bring-up is stated in +// engine.wit's `recovery-restore-bucket` doc comment and in RECOVERY.md; +// what lives here is the ORDER, and the order is the design: +// +// 1. THE BINDING, settled with `bindStore`'s own fail-at-bind +// discipline (`settleBinding` — shared code, not a second copy). +// Everything knowable is settled before a single byte is fetched. +// 2. THE ENGINE, wired to the seams over the just-armed grant, with NO +// `stateResume` and NO `init` — `bringUpEngine`'s restore arm. +// 3. THE GUEST'S RESTORE, which fetches (or is handed) the bundle, +// adopts the us partition, takes the K_p pickup and writes this +// device's own entry under the ceremony's name. +// 4. THE PULL FAN-OUT, US-DOC FIRST. The guest's own pull bootstrapped +// the account; this is the ordinary account pull path (SYNC.md §2), +// run once eagerly so the restored device has CONTENT and not only +// membership. It is the worker's existing machinery on purpose — +// RECOVERY.md keeps the content fan-out out of the guest so there +// is one account pull path rather than two. +// 5. THE FIRST CHECKPOINT, so a device that is restored and then +// reloaded before anyone touches it comes back as itself. +// 6. THE CONSUME, LAST AND NEVER FATAL. engine.wit: "called by the +// embedder AFTER the content fan-out and the first checkpoint +// succeed — not before: a consume that raced the restore would burn +// the kit for a restore that had not landed." +// +// THE RESTORED DEVICE IS T0. Promotion is the user's own later act +// (PERSISTENCE.md's try-then-keep), so nothing here touches the index +// row's tier: a restore is not a decision to keep the machine it ran on. + +interface RestorePlan { + binding: StoreBinding; + kit: RecoveryKitInput; + deviceName: string; +} + +/** + * RUN ONE CEREMONY WITH THE STORE TO ITSELF. + * + * `call()` does this for every client-initiated bucket op it dispatches + * (see `clientBucketOps`), but the recovery ceremonies arrive on the + * HOST surface, which never passes through it — so they hold the same + * claim explicitly. Without it a 45-second pull cycle could land in the + * middle of a restore and pull against an engine that is still being + * born. The counter is released in `finally`, because a REFUSED + * ceremony must not leave the scheduler muted for the life of the + * worker. + */ +async function holdingStore(body: () => Promise): Promise { + clientBucketOps++; + try { + return await body(); + } finally { + clientBucketOps--; + } +} + +/** + * OPEN A FRESH NAMESPACE WITHOUT INITING AN ENGINE — the restore path's + * first stage, and the one thing `unseal` cannot do (it inits). + * + * IT EXISTS FOR THE DRIVE CONSENT. `oauthStart`/`oauthComplete` seal + * tokens under the DEK, so they refuse on a sealed device — and a gdrive + * restore needs its consent BEFORE the binding it is about to validate. + * The S3 arm needs no such stage: its escrow is page-side and keyed by + * destination origin, so `restore()` alone is the whole ceremony there. + * + * IDEMPOTENT, and refuses a namespace that already holds a device: this + * is a door into a device that has not been born yet, and it must never + * become a second way to open one that has. + */ +async function restorePrepare(opts: UnsealOptions = {}): Promise { + if (engine) { + throw new StoreError( + "bad-destination", + "this device is already running: a restore needs a fresh namespace", + ); + } + await refuseUnlessFresh(); + if (!dek) { + const record = await getDevice(DEVICE_ID); + if (!record) { + throw new SealError("no-rung", `device-store: no device ${DEVICE_ID} in the index`); + } + const rungs = await sealState(ns); + // A namespace with rungs has been sealed before, which means a DEK + // was minted for it — and `refuseUnlessFresh` has already established + // that no ENGINE state rests under it. Climbing rather than minting + // a second one is `unseal`'s rule and its reason (a second DEK + // silently orphans everything sealed under the first). + dek = (!rungs.passphrase && !rungs.untilReseal && !rungs.prf) + ? await firstSeal(record.tier, opts) + : await climbRung(record.unsealPolicy, rungs, opts); + } + return await status(); +} + +/** + * "A RESTORE NEEDS A FRESH NAMESPACE", checked rather than assumed. + * + * The agent id in unsealed `meta` is the honest witness: it is written + * exactly once, on the fresh-init path and on the restore path, and it + * is readable WITHOUT the DEK — so this refusal works on a device + * nobody has opened yet, which is precisely when a client would be + * about to make the mistake. + */ +async function refuseUnlessFresh(): Promise { + const agent = await ns.get("meta", AGENT_KEY); + if (agent) { + throw new StoreError( + "bad-destination", + "this namespace already holds a device — restore into a fresh one " + + "(a restore is a new device, never an overwrite)", + ); + } +} + +/** + * RESTORE THIS DEVICE FROM A RECOVERY KIT. + * + * The secret discipline, stated where it is implemented: `spec.kit` + * carries the phrase (or the file's passphrase), it is handed to the + * guest, and the local references are dropped in `finally`. Nothing + * writes it to the namespace, the checkpoint, the bucket or a log, and + * `status()` has nowhere to echo it. HONESTLY BEST-EFFORT: dropping a + * reference is not scrubbing a heap — the string was cloned across the + * port and neither realm can erase the other's copy — but it is the + * same promise `UnsealOptions.passphrase` makes and it is kept the same + * way. + */ +async function restore(spec: RestoreSpec): Promise { + return await holdingStore(() => restoreCeremony(spec)); +} + +async function restoreCeremony(spec: RestoreSpec): Promise { + if (engine) { + throw new StoreError( + "bad-destination", + "this device is already running: a restore needs a fresh namespace", + ); + } + await refuseUnlessFresh(); + if (!dek) await restorePrepare(spec.unseal ?? {}); + const key = dek; + if (!key) throw new SealError("no-rung", "the device is sealed; there is nothing to restore into"); + const kit = spec.kit; + try { + // 1. THE DESTINATION, on `bindStore`'s terms and before anything is + // fetched. A missing escrow or a mismatched access key is a + // refusal HERE rather than a provider 403 in the middle of a + // ceremony that has already minted half a device. + const binding = await settleBinding(spec.binding, key); + // 2-3. The engine, and the guest's restore inside it. + try { + await bringUpEngine({ binding, kit, deviceName: spec.deviceName }); + } catch (e) { + // `unseal`'s atomic rollback, for `unseal`'s reason: a half-open + // device — key held, no engine, `status()` claiming unsealed — is + // the state this whole discipline exists to forbid. The binding + // stays sealed in the namespace (the user entered it correctly and + // a retry should not re-ask), but the grant goes: armed seams with + // no engine are authority with nothing to authorize. + dek = null; + engine = null; + resumed = null; + clearGrant(); + throw e; + } + const live = engine as unknown as Engine; + // 4. THE CONTENT FAN-OUT, us-doc first. Failures are TOLERATED and + // left to the schedule: a sibling that has not flushed, or a + // partition whose objects are not there yet, is absence — and a + // restore that refused over it would throw away an account it has + // already successfully rebuilt. The ordinary cycle retries. + await restoreFanOut(live); + // 5. The first checkpoint. This one is NOT tolerated: without it a + // reload before the debounce fires would find a namespace with an + // agent id and no state, which is the one shape nothing recovers + // from. + await checkpoint(); + // 6. THE CONSUME, and its failure is an announcement rather than a + // refusal — see `settleConsume`. + await settleConsume(live); + // The schedule starts at the END, `unseal`'s placement and for + // `unseal`'s reason: the engine is published and `status()` is + // answerable, and this only arms timers. + startSyncSchedule(); + return await status(); + } finally { + // The secret's last local reference. See this function's header for + // what that is and is not worth. + if (kit.kind === "bucket") kit.phrase = ""; + else { + kit.passphrase = ""; + kit.bundle = new Uint8Array(0); + } + } +} + +/** + * THE RESTORED DEVICE'S FIRST PULL — the account pull path (SYNC.md §2) + * run once, eagerly, instead of waiting out a cadence. + * + * US-DOC FIRST AND THEN THE POINTER MAP, in that order and re-read + * between: the us-doc's content IS the pointer map, so a fan-out that + * read the map first would fan out over whatever the guest's own + * bootstrap pull happened to leave and miss every partition a sibling + * added since. + * + * IT ADOPTS BEFORE IT PULLS, and only here. `adoptPartition` REPLACES + * whatever this device held for that id with an empty document + * (engine/guest/src/lib.rs, and solo.ts:3100's contract note), so it is + * only ever safe on a device that demonstrably held nothing — which is + * the definition of the device this function runs on, and is why this + * lives in the restore rather than in the ordinary `pullCycle`. + */ +async function restoreFanOut(live: Engine): Promise { + await pullUsDoc(live, await siblingsOf(live)); + const parts = await syncScope(live); + if (parts === null) return; + const siblings = await siblingsOf(live); + for (const part of parts) { + try { + await live.driver.adoptPartition(part.id); + } catch { + // A partition this device cannot adopt is one it is not a member + // of, or one the guest already holds. Neither is a reason to stop + // the ones after it. + continue; + } + for (const sib of siblings) { + // Owner tier between two devices of one account, so no pickup — + // `pullCycle`'s argument verbatim. + await live.driver.bucketPull(part.id, sib.agentId, undefined).catch(() => {}); + } + } +} + +/** + * CONSUME THE KIT, AND NEVER FAIL THE RESTORE OVER IT (RECOVERY.md: + * "consume failures … never block the restore: they announce and retry + * on the flush cadence's backoff loop"). + * + * The announcement is the SCHEDULER'S OWN SURFACE rather than a new one: + * the failure counts as a flush-direction failure, so it escalates + * toward the announce-after-three threshold, leaves its sentence in + * `lastError`, and is retried on the same jittered backoff. The one + * thing that count cannot say — WHAT is outstanding — is + * `SyncStatus.consumePending`. + */ +async function settleConsume(live: Engine): Promise { + try { + await consumeAndCheckpoint(live); + } catch (e) { + consumePending = true; + const delay = noteSyncOutcome("flush", e); + armFlush(delay, false); + } +} + +/** + * CONSUME, THEN CHECKPOINT — and the checkpoint is not bookkeeping, it + * is what keeps the consume from being UNDONE by the next respawn. + * + * THE STRAND HAZARD, in full, because it cost a track to find. The + * consume's last act inside the guest is `recovery_clear` — a write to + * the LIVE us-doc — followed by the guest's own `bucket_flush(us)`. + * Both landed; neither survives a worker death, and here is why each + * half fails to save the other: + * + * * THE CHECKPOINT NEVER ARMS ITSELF. The debounce hooks live in + * `call()`, which is the dispatcher for CLIENT requests only. Every + * driver call this file makes internally — the fan-out, the flush + * cycles, this consume — goes straight to `engine.driver` and arms + * nothing. So a mutation made after a sequence's last checkpoint is + * simply not in any checkpoint, and a respawn resumes the state as + * it was BEFORE the consume: the spent kit back in the account's + * registry, on the one device most likely to be looking at it. + * * THE BUCKET COPY IS OUT OF ITS OWN REACH. The clear WAS flushed — + * under THIS device's own keyed object names — and `pullCycle` + * self-filters the device out of its own sibling fan-out (a device + * does not pull from itself). So the flushed clear is durable and + * permanently invisible to every future resume of its author. It + * heals only when some OTHER device pulls it and re-manifests it, + * which is exactly what the account that just used its last-resort + * kit does not have. + * + * The record's checkpoint-BEFORE-consume ordering stays as it is: a + * crash between the consume and a FIRST checkpoint would burn the kit + * with nothing durable to show for it, which is a lockout. So this is a + * SECOND checkpoint, after the fact, and the first one is untouched. + * + * `consumePending` IS CLEARED LAST, after the checkpoint has landed. + * A checkpoint that fails leaves the obligation standing and the retry + * runs the whole thing again — which is safe precisely because + * `recovery-consume` is idempotent by contract (absence is success), so + * a second pass over an already-consumed kit succeeds and reaches the + * checkpoint that failed the first time. + */ +async function consumeAndCheckpoint(live: Engine): Promise { + await live.driver.recoveryConsume(); + // Through `checkpoint()`, never `stateCheckpoint()` directly: that is + // the file's serialization point for checkpoints (they queue against + // each other on `checkpointChain` and against nothing else), and it is + // what keeps `lastCheckpoint` honest in `status()`. + await checkpoint(); + consumePending = false; +} + +/** + * MINT A RECOVERY KIT (RECOVERY.md, "The kit ceremony"). + * + * The guest owns every refusal that matters — no bound store, no + * account, a bucket kit on a provider that cannot address objects by + * name — and they arrive as ordinary engine errors through the typed + * failure path. Nothing here second-guesses them. + * + * THE FAN-OUT IS STEP 6 AND IT IS PART OF THE CEREMONY, not a + * background nicety: "the worker then flushes the us-doc and every named + * partition, so the kit is valid the moment the ceremony reports + * success". The guest flushes the us-doc itself (`publish_account`); the + * PARTITIONS are ours, because a kit whose account names a partition the + * bucket has never seen restores an account with no content. + * + * A FAILED FAN-OUT DOES NOT UNMAKE THE KIT — the device is enrolled and + * the phrase is already minted, so refusing here would hand back nothing + * for a kit that exists. It leaves the ordinary flush schedule armed and + * the failure visible where every other flush failure is. + */ +async function createRecoveryKit(spec: RecoveryKitSpec): Promise { + const out = await holdingStore(() => kitCeremony(spec)); + // THE FAN-OUT IS OUTSIDE THE HOLD, and it has to be: `syncFlushNow` + // runs the scheduler's OWN cycle, and `syncMayRun` refuses to run one + // while a client bucket op is outstanding. Holding the store across + // it would silently turn ceremony step 6 into a no-op — which is the + // "kit that looks valid and is not" the step exists to prevent. + await syncFlushNow(); + // AND A CHECKPOINT, for `consumeAndCheckpoint`'s reason exactly. This + // ceremony reaches the engine from the HOST surface (`callHost`), not + // through `call()`, so nothing here arms the mutation debounce — and + // what it just wrote is a minted device, an epoch rotation, a K_p + // grant and the account's `recovery` row. All of it went to the bucket + // under THIS device's own keyed names, which the pull fan-out + // self-filters, so a respawn before some unrelated client mutation + // happened to arm a checkpoint would resume an account that has never + // heard of the kit whose phrase the user has just written down. + // + // SWALLOWED, and the swallow is the same ruling the fan-out above + // takes: the kit EXISTS and the phrase is minted and returned once, so + // rejecting here would hand back nothing for a kit that is real, and + // a caller retrying would mint a second one. A failed local checkpoint + // is a device in trouble for other reasons, and the next mutation's + // debounce catches up. + await checkpoint().catch(() => {}); + return out; +} + +async function kitCeremony(spec: RecoveryKitSpec): Promise { + if (!engine) { + throw new SealError("no-rung", "the device is sealed; open it before creating a recovery kit"); + } + const live = engine; + let out: RecoveryKitResult; + if (spec.kind === "bucket") { + out = { kind: "bucket", phrase: await live.driver.recoveryKitCreateBucket(spec.label) }; + } else { + try { + out = { + kind: "file", + bundle: await live.driver.recoveryKitCreateFile(spec.label, spec.passphrase), + }; + } finally { + spec.passphrase = ""; + } + } + return out; +} + + // --- the checkpoint cadence ------------------------------------------------- // // THREE TRIGGERS, because no one of them is sufficient (PERSISTENCE.md, @@ -1850,7 +2307,24 @@ function scheduleCheckpoint(): void { // requests, or decides a pull is unnecessary — if that logic ever grows // a bug, this file is not where it lives. // +// THE CYCLES MUTATE CHECKPOINTED STATE AND ARM NO CHECKPOINT, and that +// is recorded rather than fixed. A flush or a pull writes the guest's +// per-doc bucket state (#93: the name-key chain and the flushed-chunk +// map are in the checkpoint), and these calls are INTERNAL — `call()`'s +// debounce hooks are for client requests only — so a cycle's work is not +// checkpointed until some unrelated mutation happens to arm one. It is +// the same shape as the hazard `consumeAndCheckpoint` exists for, and it +// is left alone because the consequence is not the same: this state +// SELF-HEALS. The chain is re-read from the account document +// (`ensure_bucket_state`'s case 1, since SYNC.md §1 made the us-doc its +// source of truth) and the flushed-chunk map is repopulated from the +// manifests the next pull reads, so the cost of losing it is at most one +// duplicate upload, never a fact that cannot be recovered. Checkpointing +// per cycle instead would put a disk write on every idle 45 s tick of +// every bound device, which is a real price for a self-healing map. +// // BACKOFF IS PER DIRECTION AND UNTRIAGED. Any failed background cycle + // backs the direction off (truncated exponential, base 5 s, factor 2, // cap 10 min, jittered), because "transient-vs-permanent triage is not // worth string-matching error text for a background loop" — Google's @@ -1876,6 +2350,34 @@ let pullFailures = 0; let lastFlush: number | null = null; let lastPull: number | null = null; let lastSyncError: string | null = null; +/** + * A RESTORED DEVICE'S KIT IS STILL WAITING TO BE RETIRED — see + * `settleConsume`. Cleared by the first `recoveryConsume()` that + * succeeds, from wherever it is attempted; absence is success by + * contract, so this never becomes permanently stuck on a kit somebody + * else already revoked. + */ +let consumePending = false; + +/** + * THE US-DOC, AS THE BUCKET SURFACE NAMES IT: an EMPTY doc-id + * (engine.wit's `bucket-flush`/`bucket-pull`; RECOVERY.md, "The us-doc + * through the bucket, unparked"). + * + * SYNC.md §3 scoped the cycle to the pointer map and parked the us-doc; + * this is the unparking. The account document has to BE in the bucket + * because a cold restore reads the account out of it — and the engine + * flushes it only at the moments the engine controls (kit create, + * revoke, consume), so a restore can otherwise be only as fresh as the + * last of those. + * + * THE SPELLING IS `new Uint8Array(0)`: `list` lowers to a typed + * array through this adapter, so the empty list is an empty typed array + * — not `undefined`, and not an omitted argument. One frozen instance + * because it is read-only by every caller and minting one per cycle + * would be noise. + */ +const US_DOC: Uint8Array = new Uint8Array(0); let flushTimer: number | undefined; let pullTimer: number | undefined; @@ -1924,6 +2426,19 @@ const CLIENT_BUCKET_METHODS: ReadonlySet = new Set([ "bucketFlush", "bucketPull", "initStore", + // THE RECOVERY CEREMONIES ARE CLIENT BUCKET OPS TOO, and for the + // reason above rather than by analogy: each of them writes or deletes + // objects (the bundle, the K_p) and flushes the account document, and + // a background cycle landing in the middle of one would race a + // ceremony the user is watching — and would make a gate row's + // assertion about "what one ceremony wrote" false. `recoveryKits` is + // absent because it reads a document and touches no store. + "recoveryKitCreateBucket", + "recoveryKitCreateFile", + "recoveryRestoreBucket", + "recoveryRestoreFile", + "recoveryConsume", + "recoveryKitRevoke", ]); let clientBucketOps = 0; @@ -1994,7 +2509,7 @@ async function syncMayRun(): Promise { /** * THE PARTITIONS A CYCLE COVERS: the ACCOUNT POINTER MAP, re-read every - * cycle (SYNC.md §3, "Scope"). + * cycle (SYNC.md §3, "Scope"), or NULL for "this device has no account". * * Re-read rather than cached because a partition added on another device * arrives through the account's own sync, and a cached list would keep @@ -2010,15 +2525,76 @@ async function syncMayRun(): Promise { * broken sync at a device that was never asked to sync anything. Read * failures of the map are therefore ABSENCE, not error; failures of the * flush/pull calls themselves are what the backoff is about. + * + * NULL AND `[]` ARE NOW DIFFERENT ANSWERS, and the us-doc is why. An + * account with an empty pointer map still HAS an account document to + * flush and pull (RECOVERY.md's unparking), so "no partitions" can no + * longer stand in for "nothing to do". The refusal above is the honest + * test for the account's existence — it is the same call, asked for its + * other meaning. */ -async function syncScope(live: Engine): Promise { +async function syncScope(live: Engine): Promise { try { return await live.driver.usPartitions(); + } catch { + return null; + } +} + +/** + * THIS ACCOUNT'S OTHER DEVICES — the pull fan-out's other axis + * (`pullCycle`'s header has the argument for why a fan-out is what + * "pull whatever my other devices wrote" means). + * + * Revoked entries are dropped, and so is this device itself, by the + * agent id `meta` recorded at init or restore. NO DIRECTORY IS ABSENCE: + * an account-less device's ordinary state is an empty sibling list, not + * a failed cycle. + */ +async function siblingsOf(live: Engine): Promise<{ agentId: Uint8Array }[]> { + const self = (await ns.get("meta", AGENT_KEY)) ?? null; + try { + return (await live.driver.usDevicesList()) + .filter((d) => !d.revoked && (self === null || hexOf(d.agentId) !== self)); } catch { return []; } } +/** + * PULL THE ACCOUNT DOCUMENT from every sibling, absence-tolerant. + * + * IT GOES FIRST IN EVERY CYCLE, and the ordering is the whole point: + * the us-doc's content IS the pointer map and the device directory, so + * the content pulls that follow chain off what this one brought in. A + * cycle that read the map first would fan out over yesterday's set. + * + * A SIBLING THAT HAS NEVER FLUSHED THE US-DOC IS ABSENCE, NEVER AN + * ERROR — it is the ordinary state of a device that was enrolled and has + * not synced yet, and it must not cost the siblings that would have + * worked. So the per-pair outcome is counted (for `pullCycle`'s + * every-pair-failed rule) and never thrown. + */ +async function pullUsDoc( + live: Engine, + siblings: { agentId: Uint8Array }[], +): Promise<{ attempted: number; succeeded: number; failure: unknown | null }> { + let attempted = 0; + let succeeded = 0; + let failure: unknown | null = null; + for (const sib of siblings) { + if (engine !== live || dek === null || destroyed) break; + attempted++; + try { + await live.driver.bucketPull(US_DOC, sib.agentId, undefined); + succeeded++; + } catch (e) { + failure ??= e; + } + } + return { attempted, succeeded, failure }; +} + /** Record a cycle's outcome and hand back the delay the direction's next * tick should use. */ function noteSyncOutcome(direction: "flush" | "pull", failure: unknown | null): number { @@ -2042,7 +2618,8 @@ function noteSyncOutcome(direction: "flush" | "pull", failure: unknown | null): } /** - * ONE FLUSH CYCLE: every partition in the pointer map, in order. + * ONE FLUSH CYCLE: the ACCOUNT DOCUMENT first, then every partition in + * the pointer map, in order. * * A partition that fails does NOT stop the ones after it — a doc whose * objects a provider is refusing is no reason to leave the others @@ -2050,6 +2627,19 @@ function noteSyncOutcome(direction: "flush" | "pull", failure: unknown | null): * failed, which is the literal reading of SYNC.md §3's "ANY failed * background flush". The first failure is the one whose sentence is * kept, because it is the one with the least other noise in front of it. + * + * THE US-DOC RIDES THE SAME DEBOUNCE (RECOVERY.md's unparking). It is + * armed by the same mutation hook as everything else, which is correct + * without a special case: `usProfileSet`, `usMarkPut`, `usPartitionPut`, + * `usDeviceEndpointPut` and the rest are all NON-readonly methods, so a + * write to the account document already schedules a flush through + * `call()`. It goes FIRST so a cycle that dies halfway has published the + * account state that names everything else. + * + * AND THE OUTSTANDING CONSUME, at the head of the cycle. A restore whose + * kit could not be retired retries here — this is the "flush cadence's + * backoff loop" engine.wit's `recovery-consume` names, and the retry is + * safe because absence is success by contract. */ async function flushCycle(): Promise { const live = await syncMayRun(); @@ -2057,9 +2647,34 @@ async function flushCycle(): Promise { armFlush(FLUSH_DEBOUNCE_MS, false); return; } - const parts = await syncScope(live); - if (parts.length === 0) return; // nothing to do; the next mutation re-arms let failure: unknown | null = null; + if (consumePending) { + try { + // AND THE CHECKPOINT THAT MAKES IT STICK — the retry path needs it + // exactly as much as the restore's own does, and for the same + // reason: this call is internal, so nothing here arms the + // debounce, and a consume that outlives its checkpoint is undone + // by the next respawn while its bucket copy stays self-filtered + // out of its own reach. See `consumeAndCheckpoint`. + await consumeAndCheckpoint(live); + } catch (e) { + failure ??= e; + } + } + const parts = await syncScope(live); + if (parts === null) { + // No account: no us-doc to flush and no map to walk. The next + // mutation re-arms — except that an outstanding consume has to keep + // being retried, and a device with a kit to retire always has an + // account, so this branch cannot strand one. + if (failure !== null) armFlush(noteSyncOutcome("flush", failure), true); + return; + } + try { + await live.driver.bucketFlush(US_DOC); + } catch (e) { + failure ??= e; + } for (const part of parts) { if (engine !== live || dek === null || destroyed) break; try { @@ -2076,8 +2691,14 @@ async function flushCycle(): Promise { } /** - * ONE PULL CYCLE: every partition in the pointer map, from every SIBLING - * DEVICE of this account that is not this one. + * ONE PULL CYCLE: the ACCOUNT DOCUMENT and then every partition in the + * pointer map, from every SIBLING DEVICE of this account that is not + * this one. + * + * THE US-DOC IS PULLED FIRST AND THE MAP IS RE-READ AFTER IT + * (RECOVERY.md's unparking of "the us-doc through the bucket"): the + * account document carries the pointer map and the device directory, so + * it is what the content pulls chain off. See `pullUsDoc`. * * WHY A FAN-OUT AT ALL. `bucketPull(docId, ownerId, pickup)` names the * device whose keyed namespace is being read (the bringup's cold pull @@ -2111,25 +2732,22 @@ async function pullCycle(): Promise { armPull(PULL_INTERVAL_MS); return; } - const parts = await syncScope(live); - const self = (await ns.get("meta", AGENT_KEY)) ?? null; - let siblings: { agentId: Uint8Array }[] = []; - try { - siblings = (await live.driver.usDevicesList()) - .filter((d) => !d.revoked && (self === null || hexOf(d.agentId) !== self)); - } catch { - // No account directory is the account-less device's ordinary state, - // and it is ABSENCE for `syncScope`'s reason: nothing to pull from. - siblings = []; - } - if (parts.length === 0 || siblings.length === 0) { + const siblings = await siblingsOf(live); + if (siblings.length === 0) { armPull(PULL_INTERVAL_MS); return; } - let attempted = 0; - let succeeded = 0; - let failure: unknown | null = null; - for (const part of parts) { + // THE ACCOUNT DOCUMENT FIRST, and then the map it just updated — see + // `pullUsDoc`. Reading the scope AFTER this pull rather than before is + // the whole reason the ordering is specified: a partition a sibling + // published a minute ago is in the map this pull brought in, and a + // cycle that read the map first would not fetch it until the next one. + const us = await pullUsDoc(live, siblings); + let attempted = us.attempted; + let succeeded = us.succeeded; + let failure: unknown | null = us.failure; + const parts = await syncScope(live); + for (const part of parts ?? []) { for (const sib of siblings) { if (engine !== live || dek === null || destroyed) break; attempted++; @@ -2258,6 +2876,13 @@ function rearmSyncSchedule(): void { * with a destination; a device that has just been unbound, resealed or * re-pointed is not in that conversation any more, so carrying a failure * count across would announce an old bucket's outage against a new one. + * + * `consumePending` DOES NOT GO WITH THEM, deliberately: it is not a + * fact about a conversation but an OBLIGATION this device took on when + * it restored — the kit is still live in the account until something + * retires it. A reseal does not retire it, so the flag survives to be + * retried at the next cycle. (An ERASE does end it, by ending the + * global that holds it.) */ function stopSyncSchedule(): void { if (flushTimer !== undefined) { @@ -2294,7 +2919,14 @@ function syncFlushNow(): Promise { * cannot-know/has-no-opinion split rpc.ts documents. */ function syncStatusOf(binding: StoreBinding | null): SyncStatus | null { if (dek === null || binding === null) return null; - return { lastFlush, lastPull, flushFailures, pullFailures, lastError: lastSyncError }; + return { + lastFlush, + lastPull, + flushFailures, + pullFailures, + lastError: lastSyncError, + consumePending, + }; } // --- status ----------------------------------------------------------------- @@ -2407,6 +3039,49 @@ async function callHost(method: string, args: unknown[]): Promise { return await bindStore(args[0] as StoreBinding); case "unbindStore": return await unbindStore(); + // --- account recovery (RECOVERY.md; the ordering lives in + // --- `restore` and the reason it is a method in rpc.ts's + // --- `RestoreSpec`). + case "restorePrepare": + return await restorePrepare((args[0] as UnsealOptions) ?? {}); + case "restore": + return await restore(args[0] as RestoreSpec); + case "createRecoveryKit": + return await createRecoveryKit(args[0] as RecoveryKitSpec); + case "recoveryKits": { + // A pure read, but it goes through the host surface beside its two + // siblings so a sheet has ONE place to reach for kit management + // rather than one method on the host and one on the proxied + // driver. The refusals are the guest's own. + if (!engine) { + throw new SealError("no-rung", "the device is sealed; open it before listing kits"); + } + return await engine.driver.recoveryKits(); + } + case "revokeRecoveryKit": { + // The guest does the whole revocation — membership, the K_p, the + // epoch rotation, the bundle object, the record — and flushes the + // account document itself, so there is nothing to arrange here + // beyond handing back the guarantee note the UI renders. The + // partition flush the rotation implies rides the ordinary + // mutation-armed cadence. + if (!engine) { + throw new SealError("no-rung", "the device is sealed; open it before revoking a kit"); + } + const note = await engine.driver.recoveryKitRevoke(args[0] as Uint8Array); + // AND A CHECKPOINT, the third instance of `consumeAndCheckpoint`'s + // hazard: a HOST-surface call arms no debounce, and everything + // this one wrote — the revoked membership, the new name-key epoch, + // the cleared registry row — would be resurrected by a respawn, + // with the bucket's copy self-filtered out of this device's own + // reach. A resurrected REVOCATION is the worst of the three: the + // device would go on believing a kit it has already destroyed is + // live. Swallowed for the kit ceremony's reason — the revocation + // has already happened at the provider, so reporting a failure + // here would be reporting a revoke that did not occur. + await checkpoint().catch(() => {}); + return note; + } case "oauthStart": return await oauthStart(args[0] as OauthStartSpec); case "oauthComplete": diff --git a/runtime/engine.ts b/runtime/engine.ts index e7c9b7c..db7a9a9 100644 --- a/runtime/engine.ts +++ b/runtime/engine.ts @@ -96,8 +96,17 @@ export interface Driver { storeGrant(docId: Uint8Array, memberId: Uint8Array): Promise; /** Human-readable guarantee note (cooperative vs. server-side hard). */ storeRevoke(docId: Uint8Array, memberId: Uint8Array): Promise; + /** AN EMPTY `docId` NAMES THE ACCOUNT'S USER-SYSTEM DOCUMENT + * (engine.wit's `bucket-flush`, the RECOVERY.md sentinel — the us id + * itself stays hidden, and an empty id was meaningless on every arm + * before this, which is what makes giving it a meaning additive). On a + * device with no account it REFUSES BY NAME rather than answering an + * empty success. Spelled `new Uint8Array(0)` through this adapter — + * `list` lowers to `Uint8Array`, so an empty list is an empty + * typed array, not `undefined`. */ bucketFlush(docId: Uint8Array): Promise; - /** `pickup` is the link-tier standing capability; owner tiers ignore it. */ + /** `pickup` is the link-tier standing capability; owner tiers ignore it. + * The empty-`docId` sentinel applies here too, identically. */ bucketPull( docId: Uint8Array, ownerId: Uint8Array, @@ -219,9 +228,68 @@ export interface Driver { * events for its own writes. */ usEvents(): Promise; + // --- account recovery (#11; runtime/RECOVERY.md) --- (engine.wit's + // `recovery-*` block carries the whole contract — the dormant-leaf + // argument, the derivation, the single-use ruling and the + // idempotency promise. Not repeated here, to keep one authority.) + + /** Mint a BUCKET kit and return its recovery phrase (10 words). THE + * PHRASE IS RETURNED ONCE AND PERSISTED NOWHERE — not by the guest, + * and not by anything downstream of this call. S3-ONLY at this rev + * (RECOVERY.md, "Two kit kinds"): the bucket kind needs an owner-tier + * PUT at a name the guest derives, and only S3 addresses objects by + * name. Refused by name on any other provider, and on a device with + * no bound store or no account. */ + recoveryKitCreateBucket(label: string): Promise; + /** Mint a FILE kit and return the sealed bundle bytes for download. + * Stores no object, so it works on every provider. */ + recoveryKitCreateFile(label: string, passphrase: string): Promise; + /** Restore an account onto an UNINITIALIZED engine — INSTEAD of + * `init`, `identityImport` or `stateResume`. The config is a + * PARAMETER rather than `initStore` state because finding the bundle + * needs the destination first, and the destination lives inside the + * account the bundle unlocks; the same config is applied as + * `initStore` would once state exists. Resolves the restored identity + * id, hex. */ + recoveryRestoreBucket( + config: StoreConfig, + phrase: string, + deviceName: string, + ): Promise; + /** The same ceremony with the bundle handed over instead of fetched. + * A wrong passphrase is a clean slot failure ("unlock failed"). */ + recoveryRestoreFile( + config: StoreConfig, + bundle: Uint8Array, + passphrase: string, + deviceName: string, + ): Promise; + /** Consume the kit this instance restored from. Called by the + * EMBEDDER after the content fan-out and the first checkpoint + * succeed, never before. IDEMPOTENT BY CONTRACT: absence is success, + * so a failure is retried on the flush cadence's backoff loop and + * never blocks or undoes the restore. */ + recoveryConsume(): Promise; + /** The account's live kits — a projection of the us-doc's `recovery` + * map. Nothing here unlocks anything. */ + recoveryKits(): Promise; + /** Revoke a kit: the same mechanic as a lost phone, because it IS the + * same thing. Returns the human-readable guarantee note `storeRevoke` + * returns. */ + recoveryKitRevoke(agentId: Uint8Array): Promise; + stats(): Promise; } +/** `recovery-kit` — a record, so it lowers to a plain object (the + * `{kind, value}` variant convention does not apply). `kind` is + * `"bucket"` or `"file"`; `created` is a WIT `u64`, hence a bigint. */ +export interface RecoveryKit { + agentId: Uint8Array; + kind: string; + created: bigint; +} + // --- device-pairing + user-system WIT record/variant mirrors // (engine.wit ~214-280). `option` lowers to `T | undefined`, `list` // to Uint8Array, `u64` to bigint, `u16`/`u32` to number, `tuple` to diff --git a/runtime/tests/devstore/page.ts b/runtime/tests/devstore/page.ts index 26a07b1..d7a342e 100644 --- a/runtime/tests/devstore/page.ts +++ b/runtime/tests/devstore/page.ts @@ -41,7 +41,11 @@ import { makePublicFetch, makeSharedFetch, } from "../../store-egress.ts"; -import type { OauthStartSpec, StoreBinding } from "../../device-store/rpc.ts"; +import type { + OauthStartSpec, + RecoveryKitSpec, + StoreBinding, +} from "../../device-store/rpc.ts"; // The brand predicate, IN THE PAGE'S REALM. Row 18's central claim since // the 0.4.0 bump is that `fromCloneable` mints a value this copy // recognizes — so the predicate has to be the page's own, not the @@ -1712,8 +1716,436 @@ const ops: Record Promise> = { const docHex = [...docId].map((b) => b.toString(16).padStart(2, "0")).join(""); return { attempt: await refuses(() => conn.driver.bucketFlush(docId)), docHex }; }, + + // --- account recovery (rows 54+; runtime/RECOVERY.md) --------------------- + // + // EVERY SECRET IN THIS SECTION IS SYNTHETIC AND LABELLED, and the + // GENERATED ones (the recovery phrase) are never chosen here at all: + // the guest mints the phrase, hands it back once, and this page + // carries it in a local for the length of one probe call. Nothing here + // stores a phrase anywhere the scan row could find it — which is + // exactly what row 54 is checking, so the harness has to hold itself + // to the same rule as the code under test. + + /** Mint a kit through the HOST method, which is the supported path: + * it drives the post-ceremony flush fan-out so the kit is valid the + * moment this resolves (RECOVERY.md's ceremony step 6). */ + "rc-kit-create": async (arg: { id: string; spec: RecoveryKitSpec }) => { + const conn = conns.get(arg.id)!; + const attempt = await attemptValue(() => conn.createRecoveryKit(arg.spec)); + if (!attempt.ok) return { attempt: { ok: false, error: attempt.error } }; + const out = attempt.value; + // THE PHRASE IS NOT RETURNED TO THE DRIVER. It goes into this + // page's `phrases` map under a handle, and the row drives the + // restore by handle — so the driver never holds the secret, the + // Playwright protocol never carries it, and the run's own log + // cannot leak it. What the row gets is its SHAPE: the word count + // and the length, which is what "10 words, ~103 bits" is asserted + // through. + const handle = `kit-${phrases.size + 1}`; + if (out.kind === "bucket") { + phrases.set(handle, out.phrase); + const words = out.phrase.split(/\s+/).filter((w) => w.length > 0); + return { + attempt: { ok: true }, + kind: "bucket", + handle, + words: words.length, + chars: out.phrase.length, + allLowercaseWords: words.every((w) => /^[a-z]+$/.test(w)), + distinctWords: new Set(words).size, + }; + } + bundles.set(handle, out.bundle); + return { attempt: { ok: true }, kind: "file", handle, bytes: out.bundle.length }; + }, + + /** The account's kit list, as the devices sheet will read it. Agent + * ids come back as a short PREFIX only: they are public keys, but a + * row's evidence line is not the place for a full one. */ + "rc-kits": async (arg: { id: string }) => { + const conn = conns.get(arg.id)!; + const attempt = await attemptValue(() => conn.recoveryKits()); + if (!attempt.ok) return { attempt }; + return { + attempt: { ok: true }, + kits: attempt.value.map((k) => ({ + agent: hexOf(k.agentId).slice(0, 12), + kind: k.kind, + created: String(k.created), + })), + }; + }, + + "rc-revoke": async (arg: { id: string; agentPrefix: string }) => { + const conn = conns.get(arg.id)!; + const kits = await conn.recoveryKits(); + const kit = kits.find((k) => hexOf(k.agentId).startsWith(arg.agentPrefix)); + if (!kit) return { attempt: { ok: false, error: { message: "no such kit" } } }; + return { attempt: await attemptValue(() => conn.revokeRecoveryKit(kit.agentId)) }; + }, + + /** + * RESTORE A FRESH DEVICE FROM A KIT. + * + * `prepare` runs the DEK-only first stage — the gdrive path needs it, + * because the consent seals tokens under the DEK — and `oauth` runs + * the headless consent in between, exactly as the driver's own + * `startAndFetchAuth` does for an ordinary bind. + * + * The kit is named by HANDLE, never by value: see `rc-kit-create`. + */ + "rc-restore": async (arg: { + /** An ALREADY-PREPARED device (the two-stage gdrive path), or absent + * to create the fresh namespace here. */ + id?: string; + petname: string; + binding: StoreBinding; + handle: string; + kind: "bucket" | "file"; + deviceName: string; + passphrase?: string; + wrongPhrase?: string; + prepare?: boolean; + /** The consent to run between `restorePrepare` and `restore`. The + * page cannot 302 for itself, so the driver hands back the code and + * state it fetched from the fake — the same split + * `startAndFetchAuth` uses. */ + oauth?: { code: string; state: string }; + }) => { + const deviceId = arg.id ?? + (await createDevice({ petname: arg.petname, posture: "platform" })).id; + const conn = await connect({ id: deviceId }); + if (arg.prepare) { + await conn.restorePrepare({ passphrase: PASS, untilReseal: true }); + } + if (arg.oauth) { + await conn.oauthComplete(arg.oauth.code, arg.oauth.state); + } + const kit = arg.kind === "bucket" + ? { + kind: "bucket" as const, + phrase: arg.wrongPhrase ?? phrases.get(arg.handle) ?? "", + } + : { + kind: "file" as const, + bundle: bundles.get(arg.handle) ?? new Uint8Array(0), + passphrase: arg.passphrase ?? "", + }; + const attempt = await refuses(() => + conn.restore({ + binding: arg.binding, + kit, + deviceName: arg.deviceName, + unseal: { passphrase: PASS, untilReseal: true }, + }) + ); + return { id: deviceId, attempt, status: await conn.status() }; + }, + + /** `restorePrepare` on its own, so a row can start the two-stage + * ceremony and then run a consent through the ordinary gd- ops. */ + "rc-prepare": async (arg: { petname: string }) => { + const made = await createDevice({ petname: arg.petname, posture: "platform" }); + const conn = await connect({ id: made.id }); + const attempt = await refuses(() => + conn.restorePrepare({ passphrase: PASS, untilReseal: true }) + ); + return { id: made.id, attempt, status: await conn.status() }; + }, + + /** + * THE USER'S OWN Sync-now, spelled the way the storage sheet spells + * it: `driver.bucketFlush` per doc, straight through, with the + * ACCOUNT DOCUMENT first under its empty-id sentinel (engine.wit's + * `bucket-flush`; RECOVERY.md's unparking). Not the scheduler — an + * explicit act deserves an explicit answer, and a row that needs + * determinism should press the button rather than race a debounce. + */ + "rc-flush-now": async (arg: { id: string }) => { + const conn = conns.get(arg.id)!; + const us = await refuses(() => conn.driver.bucketFlush(new Uint8Array(0))); + const parts = await conn.driver.usPartitions(); + const each: { name: string; refused: boolean }[] = []; + for (const p of parts) { + const r = await refuses(() => conn.driver.bucketFlush(p.id)); + each.push({ name: p.name, refused: r.refused }); + } + return { us, each }; + }, + + /** Adopt a partition named in the account's pointer map and pull it + * from a named sibling — the embedder half a PAIRED device runs + * (solo.ts's `adoptPartition` beat). The restore path does this + * inside the worker; this op exists for the rows that need a + * SIBLING's device to catch up without one. */ + "rc-pull-now": async (arg: { id: string }) => { + const conn = conns.get(arg.id)!; + const devices = await conn.driver.usDevicesList(); + const self = (await conn.status()).agentId ?? ""; + const sibs = devices.filter((d) => !d.revoked && hexOf(d.agentId) !== self); + const us: boolean[] = []; + for (const s of sibs) { + const r = await refuses(() => conn.driver.bucketPull(new Uint8Array(0), s.agentId, undefined)); + us.push(!r.refused); + } + return { siblings: sibs.length, usPulled: us.filter(Boolean).length }; + }, + + /** The account's device directory, as a sheet would render it. */ + "rc-devices": async (arg: { id: string }) => { + const conn = conns.get(arg.id)!; + const list = await conn.driver.usDevicesList(); + return { + names: list.map((d) => d.name).sort(), + revoked: list.filter((d) => d.revoked).length, + n: list.length, + }; + }, + + /** The account profile, both directions — row 60's mutation and its + * observation. `usProfileSet` is NOT in rpc.ts's READONLY_METHODS, so + * the set is a MUTATION and arms the same flush debounce a todo does; + * that is the fact the row is built on. */ + "rc-profile-set": async (arg: { id: string; displayName: string; hue?: number }) => { + const conn = conns.get(arg.id)!; + const attempt = await refuses(() => + conn.driver.usProfileSet({ displayName: arg.displayName, hue: arg.hue ?? 0 }) + ); + return { attempt }; + }, + + "rc-profile-get": async (arg: { id: string }) => { + const conn = conns.get(arg.id)!; + const p = await conn.driver.usProfileGet(); + return { displayName: p.displayName, hue: p.hue }; + }, + + /** Drain the account's remote-change events — the ORDINARY surface a + * visor announces from (#22), with local-echo suppression engine-side + * so a device never hears its own writes. */ + "rc-events": async (arg: { id: string }) => { + const conn = conns.get(arg.id)!; + const events = await conn.driver.usEvents(); + return { kinds: events.map((e) => e.kind) }; + }, + + /** + * THE SECRET-ABSENCE SCAN (row 54), in the shape the identity rows' + * at-rest checks use: go and look, everywhere this origin can store a + * byte, for the phrase — and for a distinctive slice of it, so a + * different encoding or a partial write cannot slip past an + * equality test. + * + * * every IndexedDB database on the origin, every store, every + * record, serialized (bytes included, as hex AND as latin1 text); + * * localStorage and sessionStorage, keys and values; + * * every file under every OPFS directory, recursively, as bytes. + * + * The needle is taken from the `phrases` map by handle and never + * returned to the driver; what comes back is counts and a boolean. + */ + "rc-scan": async (arg: { handle: string }) => { + const phrase = phrases.get(arg.handle) ?? ""; + if (phrase === "") throw new Error("rc-scan: no such phrase handle"); + // Three needles: the phrase as typed, its middle words (a partial + // write or a re-joined variant), and the phrase with single spaces + // collapsed out entirely (a normalization that stored it another + // way would still contain this). + const words = phrase.split(/\s+/); + const needles = [ + phrase, + words.slice(2, 5).join(" "), + words.join(""), + ].filter((n) => n.length >= 8); + const enc8 = new TextEncoder(); + const needleHex = needles.map((n) => hexOf(enc8.encode(n))); + + let idbRecords = 0; + let opfsFiles = 0; + let storageEntries = 0; + const hits: string[] = []; + + const look = (where: string, hay: string, hayHex: string) => { + for (let i = 0; i < needles.length; i++) { + if (hay.includes(needles[i]) || hayHex.includes(needleHex[i])) hits.push(`${where}#${i}`); + } + }; + + // --- IndexedDB, every database this origin holds. + const dbs = await indexedDB.databases(); + for (const info of dbs) { + if (!info.name) continue; + const db = await new Promise((resolve, reject) => { + const req = indexedDB.open(info.name!); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); + for (const store of Array.from(db.objectStoreNames)) { + const all = await new Promise((resolve, reject) => { + const tx = db.transaction(store, "readonly"); + const req = tx.objectStore(store).getAll(); + req.onsuccess = () => resolve(req.result as unknown[]); + req.onerror = () => reject(req.error); + }); + for (const rec of all) { + idbRecords++; + const { text, hex } = flatten(rec); + look(`idb:${info.name}/${store}`, text, hex); + } + } + db.close(); + } + + // --- localStorage / sessionStorage. + for (const [label, area] of [["local", localStorage], ["session", sessionStorage]] as const) { + for (let i = 0; i < area.length; i++) { + const k = area.key(i)!; + const v = area.getItem(k) ?? ""; + storageEntries++; + look(`${label}Storage:${k}`, `${k}\n${v}`, hexOf(enc8.encode(`${k}\n${v}`))); + } + } + + // --- OPFS, every file under every directory, recursively. + const walk = async (dir: FileSystemDirectoryHandle, path: string): Promise => { + // deno-lint-ignore no-explicit-any + for await (const [name, handle] of (dir as any).entries()) { + const at = `${path}/${name}`; + if (handle.kind === "directory") { + await walk(handle as FileSystemDirectoryHandle, at); + } else { + opfsFiles++; + const bytes = new Uint8Array( + await (await (handle as FileSystemFileHandle).getFile()).arrayBuffer(), + ); + look(`opfs:${at}`, latin1(bytes), hexOf(bytes)); + } + } + }; + await walk(await navigator.storage.getDirectory(), ""); + + return { + needles: needles.length, + idbDatabases: dbs.length, + idbRecords, + storageEntries, + opfsFiles, + hits, + clean: hits.length === 0, + }; + }, + + /** + * THE ACCOUNT CEREMONY IN THE SHAPE A REAL EMBEDDER RUNS IT + * (demo/host/solo.ts's `newAccount`, and the native recovery acts' + * setup — engine/host/src/recover_acts.rs). + * + * ORDER IS LOAD-BEARING: user-create → create the tasks partition → + * DELEGATE IT TO THE USER GROUP → seal → publish the pointer. + * + * THE DELEGATION IS WHY THIS OP EXISTS beside `hc-us-create` + + * `hc-us-partition-put`, which publish the partition the WORKER minted + * at fresh init — one delegated to the founding DEVICE, because it was + * created before any account existed. That is fine for the scheduler + * rows (a device syncing with itself), and it is exactly wrong for + * recovery: BeeKEM adds are not retroactive, so a doc's first epoch + * must already cover its intended readership, and a device-delegated + * partition is unreadable by a device enrolled later — including a + * restored kit. The recovery acts say the same thing in the same words + * ("A partition delegated to the founding DEVICE instead would be + * unreadable by the restored kit"). Measured here first as five dark + * chunks on a restored device that had pulled every byte correctly. + */ + "rc-account-create": async (arg: { id: string; displayName: string; pointer?: string }) => { + const conn = conns.get(arg.id)!; + const groupId = await conn.driver.userCreate({ displayName: arg.displayName, hue: 0 }); + const id = await conn.driver.createPartition(); + await conn.driver.khAddMember(id, groupId, "edit"); + await conn.driver.sealPartition(id); + await conn.driver.usPartitionPut(arg.pointer ?? "tasks", id); + const parts = await conn.driver.usPartitions(); + return { + groupId: groupId.length, + partition: hexOf(id).slice(0, 16), + names: parts.map((p) => p.name), + active: hexOf(await conn.tasks.partition()).slice(0, 16), + }; + }, }; +/** + * THE MINTED SECRETS, HELD ON THE PAGE AND NEVER HANDED TO THE DRIVER. + * + * A recovery phrase is displayed once in visor pixels and persisted + * nowhere; the harness honours the same rule by keeping it in a page + * local under a handle. The row asks for a RESTORE BY HANDLE, so the + * secret never crosses the Playwright protocol and never reaches the + * run's log — and the scan row's needle is read from here rather than + * being sent back in. + * + * These are plain module-scope maps, so they die with the document. No + * row reloads between minting a kit and using it. + */ +const phrases = new Map(); +const bundles = new Map(); + +/** Bytes as latin1 text, so a byte-for-byte substring search over a + * binary file finds an ASCII needle inside it. */ +function latin1(bytes: Uint8Array): string { + let out = ""; + for (const b of bytes) out += String.fromCharCode(b); + return out; +} + +/** + * One stored record, flattened into (text, hex) so a search can look at + * it both ways: an IndexedDB value is an arbitrary structured-clone + * graph, and a phrase could be sitting in it as a string, as UTF-8 bytes + * inside a typed array, or inside a nested record. + */ +function flatten(value: unknown): { text: string; hex: string } { + const texts: string[] = []; + const hexes: string[] = []; + const seen = new Set(); + const walk = (v: unknown) => { + if (v === null || v === undefined) return; + if (typeof v === "string") { + texts.push(v); + hexes.push(hexOf(new TextEncoder().encode(v))); + return; + } + if (typeof v === "number" || typeof v === "boolean" || typeof v === "bigint") { + texts.push(String(v)); + return; + } + if (v instanceof Uint8Array) { + texts.push(latin1(v)); + hexes.push(hexOf(v)); + return; + } + if (v instanceof ArrayBuffer) { + const b = new Uint8Array(v); + texts.push(latin1(b)); + hexes.push(hexOf(b)); + return; + } + if (typeof v === "object") { + if (seen.has(v)) return; + seen.add(v); + // A CryptoKey has nothing readable in it and that is the point; + // enumerating it yields nothing either way. + for (const k of Object.keys(v as Record)) { + texts.push(k); + walk((v as Record)[k]); + } + if (Array.isArray(v)) for (const item of v) walk(item); + } + }; + walk(value); + return { text: texts.join("\u0000"), hex: hexes.join("") }; +} + // --- the host client, per page ---------------------------------------------- /** Where the harness's build put the engine. Resolved against the diff --git a/runtime/tests/devstore/run.ts b/runtime/tests/devstore/run.ts index e7952d2..7ae3745 100644 --- a/runtime/tests/devstore/run.ts +++ b/runtime/tests/devstore/run.ts @@ -294,6 +294,115 @@ const s3LogGet = (port: number): Promise => const s3LogClear = (port: number): Promise => fetch(`http://127.0.0.1:${port}/__s3log/clear`, { method: "POST" }).then(() => {}); +// --- the S3-shaped OBJECT STORE (recovery rows 57+) ----------------------------- +// +// A STORE, NOT A RECORDER, AND THAT IS THE DIFFERENCE THAT MATTERS. The +// recorder above answers every GET with 404 by design — it exists to +// observe that a signed request left the worker, and the rows that use +// it assert egress rather than durability. The RECOVERY rows cannot +// live on that: a restore READS the account out of the bucket (that is +// the whole claim), so the kit's bundle, the K_p and every flushed +// object have to still be there when a second device asks for them. +// +// So this is a separate server on its own ephemeral port rather than an +// upgrade of the recorder: every existing row keeps the backend it was +// written against, and nothing about "GET is always a 404" changes +// underneath rows 28-47. +// +// It implements exactly what providers/s3/store's four verbs need +// (`/{bucket}` for the bucket, `/{bucket}/{key}` for an object; keys +// contain slashes — `recovery/` — so the key is everything after +// the first segment) and NOT S3: no signature is checked, no ACL, no +// versioning, no listing. The signing claim is rows 30/33's and is +// measured against the recorder. +// +// DELETE ANSWERS 204 WHETHER OR NOT THE OBJECT WAS THERE, which is real +// S3 behaviour and is exactly the property `recovery-consume`'s +// idempotency contract rests on ("absence is success ... a retry after +// partial success must not error on an object that is already gone"). +// `refuseNextDeletes` is the injected outage for the consume-failure +// row: it refuses the DELETE and NOTHING else, so a restore still +// succeeds end to end and only its tail fails — which is the exact +// shape RECOVERY.md says must never block a restore. +interface ObjectStoreHandle { + server: Deno.HttpServer; + port: number; + /** Every object key currently stored, sorted. Names only — no bytes + * ever leave this harness, and the names are keyed hashes anyway. */ + names(): string[]; + /** Refuse the next `n` DELETEs with a 503. `Infinity` for an outage + * with no end until it is healed with 0. */ + refuseNextDeletes(n: number): void; + deleteRefusalsPending(): number; +} + +function serveObjects(): ObjectStoreHandle { + const objects = new Map(); + const buckets = new Set(); + let refuseDeletes = 0; + let port = 0; + const cors = { + "access-control-allow-origin": "*", + "access-control-allow-methods": "GET, PUT, POST, DELETE, OPTIONS", + "access-control-allow-headers": "*", + }; + const server = Deno.serve({ + port: 0, + hostname: "127.0.0.1", + onListen: (addr) => { + port = addr.port; + }, + }, async (req) => { + if (req.method === "OPTIONS") return new Response(null, { status: 204, headers: cors }); + const url = new URL(req.url); + const segments = url.pathname.replace(/^\//, "").split("/"); + const bucket = segments[0] ?? ""; + const key = segments.slice(1).join("/"); + if (key === "") { + // The bucket itself: `ensureBucket`'s PUT, and nothing else. + if (req.method === "PUT") { + buckets.add(bucket); + return new Response(null, { status: 200, headers: cors }); + } + return new Response(null, { status: buckets.has(bucket) ? 200 : 404, headers: cors }); + } + if (req.method === "PUT") { + objects.set(key, new Uint8Array(await req.arrayBuffer())); + return new Response(null, { status: 200, headers: cors }); + } + if (req.method === "GET") { + const body = objects.get(key); + if (!body) return new Response(null, { status: 404, headers: cors }); + return new Response(body as BodyInit, { + status: 200, + headers: { ...cors, "content-type": "application/octet-stream" }, + }); + } + if (req.method === "DELETE") { + if (refuseDeletes > 0) { + refuseDeletes--; + return new Response("injected outage: this store is refusing deletes", { + status: 503, + headers: cors, + }); + } + // 204 whether or not it was there — see the header. + objects.delete(key); + return new Response(null, { status: 204, headers: cors }); + } + return new Response(null, { status: 405, headers: cors }); + }); + return { + server, + port, + names: () => [...objects.keys()].sort(), + refuseNextDeletes: (n: number) => { + refuseDeletes = n; + }, + deleteRefusalsPending: () => (refuseDeletes === Infinity ? Infinity : refuseDeletes), + }; +} + // Synthetic labeled S3 credentials — never realistic-looking material, // spelled the same way across every row that uses them. const S3_ACCESS_KEY = "SYNTHETIC-TEST-KEY"; @@ -465,6 +574,13 @@ async function main() { const s3Origin = `http://127.0.0.1:${s3Port}`; console.log(`s3 recorder: ${s3Origin}`); + // The recovery rows' backend: a real (tiny) object store, because a + // restore reads the account back out of the bucket. See `serveObjects`. + const objects = serveObjects(); + await new Promise((r) => setTimeout(r, 50)); + const objOrigin = `http://127.0.0.1:${objects.port}`; + console.log(`s3 object store: ${objOrigin}`); + const fake = await startFakeDrive(); // The fake now serves its own CORS (access-control-allow-origin: *, // OPTIONS preflights answered 204 with authorization/content-type @@ -2917,16 +3033,24 @@ async function main() { 45_000, ); const boardsAfter = syncBoards(); - const board = boardsAfter[0]?.board ?? {}; - const values = Object.values(board); + const boards = boardsAfter.map((b) => b.board); + // TWO DOC FOLDERS, NOT ONE, since RECOVERY.md unparked the us-doc: + // a cycle now flushes the ACCOUNT DOCUMENT (the empty-doc-id + // sentinel) as well as every partition in the pointer map. Each + // carries its own change board, and each board's single value is + // this device's FIRST committed flush of that doc — so "1" twice + // rather than "1" once. The count is the assertion it always was; + // what changed is the SCOPE of a cycle, deliberately. + const everyBoardFirstFlush = boards.length > 0 && + boards.every((b) => Object.keys(b).length === 1 && Object.values(b)[0] === "1"); const ok = consent.ok && bound.attempt.refused === false && ensure.attempt.refused === false && scoped.attempt.refused === false && scoped.names.includes("tasks") && idleStatus.sync !== null && idleStatus.sync.lastFlush === null && flushed.ok && flushed.sync.flushFailures === 0 && - boardsBefore.length === 0 && boardsAfter.length === 1 && - Object.keys(board).length === 1 && values[0] === "1"; + boardsBefore.length === 0 && boardsAfter.length === 2 && + everyBoardFirstFlush; record( "48 sync", "a mutation flushes itself within the debounce window, with no button pressed", @@ -2940,11 +3064,13 @@ async function main() { `todo went in through the RPC and NOTHING ELSE was called: ${flushed.waitedMs} ms ` + `later (debounce 20 s + margin, deadline 45 s) status().sync=${j(flushed.sync)} — ` + `lastFlush stamped, flushFailures 0. The provider agrees: ${boardsAfter.length} doc ` + - `folder now, carrying the change board ${j(board)} — ONE key (16 hex characters of a ` + - `public verifying key) whose value is the decimal counter "1", this device's first ` + - `COMMITTED flush of this doc, patched after the manifest write that is the commit ` + - `point. A counter and a public tag are the only things that may ever go on that ` + - `board (SYNC.md §2).`, + `folders now — the tasks partition AND the ACCOUNT DOCUMENT, which rides the cycle ` + + `since RECOVERY.md unparked SYNC.md §3's parked item (the empty-doc-id sentinel; a ` + + `restore can only be as fresh as the last us flush) — carrying the change boards ` + + `${j(boards)}: each ONE key (16 hex characters of a public verifying key) whose value ` + + `is the decimal counter "1", this device's first COMMITTED flush of that doc, patched ` + + `after the manifest write that is the commit point. A counter and a public tag are ` + + `the only things that may ever go on that board (SYNC.md §2).`, ); }); @@ -3716,12 +3842,729 @@ async function main() { await storm.close(); }); + // --- 54-60: ACCOUNT RECOVERY (runtime/RECOVERY.md) --------------------- + // + // "Losing every device does not lose the account." The kit ceremony + // mints a DORMANT MEMBER DEVICE whose secrets exist only inside a + // sealed bundle; restore boots that device on a fresh namespace and + // then consumes the kit, because a reusable kit would need a silent + // background re-exporter whose failure is invisible until the + // disaster it exists for. + // + // THE SECRETS NEVER REACH THIS FILE. The guest mints the phrase and + // the page holds it under a handle (page.ts's `phrases`); every row + // below drives a restore BY HANDLE, so no phrase and no bundle + // crosses the Playwright protocol or lands in this run's log. What + // the rows assert about them is shape (ten words), absence (the scan) + // and consequence (a restore that works, and one that refuses). + // + // THE BACKEND IS THE OBJECT STORE, not the recorder: see + // `serveObjects` for why the difference is structural rather than + // convenient. + const rcBinding = { + kind: "s3" as const, + endpoint: objOrigin, + bucket: "pm-recovery", + accessKey: S3_ACCESS_KEY, + }; + // A wrong phrase, obviously synthetic and obviously not a kit's: ten + // words that no wordlist draw would produce together. + const WRONG_PHRASE = "wrong wrong wrong not the phrase test test test test test"; + const FILE_PASS = "the-file-kit-passphrase-TEST"; + const FILE_PASS_WRONG = "definitely-not-the-file-passphrase-TEST"; + let rcDevice = ""; + let rcKitHandle = ""; + let rcRestored = ""; + let objectsAfterKit: string[] = []; + let objectsAfterRestore: string[] = []; + const RC_TODOS = ["a todo from before the kit", "a second todo from before the kit"]; + const RC_LATE = "a todo written AFTER the kit was minted"; + const RC_NAME = "the machine it became"; + + // --- 57: the kit ceremony, and the phrase is nowhere on disk ----------- + // + // RECOVERY.md pins the phrase: ten words from the EFF short + // wordlist, ~103.4 bits, "generated IN-GUEST (single authority for + // format and derivation), displayed once in visor pixels, never + // persisted anywhere". + // + // THE ABSENCE IS THE HEADLINE and it is asked the way the identity + // rows ask their at-rest questions: go and look. Every IndexedDB + // record on the origin (bytes included), both web-storage areas, and + // every file under every OPFS directory — searched for the phrase, + // for a slice of it, and for a whitespace-collapsed variant, as text + // AND as hex. A `clean` verdict here is the difference between "we + // do not write it down" as a claim and as a fact: the DEVICE STORE + // is the layer that persists things, so it is the layer that has to + // be searched. + await guard(async () => { + const made = await probe(page, "hc-make", { + petname: "recovery-origin", + policy: "until-reseal", + promote: true, + }); + const id = made.id as string; + rcDevice = id; + await probe(page, "hc-open", { id, unseal: { passphrase: PASS, untilReseal: true } }); + await probe(page, "sx-escrow", { + origin: objOrigin, + accessKey: S3_ACCESS_KEY, + secret: S3_SECRET, + }); + const bound = await probe(page, "hc-bind", { id, binding: rcBinding }); + const ensure = await probe(page, "hc-ensure-bucket", { id }); + // THE ACCOUNT IN THE SHAPE A REAL EMBEDDER BUILDS IT — the tasks + // partition DELEGATED TO THE USER GROUP, not to this device. See + // page.ts's `rc-account-create`: a device-delegated partition is + // unreadable by any device enrolled later, restored kits included, + // because BeeKEM adds are not retroactive. + const scoped = await probe(page, "rc-account-create", { + id, + displayName: "Synthetic Recovery Account", + }); + await probe(page, "hc-add", { id, titles: RC_TODOS }); + // The user's own Sync-now, not the schedule: this row wants + // determinism, and an explicit act deserves an explicit answer. + const flushed = await probe(page, "rc-flush-now", { id }); + + const kit = await probe(page, "rc-kit-create", { + id, + spec: { kind: "bucket", label: "paper backup" }, + }); + const kits = await probe(page, "rc-kits", { id }); + const scan = kit.attempt.ok ? await probe(page, "rc-scan", { handle: kit.handle }) : null; + const devices = await probe(page, "rc-devices", { id }); + if (kit.attempt.ok) rcKitHandle = kit.handle; + objectsAfterKit = objects.names(); + const bundleObjects = objectsAfterKit.filter((n) => n.startsWith("recovery/")); + + const ok = bound.attempt.refused === false && ensure.attempt.refused === false && + j(scoped.names) === j(["tasks"]) && flushed.us.refused === false && + kit.attempt.ok === true && kit.kind === "bucket" && + kit.words === 10 && kit.allLowercaseWords === true && + kits.attempt.ok === true && kits.kits.length === 1 && + kits.kits[0].kind === "bucket" && + devices.n === 2 && devices.names.includes("paper backup") && + bundleObjects.length === 1 && + scan !== null && scan.clean === true && scan.idbRecords > 0 && scan.opfsFiles > 0; + record( + "57 recovery", + "a bucket kit mints ten words, enrolls a dormant device — and the phrase is on no disk anywhere", + ok, + `an S3-bound device with an account and its tasks partition published minted a BUCKET ` + + `kit. The phrase came back with ${kit.words} words (RECOVERY.md pins ten from the EFF ` + + `short list, ~103.4 bits), all lowercase word characters: ${kit.allLowercaseWords} — ` + + `and it never left the page: the driver holds a HANDLE, not a secret. The account's ` + + `kit list is ${j(kits.kits)} (agent ids truncated to 12 hex characters here; they are ` + + `public keys, but a log is not the place for a whole one), and the kit is a real ` + + `DEVICE: the directory now names ${j(devices.names)} — ${devices.n} entries, the kit ` + + `beside the machine that minted it, revocable like any device. The store holds ` + + `${bundleObjects.length} object under \`recovery/\` (the phrase-derived name; the ` + + `provider sees it regardless and it unlocks nothing — the payload is sealed under the ` + + `phrase-derived KEK). THE SCAN: ${scan?.idbDatabases} IndexedDB database(s), ` + + `${scan?.idbRecords} records, ${scan?.storageEntries} web-storage entries and ` + + `${scan?.opfsFiles} OPFS files were searched for the phrase, for a three-word slice of ` + + `it and for a whitespace-collapsed variant, as text and as hex. Hits: ` + + `${j(scan?.hits ?? [])}.`, + ); + }); + + // --- 58: the restore round trip, on a SECOND device namespace ---------- + // + // THE CLAIM RECOVERY.md OPENS WITH, made executable in one browser: + // a fresh device namespace with no engine state, no identity and no + // account boots from the kit plus the destination alone, and comes + // up holding the account's content. + // + // THE LATE WRITE IS THE POINT OF THE THIRD TODO. A kit is a key to + // the BUCKET, not a snapshot of the account (RECOVERY.md: "the kit + // is the bucket's key, not a second bucket"), so a todo written + // AFTER the ceremony has to arrive too — which is only true if the + // restore actually rehydrates from the store rather than from + // anything bundled. + // + // AND THE NAME: "the kit's label gives way to the user's word for + // the machine it became" (engine.wit's `recovery-restore-bucket`). + // The devices sheet must show the ceremony's name, not "paper + // backup". + await guard(async () => { + await probe(page, "hc-add", { id: rcDevice, titles: [RC_LATE] }); + const flushed = await probe(page, "rc-flush-now", { id: rcDevice }); + const before = await probe(page, "hc-items", { id: rcDevice }); + + const restored = await probe(page, "rc-restore", { + petname: "restored-device", + binding: rcBinding, + handle: rcKitHandle, + kind: "bucket", + deviceName: RC_NAME, + }); + rcRestored = restored.id as string; + const after = restored.attempt.refused + ? { titles: [], n: 0, revision: "-" } + : await probe(page, "hc-items", { id: rcRestored }); + const devices = restored.attempt.refused + ? { names: [], n: 0 } + : await probe(page, "rc-devices", { id: rcRestored }); + objectsAfterRestore = objects.names(); + + const equal = j(after.titles) === j(before.titles); + const ok = flushed.us.refused === false && restored.attempt.refused === false && + restored.status.sealed === false && restored.status.storage?.kind === "s3" && + restored.status.agentId !== null && restored.status.tier === "t0" && + equal && after.n === 3 && after.titles.includes(RC_LATE) && + devices.names.includes(RC_NAME) && !devices.names.includes("paper backup"); + record( + "58 recovery", + "a fresh namespace restores the whole account from phrase + destination, with NO live peer", + ok, + `a SECOND device namespace in this browser — no engine state, no identity, no account — ` + + `was handed the destination and the kit and nothing else. It came up unsealed ` + + `(sealed=${restored.status.sealed}), bound (${j(restored.status.storage?.kind)}), with ` + + `an agent id of its own, and at TIER ${j(restored.status.tier)}: a restore is not a ` + + `decision to keep the machine it ran on, so promotion stays the user's later act ` + + `(PERSISTENCE.md's try-then-keep). Its todo list is ${j(after.titles)} against the ` + + `origin device's ${j(before.titles)} — equal: ${equal} — INCLUDING ` + + `${j(RC_LATE)}, written after the kit was minted, which is what proves the content ` + + `came out of the BUCKET rather than out of the bundle. The devices sheet now reads ` + + `${j(devices.names)}: the ceremony's name, and the kit's label "paper backup" is gone ` + + `from it.`, + ); + }); + + // --- 59: the kit is spent — consumed artifacts, and a refused second --- + // + // RECOVERY.md, "Single-use, consumed at restore": the bundle object + // and the K_p are deleted after the restore fully succeeds and the + // us-doc record is cleared. Three witnesses, because each one alone + // is weaker than it looks: + // + // * THE STORE'S OWN SET DIFFERENCE — what actually stopped + // existing between the ceremony and the end of the restore. + // Asked as REMOVALS only: a restore also WRITES (its own + // manifests, its own oplogs), so the assertion is about what + // went, not about what is left. + // * A SECOND RESTORE, refused. Double-restore is an identity fork + // — two live instances of one identity clobbering each other's + // keyed names — so consumption makes it structurally impossible + // rather than merely discouraged. + // * THE ACCOUNT'S OWN LIST, read on the ORIGIN device after it + // pulls: the consume cleared the record and flushed the us-doc, + // so the device that minted the kit learns it is gone through + // the ordinary account pull path and not through a side channel. + await guard(async () => { + const removed = objectsAfterKit.filter((n) => !objectsAfterRestore.includes(n)); + const bundleGone = removed.some((n) => n.startsWith("recovery/")); + const kpGone = removed.some((n) => !n.startsWith("recovery/")); + + const second = await probe(page, "rc-restore", { + petname: "second-restore", + binding: rcBinding, + handle: rcKitHandle, + kind: "bucket", + deviceName: "a device that must not exist", + }); + const refusal = String(second.attempt.error?.message ?? ""); + + // The origin device catches up through the ordinary pull path. + const pulled = await probe(page, "rc-pull-now", { id: rcDevice }); + const kits = await probe(page, "rc-kits", { id: rcDevice }); + + const ok = bundleGone && kpGone && removed.length === 2 && + second.attempt.refused === true && + /no recovery kit at this name/i.test(refusal) && + second.attempt.error?.isWit === true && + pulled.usPulled >= 1 && + kits.attempt.ok === true && kits.kits.length === 0; + record( + "59 recovery", + "the kit is single-use: artifacts gone, a second restore refused, the account's list empty", + ok, + `between the ceremony and the end of the restore the store LOST exactly ` + + `${removed.length} object(s): the \`recovery/\` bundle (${bundleGone}) and one ` + + `unprefixed object, the K_p pickup (${kpGone}). Nothing else was removed — a restore ` + + `writes as well as reads, so the claim is about removals. A SECOND restore with the ` + + `same kit was refused (isWit=${second.attempt.error?.isWit}) with ` + + `${j(refusal.slice(0, 90))} — the same refusal a WRONG phrase gets, deliberately: the ` + + `kit's absence is the only fact either case establishes, and a double restore would ` + + `be an identity fork of one account (two live instances clobbering each other's keyed ` + + `oplog/manifest names, which SYNC.md's single-writer-per-name invariant forbids). The ` + + `ORIGIN device then pulled the account document from ${pulled.usPulled} of ` + + `${pulled.siblings} sibling(s) and its kit list is now ${j(kits.kits)} — empty, ` + + `learned through the ordinary account pull path rather than announced out of band.`, + ); + }); + + // --- 60: the two refusals, one per kit kind ----------------------------- + // + // A WRONG PHRASE derives a different name and finds nothing: "the + // refusal is 'no recovery kit at this name', never a partial + // restore" (engine.wit). A WRONG FILE PASSPHRASE is a clean keyslot + // miss — "unlock failed", indistinguishable from any other, which is + // the point: a slot that told you WHICH slot missed would be an + // oracle. + // + // The file kit minted here is revoked at the end of the row rather + // than left standing, so the account goes back to holding no kits — + // and the revocation is the leaked-kit answer exercised in passing + // ("a leaked phrase or file is answered by revoking the kit device, + // because it IS the same thing"). + await guard(async () => { + const wrong = await probe(page, "rc-restore", { + petname: "wrong-phrase", + binding: rcBinding, + handle: rcKitHandle, + kind: "bucket", + wrongPhrase: WRONG_PHRASE, + deviceName: "a device that must not exist", + }); + const wrongMsg = String(wrong.attempt.error?.message ?? ""); + + const fileKit = await probe(page, "rc-kit-create", { + id: rcDevice, + spec: { kind: "file", label: "downloaded file kit", passphrase: FILE_PASS }, + }); + const badPass = fileKit.attempt.ok + ? await probe(page, "rc-restore", { + petname: "wrong-passphrase", + binding: rcBinding, + handle: fileKit.handle, + kind: "file", + passphrase: FILE_PASS_WRONG, + deviceName: "a device that must not exist", + }) + : { attempt: { refused: false, error: null } }; + const badMsg = String(badPass.attempt.error?.message ?? ""); + + const kitsBefore = await probe(page, "rc-kits", { id: rcDevice }); + const revoked = kitsBefore.attempt.ok && kitsBefore.kits.length > 0 + ? await probe(page, "rc-revoke", { id: rcDevice, agentPrefix: kitsBefore.kits[0].agent }) + : { attempt: { ok: false } }; + const kitsAfter = await probe(page, "rc-kits", { id: rcDevice }); + + const ok = wrong.attempt.refused === true && + /no recovery kit at this name/i.test(wrongMsg) && + wrong.attempt.error?.isWit === true && + fileKit.attempt.ok === true && fileKit.kind === "file" && fileKit.bytes > 0 && + badPass.attempt.refused === true && /unlock failed/i.test(badMsg) && + revoked.attempt.ok === true && typeof revoked.attempt.value === "string" && + revoked.attempt.value.length > 0 && + kitsAfter.attempt.ok === true && kitsAfter.kits.length === 0; + record( + "60 recovery", + "a wrong phrase finds no kit; a wrong file passphrase is one clean keyslot miss", + ok, + `a restore attempted with an obviously-synthetic wrong phrase was refused ` + + `(isWit=${wrong.attempt.error?.isWit}) with ${j(wrongMsg.slice(0, 90))}: a wrong ` + + `phrase derives a DIFFERENT object name and finds nothing there, so the refusal is ` + + `an absence and never a partial restore. A FILE kit (${fileKit.bytes} sealed bytes, ` + + `stored in no bucket — which is why this kind works on every provider) was then ` + + `opened with the wrong passphrase and refused with ${j(badMsg.slice(0, 90))} — a ` + + `clean keyslot miss, indistinguishable from any other, because a slot that named ` + + `which one missed would be an oracle. The file kit was then REVOKED, the leaked-kit ` + + `answer: the call returned the guarantee note the UI renders ` + + `(${j(String(revoked.attempt.value ?? "").slice(0, 80))}) and the account's list is ` + + `back to ${j(kitsAfter.kits)}.`, + ); + }); + + // --- 61: a consume that fails announces and retries — it never blocks --- + // + // RECOVERY.md: "Consume failures (unreachable bucket at the end of a + // restore) never block the restore: they announce and retry on the + // flush cadence's backoff loop." engine.wit says why the retry is + // safe: `recovery-consume` is IDEMPOTENT BY CONTRACT, absence is + // success. + // + // THE OUTAGE IS DELETE-ONLY, injected into the object store, and + // that shape is chosen rather than convenient: a restore reads and + // writes but deletes nothing, so refusing DELETEs leaves the whole + // restore working and breaks only its tail — which is exactly the + // condition the rule is about. A blanket outage would have failed + // the restore itself and proved nothing about the tail. + // + // "ANNOUNCE-SHAPED" IS ASSERTED, not asserted-about: the failure + // lands on the SCHEDULER'S OWN surface — `flushFailures` climbing + // toward the announce-after-three threshold (SYNC.md §3, row 49's + // claim) and a sentence in `lastError` — plus the one thing a count + // cannot say, `consumePending`, so a sheet can name what is + // outstanding instead of reporting a generic stall. + await guard(async () => { + const kit = await probe(page, "rc-kit-create", { + id: rcDevice, + spec: { kind: "bucket", label: "a kit whose consume will fail" }, + }); + await probe(page, "rc-flush-now", { id: rcDevice }); + + // THE OUTAGE. `Infinity` rather than a count: the row heals it + // explicitly, and a count would make the number of deletes one + // consume happens to make into a load-bearing constant. + objects.refuseNextDeletes(Infinity); + const restored = await probe(page, "rc-restore", { + petname: "restored-under-outage", + binding: rcBinding, + handle: kit.handle, + kind: "bucket", + deviceName: "a device whose kit outlived its restore", + }); + const id = restored.id as string; + const atReturn = restored.status.sync; + const items = restored.attempt.refused ? { n: 0 } : await probe(page, "hc-items", { id }); + + // The retry is on the flush direction's backoff (base 5 s, ×2, + // jittered ×0.5–1.5), so a second failure lands within ~15 s of + // the first; 60 s is margin. + const failing = await untilSync( + page, + id, + "the consume retry failing a second time", + (s) => s.flushFailures >= 2 && s.consumePending === true, + 60_000, + ); + const sentence = String(failing.sync?.lastError ?? ""); + + // AND THE STORE COMES BACK. + objects.refuseNextDeletes(0); + const healed = await untilSync( + page, + id, + "the consume succeeding on a retry", + (s) => s.consumePending === false && s.flushFailures === 0, + 90_000, + ); + await probe(page, "hc-close", { id }); + + const ok = kit.attempt.ok === true && restored.attempt.refused === false && + restored.status.sealed === false && items.n === 3 && + atReturn !== null && atReturn.consumePending === true && + failing.ok && sentence.length > 0 && /\s/.test(sentence) && + healed.ok && healed.sync.consumePending === false; + record( + "61 recovery", + "a consume that cannot reach the store announces and retries; the restore itself stands", + ok, + `a fresh kit was minted and the store was put into a DELETE-ONLY outage — a restore ` + + `reads and writes but deletes nothing, so this breaks the ceremony's TAIL and nothing ` + + `else. The restore SUCCEEDED anyway (refused=${restored.attempt.refused}, ` + + `${items.n} todos in hand) and at the instant it returned status().sync=${j(atReturn)}: ` + + `consumePending TRUE, which is the one thing a failure count cannot say — WHAT is ` + + `outstanding, so a sheet can name the kit instead of reporting a generic stall. The ` + + `retry then ran on the flush direction's own backoff: ${failing.waitedMs} ms later ` + + `flushFailures=${failing.sync?.flushFailures} (climbing toward the announce-after-three ` + + `threshold SYNC.md §3 sets, and row 49 measures) with the sentence ` + + `${j(sentence.slice(0, 100))} — prose, no object name, no material. The store was then ` + + `healed and left alone: ${healed.waitedMs} ms later sync=${j(healed.sync)} — the kit ` + + `was retired by a RETRY, which is only safe because absence is success by contract ` + + `(engine.wit: "a retry after partial success must not error on an object that is ` + + `already gone").`, + ); + }); + + // --- 62: a FILE kit end to end against the fake Drive ------------------ + // + // THE PROVIDER HALF OF THE DESIGN, executable. Bucket kits are + // S3-ONLY at this rev (the kind needs an owner-tier PUT at a name + // the guest DERIVES, and only S3 addresses objects by name), so the + // promise that "no provider loses recovery coverage" rests entirely + // on the FILE kit — which stores no object and therefore works + // anywhere. This row is that promise, run. + // + // IT IS ALSO THE `recovery-consume` GDRIVE ARM'S PROMISED COVERAGE. + // The engine's own battery runs against MinIO, so its S3 arm is the + // one `just recover` exercises; recovery.rs's `delete_own_pickup` + // names these devstore rows as where the Drive arm gets its + // executable coverage instead of duplicating a fake Drive inside the + // native rig for one delete. + // + // THE CREDENTIALS DO NOT RIDE THE BUNDLE, and the two-stage ceremony + // is that rule made structural: the restoring device runs its OWN + // consent (`restorePrepare` opens the namespace so the consent has + // somewhere sealed to land) before the restore validates a binding + // against it. An OAuth token is device-scoped by DRIVE.md's ruling + // and there is nowhere in a bundle to put one. + const RC_GD_ROOT = "pm-recovery-gd"; + // Row 60 rides this pair; see the note there for why it must be a + // pair that has crossed no revocation epoch. + let rcGdOrigin = ""; + let rcGdRestored = ""; + const rcGdBinding = { + kind: "gdrive" as const, + root: RC_GD_ROOT, + apiBase: gdOrigin, + clientId: GD_CLIENT_ID, + space: "drive" as const, + }; + await guard(async () => { + const made = await probe(page, "hc-make", { + petname: "recovery-gdrive", + policy: "until-reseal", + promote: true, + }); + const originId = made.id as string; + rcGdOrigin = originId; + await probe(page, "hc-open", { + id: originId, + unseal: { passphrase: PASS, untilReseal: true }, + }); + const consent = await startAndFetchAuth(page, originId, gdriveSpec); + await probe(page, "gd-oauth-complete", { + id: originId, + code: consent.code, + state: consent.state, + }); + const bound = await probe(page, "hc-bind", { id: originId, binding: rcGdBinding }); + await probe(page, "hc-ensure-bucket", { id: originId }); + await probe(page, "rc-account-create", { + id: originId, + displayName: "Synthetic Drive Account", + }); + const titles = ["a drive todo", "a second drive todo"]; + await probe(page, "hc-add", { id: originId, titles }); + await probe(page, "rc-flush-now", { id: originId }); + + const kit = await probe(page, "rc-kit-create", { + id: originId, + spec: { kind: "file", label: "drive file kit", passphrase: FILE_PASS }, + }); + + // The restoring device: prepare (DEK), consent (sealed under it), + // then restore. The consent's code/state are fetched here for the + // same reason every other gdrive row fetches them here — the page + // cannot follow the fake's 302 for itself. + const fresh = await probe(page, "rc-prepare", { petname: "restored-from-file" }); + const freshId = fresh.id as string; + const consent2 = await startAndFetchAuth(page, freshId, gdriveSpec); + const restored = await probe(page, "rc-restore", { + id: freshId, + petname: "restored-from-file", + binding: rcGdBinding, + handle: kit.handle, + kind: "file", + passphrase: FILE_PASS, + deviceName: "the drive machine it became", + oauth: { code: consent2.code, state: consent2.state }, + }); + const id = restored.id as string; + rcGdRestored = id; + const after = restored.attempt.refused + ? { titles: [], n: 0 } + : await probe(page, "hc-items", { id }); + const sync = restored.status.sync; + const devices = restored.attempt.refused + ? { names: [] } + : await probe(page, "rc-devices", { id }); + + const ok = bound.attempt.refused === false && kit.attempt.ok === true && + kit.kind === "file" && kit.bytes > 0 && + fresh.attempt.refused === false && fresh.status.sealed === false && + restored.attempt.refused === false && + restored.status.storage?.kind === "gdrive" && + j(after.titles) === j([...titles].sort()) && + sync !== null && sync.consumePending === false && + devices.names.includes("the drive machine it became"); + record( + "62 recovery", + "a FILE kit restores a Drive-bound account end to end, and the consume lands there too", + ok, + `a Google-Drive-bound account (its own store root, its own consent) minted a FILE kit of ` + + `${kit.bytes} sealed bytes — no object stored anywhere, which is why this kind works ` + + `on a provider that cannot address objects by a name the guest derives, and why ` + + `RECOVERY.md can say no provider loses recovery coverage while bucket kits stay ` + + `S3-only. A fresh namespace then ran the TWO-STAGE ceremony: restorePrepare opened it ` + + `(sealed=${fresh.status.sealed}) so the Drive consent had somewhere sealed to land — ` + + `credentials never ride bundles, and an OAuth grant is device-scoped — and the restore ` + + `followed with bytes + passphrase + destination. It came up bound ` + + `(${j(restored.status.storage?.kind)}) holding ${j(after.titles)}, and its devices ` + + `entry reads ${j(devices.names)}. THE CONSUME SUCCEEDED ON DRIVE: ` + + `sync.consumePending=${sync?.consumePending} — this is the executable coverage ` + + `recovery.rs's \`delete_own_pickup\` promises for the gdrive arm, which the engine's ` + + `MinIO-only battery cannot reach.`, + ); + }); + + // --- 63: the us-doc rides the cycle, with no button anywhere ----------- + // + // RECOVERY.md unparks what SYNC.md §3 explicitly parked: "the worker's + // flush/pull cycle MUST include [the us-doc] — the engine flushes the + // us-doc only at the moments it controls (kit create, revoke, + // consume), and a restore can only be as fresh as the last us flush." + // + // The claim is therefore about the SCHEDULER and not about a + // ceremony, so nothing in this row presses anything. Device A + // changes its PROFILE — a us-doc write, and one that is not in + // rpc.ts's READONLY_METHODS, so it arms the same 20 s flush debounce + // a todo does — and then two waits: A's own `lastFlush` moving, and + // B (a sibling with NO wire between them, only the bucket) reporting + // the change through the ordinary surfaces. + // + // B'S WITNESS IS THE EVENT QUEUE, which is the honest one: `usEvents` + // is where a visor's announcements come from, and local-echo + // suppression is engine-side, so `profile-changed` arriving there + // means B learned it from somewhere other than itself. + // + // THE PAIR IS ROW 62'S, and the choice is a FINDING rather than a + // convenience. Rows 59-61 put the S3 account through a + // `recovery-kit-revoke`, which rotates the us-doc's NAME-KEY EPOCH + // (that is the "hard forward" half of the guarantee note), and a + // sibling that has not yet caught up derives object names from the + // chain it holds — which it can only refresh by reading the us-doc, + // whose newest objects now sit under the NEW epoch's names. Measured + // here: after that revocation the restored device kept reading the + // origin's stale epoch-0 manifest and never saw the profile change. + // That is a pre-existing property of rotation-plus-a-lagging-device + // (SYNC.md's territory, not this round's), and pinning this row to a + // pair that has crossed no revocation keeps it a measurement of the + // us-doc riding the cycle rather than of that separate question. + // Flagged in the track report. + await guard(async () => { + const idA = rcGdOrigin; + const idB = rcGdRestored; + const NEW_NAME = "Renamed On The Other Device"; + const beforeA = (await probe(page, "hc-status", { id: idA })).sync; + // Drain B's queue first, so anything the row observes afterwards + // is this row's own change and not an older one. + await probe(page, "rc-events", { id: idB }); + + const set = await probe(page, "rc-profile-set", { id: idA, displayName: NEW_NAME }); + const flushedA = await untilSync( + page, + idA, + "A's scheduled flush after a us-doc write", + (s) => s.lastFlush !== null && (beforeA?.lastFlush === null || s.lastFlush > beforeA.lastFlush), + 60_000, + ); + // B pulls on the ordinary 45 s cadence; the us-doc goes FIRST in + // its cycle, which is what makes the profile arrive at all. + const deadline = Date.now() + 120_000; + let profileB = { displayName: "" }; + let kindsB: string[] = []; + while (Date.now() < deadline) { + profileB = await probe(page, "rc-profile-get", { id: idB }); + const ev = await probe(page, "rc-events", { id: idB }); + kindsB = [...kindsB, ...ev.kinds]; + if (profileB.displayName === NEW_NAME) break; + await new Promise((r) => setTimeout(r, 2_000)); + } + const syncB = (await probe(page, "hc-status", { id: idB })).sync; + + const ok = set.attempt.refused === false && flushedA.ok && + flushedA.sync.flushFailures === 0 && + profileB.displayName === NEW_NAME && + kindsB.includes("profile-changed") && + syncB !== null && syncB.lastPull !== null; + record( + "63 recovery", + "the account document rides the ordinary flush/pull cycle — SYNC.md's parked item, unparked", + ok, + `device A changed its account PROFILE and NOTHING ELSE was called. A us-doc write is a ` + + `mutation like any other (it is absent from rpc.ts's READONLY_METHODS), so it armed ` + + `the same ~20 s flush debounce a todo does: ${flushedA.waitedMs} ms later A's ` + + `status().sync=${j(flushedA.sync)} — a completed scheduled cycle, which now includes ` + + `the account document under the EMPTY doc-id sentinel (engine.wit's bucket-flush; the ` + + `us id itself stays hidden and an empty id was meaningless on every arm before this). ` + + `Device B — a SIBLING with no wire of any kind between them, only the shared bucket — ` + + `then pulled on its ordinary 45 s cadence, where the us-doc goes FIRST so the content ` + + `pulls chain off the map it brings in. B's profile now reads ${j(profileB.displayName)} ` + + `and its event queue carried ${j(kindsB)}: \`profile-changed\` arrived through the ` + + `ORDINARY announcement surface, and local-echo suppression is engine-side, so B could ` + + `only have learned it from A. B's own sync record: ${j(syncB)}.`, + ); + await probe(page, "hc-close", { id: idA }); + await probe(page, "hc-close", { id: idB }); + }); + + // --- 64: the consume survives the worker's death ------------------------ + // + // THE STRAND, PINNED. A consume's last act is a write to the LIVE + // us-doc (`recovery_clear`) plus the guest's own flush of it. Both + // land, and without a checkpoint after them NEITHER survives a + // respawn: + // + // * the worker calls `recoveryConsume()` INTERNALLY, and the + // mutation debounce lives in `call()`, which dispatches CLIENT + // requests only — so nothing arms a checkpoint, and a resume + // rewinds the account to before the consume; + // * the flushed copy is under THIS device's own keyed names, and + // `pullCycle` self-filters a device out of its own fan-out — so + // the durable copy is permanently out of its author's reach. It + // heals only when ANOTHER device pulls it and re-manifests it, + // which is exactly what an account that just spent its + // last-resort kit does not have. + // + // The symptom is a restored device whose own kit list still shows + // the kit it consumed — reported from the solo page's fork-door + // restore, which reloads on completion and so respawns the worker. + // Row 56 asserts the ORIGIN device's view; this asserts the + // RESTORED device's, across a real death. + // + // WHY A SECOND CHECKPOINT AND NOT AN EARLIER ONE: the restore's + // checkpoint stays BEFORE the consume, because a crash between a + // consume and a first checkpoint would burn the kit with nothing + // durable to show for it — a lockout. So the fix adds a checkpoint + // after a SUCCESSFUL consume and changes no existing ordering. + await guard(async () => { + const kit = await probe(page, "rc-kit-create", { + id: rcDevice, + spec: { kind: "bucket", label: "a kit that gets consumed and killed" }, + }); + await probe(page, "rc-flush-now", { id: rcDevice }); + const restored = await probe(page, "rc-restore", { + petname: "restored-then-killed", + binding: rcBinding, + handle: kit.handle, + kind: "bucket", + deviceName: "a device that outlives its kit", + }); + const id = restored.id as string; + const live = restored.attempt.refused + ? { attempt: { ok: false }, kits: [{ kind: "?" }] } + : await probe(page, "rc-kits", { id }); + + // THE KILL: `__die` closes the worker's own global, which is a + // crash on demand — the lock and the lease go exactly as they + // would if the process had died (rows 11, 21, 50's discipline). + const died = await probe(page, "hc-die", { id }); + const back = await probe(page, "hc-open", { id, unseal: { passphrase: PASS } }); + const afterKill = await probe(page, "rc-kits", { id }); + + const ok = kit.attempt.ok === true && restored.attempt.refused === false && + restored.status.sync?.consumePending === false && + live.attempt.ok === true && live.kits.length === 0 && + died.lockHeld === false && + back.unseal.refused === false && back.status.resumed === true && + afterKill.attempt.ok === true && afterKill.kits.length === 0; + record( + "64 recovery", + "a consumed kit STAYS consumed across the worker's death — the restored device's own view", + ok, + `a fresh kit was minted and used; the restore's consume succeeded ` + + `(consumePending=${restored.status.sync?.consumePending}) and the restored device's ` + + `OWN kit list read ${j(live.kits)} — the guest is consistent the moment it finishes. ` + + `The worker was then KILLED (lock released: ${died.lockHeld === false}) and a fresh ` + + `one opened the same namespace, resuming from the checkpoint ` + + `(resumed=${back.status.resumed}). Its kit list is ${j(afterKill.kits)}. WITHOUT the ` + + `post-consume checkpoint this reads back as the spent kit, and neither half of the ` + + `consume saves the other: the worker calls \`recoveryConsume()\` internally, so the ` + + `mutation debounce in \`call()\` — which dispatches CLIENT requests only — never arms, ` + + `and the clear that WAS flushed sits under this device's own keyed names, which ` + + `\`pullCycle\` self-filters out of its own fan-out. Durable in the bucket, invisible ` + + `to its author, forever, on the one device that just spent the account's last-resort ` + + `kit. The restore's FIRST checkpoint deliberately still precedes the consume: a crash ` + + `in between would burn the kit with nothing durable to show for it.`, + ); + await probe(page, "hc-close", { id }); + }); + + await probe(page, "hc-close", { id: rcDevice }); + await probe(page, "hc-close", { id: rcRestored }); + await ctx.close(); } finally { await browser.close(); await server.shutdown(); await s3Server.shutdown(); + await objects.server.shutdown(); await fake.close(); } diff --git a/visor/ui/entry.ts b/visor/ui/entry.ts index 4fbaab1..f6ff887 100644 --- a/visor/ui/entry.ts +++ b/visor/ui/entry.ts @@ -137,6 +137,31 @@ export interface DevicePickerHost { * device never needs a passphrase, so a refusal here is only ever a * report. */ openNew(): Promise; + /** RESTORE AN ACCOUNT FROM A RECOVERY KIT (runtime/RECOVERY.md, + * "Restore"). The account outlives its last device, so this door must + * exist on a browser that holds NO device of that account — which is + * every browser a real recovery happens on. + * + * THE CEREMONY IS THE HOST'S, exactly as `openWithPasskey`'s is, and + * for a stronger reason: restoring collects a destination, storage + * credentials, a kit secret and a device name, and drives a + * multi-stage worker bring-up. None of that is the visor's to know. + * The visor renders the door; the host walks it. + * + * THIS MODULE CLOSES THE PICKER BEFORE CALLING, and does not reopen + * it. That is a departure from the resolve/reject contract above and + * it is deliberate: the picker is an EXCLUSIVE drawer tenant, so a + * ceremony that needs the drawer cannot have it while the picker + * holds it. The host therefore owns the whole drawer from here on — + * including putting the user back at a usable entry surface if the + * restore is abandoned or refused (runtime/RECOVERY.md's ceremony + * must never wedge the way in). A rejection is not rendered here, + * because by then there is nothing here to render into. + * + * OPTIONAL because an embedder may ship no recovery path at all; the + * control is simply not drawn on such a host, rather than drawn and + * then apologised for. */ + restore?(): Promise; } /** Read a rejection as a refusal, without trusting its shape. */ @@ -267,7 +292,23 @@ export function mountDevicePicker( newBtn.id = "device-new"; newBtn.textContent = "Set up a new device here"; + // THE RECOVERY DOOR. Drawn only when the host has one — and drawn + // WHATEVER `rows` holds, because the question it answers ("all my + // devices are gone") is independent of what this browser happens to + // remember. A browser with three unrelated devices in its index is + // still a browser someone may be recovering an account onto. + // + // SECONDARY, not primary: recovery is the rare door, and a control + // with the same weight as "open this device" would read as an + // invitation rather than a way out of a disaster. + const restoreBtn = document.createElement("button"); + restoreBtn.type = "button"; + restoreBtn.id = "device-restore"; + restoreBtn.className = "entry-secondary"; + restoreBtn.textContent = "Restore from a recovery kit…"; + root.append(heading, note, list, pass, passkey, problem, newBtn); + if (host.restore !== undefined) root.append(restoreBtn); /** THE HEIGHT IS MEASURED, so every visibility change owes the drawer * a re-measure: the sheet animates to a pixel target and clips @@ -424,6 +465,20 @@ export function mountDevicePicker( if (opts.problem !== undefined && opts.problem !== "") showProblem(opts.problem); + // THE HANDOVER (see `DevicePickerHost.restore`): close first, then + // call. The picker is exclusive, so the drawer has to be given up + // before a ceremony that needs it can open — and closing first also + // means a host whose ceremony throws immediately cannot leave two + // sheets contending for the same slot. + restoreBtn.onclick = () => { + if (busy) return; + const run = host.restore; + if (run === undefined) return; + busy = true; + tenant.close(); + void run(); + }; + tenant.open({ root }, () => ({ root })); return { @@ -440,6 +495,21 @@ export interface FirstRunHost { * created, and this module closes the sheet. Reject to re-enable the * fork and show the message on it. */ newAccount(): Promise; + /** RESTORE AN ACCOUNT FROM A RECOVERY KIT — the same door the picker + * carries, on the surface a VIRGIN BROWSER actually lands on. + * + * WHY IT IS HERE AS WELL, and this is the important half: a browser + * with no devices never sees the picker at all (the first-run path + * makes a device without asking — PERSISTENCE.md's try-then-keep), and + * a browser with no devices is exactly the browser a real recovery + * happens on. A recovery door that only appeared once you already had + * a device would be a door on the wrong side of the disaster. + * + * Same handover contract as `DevicePickerHost.restore`: this module + * closes the fork before calling and does not reopen it; the host owns + * the drawer, the ceremony, and returning the user to a usable + * surface. OPTIONAL for the same reason. */ + restore?(): Promise; } /** @@ -542,12 +612,37 @@ export function offerFirstRun( "it there, and both of you check the same six digits.", ); + // THE THIRD CHOICE — see `FirstRunHost.restore`. Rendered as a quiet + // control rather than a third peer of the two above: it is the answer + // to a disaster, not a way to start. + const restoreBtn = document.createElement("button"); + restoreBtn.type = "button"; + restoreBtn.id = "solo-restore-account"; + restoreBtn.className = "entry-secondary"; + restoreBtn.textContent = "Restore from a recovery kit…"; + const restoreChoice = choice( + restoreBtn, + "Every device for this account is gone, and you kept a recovery kit — a phrase, or a " + + "file and its passphrase. You will need the storage this account syncs through as well.", + ); + const build = () => { if (phase === "join") root.replaceChildren(joinHeading, joinContainer); - else root.replaceChildren(forkHeading, newChoice, joinChoice, problem); + else if (host.restore !== undefined) { + root.replaceChildren(forkHeading, newChoice, joinChoice, restoreChoice, problem); + } else root.replaceChildren(forkHeading, newChoice, joinChoice, problem); return { root }; }; + restoreBtn.onclick = () => { + const run = host.restore; + if (run === undefined) return; + // Close first, then call — the handover the picker's door makes, for + // the same reason: the ceremony needs the drawer this sheet is in. + tenant.close(); + void run(); + }; + newBtn.onclick = () => { newBtn.disabled = true; joinBtn.disabled = true; diff --git a/visor/ui/visor.css b/visor/ui/visor.css index 3642332..e43accf 100644 --- a/visor/ui/visor.css +++ b/visor/ui/visor.css @@ -1071,3 +1071,47 @@ .settings-sheet .settings-head h2 { flex: 1 1 100%; } .settings-sheet .settings-reset { max-width: 100%; margin-left: auto; } } + +/* --- account recovery (runtime/RECOVERY.md) ------------------------------- + + THE PHRASE, DISPLAYED ONCE. It is set in a monospaced face, at a size + a person can read off a screen while writing on paper, and it WRAPS — + ten diceware words do not fit a phone's drawer on one line, and a + phrase clipped by an overflow rule is a kit the user believes they + have and does not. The generous line-height is for the same reader: + they are transcribing, not skimming, and word boundaries have to be + unambiguous. `user-select: all` makes one tap select the whole phrase, + because a partial selection copied to a note is the same silent + half-kit failure. */ +.recovery-phrase { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 15px; + line-height: 1.9; + word-spacing: .35em; + overflow-wrap: break-word; + user-select: all; + margin: .6em 0; + padding: .7em .8em; + border-radius: 6px; + background: rgba(255, 255, 255, .07); +} + +/* THE FILE KIT'S WARNING — the owner's amendment, and the one block of + copy in the visor that is meant to SLOW A READER DOWN. It reuses + .entry-problem's alarm colour (the sheet's existing vocabulary for + "read this") and adds the weight and the rule that stop it reading as + one more hint: a paragraph the eye can skip is a paragraph that did + not warn anybody. */ +.recovery-warning { + font-size: 13px; + line-height: 1.5; + margin: .6em 0; + padding: .6em .7em; + border-left: 3px solid #ffd79a; + background: rgba(255, 215, 154, .08); +} + +/* A kit row is a DEVICE row (the round's core ruling), so it inherits + .device-row's layout and adds only what a kit has that a device does + not: a control that retires it. */ +.cred-sheet .recovery-row .recovery-revoke { font: inherit; }