diff --git a/apps/todomvc/src/lib.rs b/apps/todomvc/src/lib.rs index cb49f775..4153c00b 100644 --- a/apps/todomvc/src/lib.rs +++ b/apps/todomvc/src/lib.rs @@ -132,6 +132,31 @@ mod service { } } +/// `polyvisor:app/route`, thin like [`service`] above. +#[cfg(target_arch = "wasm32")] +mod route { + use crate::bindings::polyvisor::app::route; + + pub fn get() -> String { + route::get() + } + + pub fn set(route: &str) { + route::set(route) + } +} + +/// Off the component target there is no host to answer it; same reason as +/// `service`'s native stub just below. +#[cfg(not(target_arch = "wasm32"))] +mod route { + pub fn get() -> String { + String::new() + } + + pub fn set(_route: &str) {} +} + /// Off the component target there is no service and no host to answer it. /// The stub exists only so the components below type-check under a native /// `cargo clippy --workspace --all-targets`; it is never linked into the @@ -214,7 +239,16 @@ const STYLESHEET: &str = "asset:0f827d119b7bec30534b1767e8ab8ee0f2890c98f93baa11 pub fn app() -> Element { // The snapshot. Owned by the `tasks` service; this is a cached view of it. let items = use_signal(Vec::::new); - let filter = use_signal(|| FilterState::All); + // The route is this app's own prior output, relayed back by the visor — + // not user-typed input (`wit/app.wit` `route`: "a route this app is + // handed is one it wrote itself on one of the user's own devices"). An + // unknown value (including a plain launch's "") is simply `All`, the + // same as a value this app never wrote. + let filter = use_signal(|| match route::get().as_str() { + "active" => FilterState::Active, + "completed" => FilterState::Completed, + _ => FilterState::All, + }); // On mount: the first snapshot. use_future(move || refresh(items)); @@ -450,10 +484,14 @@ fn ListFooter( } } ul { class: "filters", - for (state , state_text , url) in [ - (FilterState::All, "All", "#/"), - (FilterState::Active, "Active", "#/active"), - (FilterState::Completed, "Completed", "#/completed"), + // `#/`, `#/active`, `#/completed` stay as in-frame anchors + // (the frame policy allows `#`-fragment hrefs); the visor's + // *page* fragment that carries the bookmarkable route is a + // different thing, set below via `route::set`. + for (state , state_text , url , route_value) in [ + (FilterState::All, "All", "#/", ""), + (FilterState::Active, "Active", "#/active", "active"), + (FilterState::Completed, "Completed", "#/completed", "completed"), ] { li { a { @@ -461,7 +499,8 @@ fn ListFooter( class: if filter() == state { "selected" }, onclick: move |evt: MouseEvent| { evt.prevent_default(); - filter.set(state) + filter.set(state); + route::set(route_value); }, {state_text} } diff --git a/docs/design.md b/docs/design.md index fb71957a..d28936c2 100644 --- a/docs/design.md +++ b/docs/design.md @@ -415,6 +415,70 @@ floor; predicates become bools at the WIT boundary; a Dioxus component that writes a signal it never reads renders once forever — invisible to native tests, so browser gates are mandatory for every visor change. +## Routing + +The page URL gains one thing: a fragment, `#app/`, naming a +running app and a route the app chose, so "this app, here" can be +bookmarked. The fragment is the only place for it — it never reaches the +server, and it is the one part of the URL an app frame's `href` policy +already treats as same-document (`web/policy.ts`). Two parts, two +owners: the visor owns the grammar (`/`, `app` the only kind +today; anything else is refused by `route-decode` exactly as an +unreadable token is), and the app owns the +text inside `route`, which the visor carries byte-for-byte and never +interprets. + +- **The token is ciphertext, and deterministic.** `install-id ‖ route`, + zero-padded to 256 bytes, sealed under a *user* route key with + AES-256-GCM and a synthetic nonce (`HMAC(k_siv, plaintext)`), so equal + state is equal text: bookmarks dedupe, `replaceState` does not churn + the bar, and browser history sees one entry per state rather than one + per keystroke (`runtime/crates/kernel/src/route.rs`). What this buys + is both directions of the trust problem at once. Inbound, a route + that decrypts is one this user's visor wrote for this install — the + app is handed its own prior output, not attacker-typed input. + Outbound, the app never holds the key, so the URL bar, history sync, + screenshots and "recently closed" learn nothing of the route. The + fixed length closes the length channel; what remains is *that* an + update happened, when, and whether state changed — a bit or two per + update, to a reader who already holds the user's browser account. That + is the accepted residue, and it applies only to apps that import + `route` at all. +- **The key is user-level and lives sealed.** Bookmarks made on one + device should open on the user's others, so the key cannot be + device-derived; and the group document `us` is plaintext to relays by + ruling (`engine/src/vault.rs`), so it cannot live there. It lives in a + visor-owned document that rides the app-document machinery under the + reserved id `polyvisor:visor` (`engine/src/visor.rs`) — keyhive-sealed, + synced and checkpointed like any app tree, for free. The same + document holds the **install table**: a random 16-byte id per (user, + app), minted on first launch. The URL names the install, not the + package, so the concept of "an install" exists before installs are a + user-visible act; two devices that mint before pairing converge on one + key by automerge's last-writer-wins, and the loser's pre-pairing + bookmarks stop opening — stated, not fixed. +- **The glue owns the URL bar.** `shell.open-frame` writes the fragment + for the one open session, `close-frame` clears it, and an app's + `route.set` is relayed by the frame to the glue, debounced, encoded by + the kernel, and written with `history.replaceState` — never + `pushState`. The back button is the visor's; an app gets no history + entry. Routes over the cap (238 bytes) are refused and the bar does not + move. +- **Consumed once, after unseal.** The visor reads `shell.fragment` + exactly once per page load, at the first identity read that finds the + device open, and hands it to `apps.route-decode`; a sealed device + consumes it after the unseal ceremony. Nothing about the fragment runs + before the hue is painted, so a URL cannot influence the + grey-until-unseal sequence. `hashchange` is ignored: there is one + launch path. +- **Sharing is foreclosed here, by construction.** Only this user's + devices hold the key; another device answers "this link is not one + this device can open". A future shareable form is a different `kind` + with its own envelope and its own policy, not a relaxation of `app/`. +- **Struck: pairing codes in the fragment.** A code is either short + enough to type or key material that does not belong in a URL at all; + the fragment was a transport looking for a route. + ## Pins, with reasons | Dependency | Pin | Reason | diff --git a/e2e/run.ts b/e2e/run.ts index 2fa4efdf..c11f8e1c 100644 --- a/e2e/run.ts +++ b/e2e/run.ts @@ -687,7 +687,9 @@ async function pullUntilTodo(page: Page, title: string): Promise { await new Promise((r) => setTimeout(r, 2_000)); await remountTodoMvc(page); try { - await todoFrame(page).getByText(title).first().waitFor({ timeout: 3_000 }); + await todoFrame(page).getByText(title).first().waitFor({ + timeout: 3_000, + }); return; } catch { if (performance.now() > deadline) { @@ -734,11 +736,26 @@ async function addTodo(page: Page, title: string): Promise { } /** Reload the page and put TodoMVC back on screen: a fresh mount, which is - * a fresh `tasks.items` read. */ + * a fresh `tasks.items` read. A reload with the app open leaves `#app/` + * in the URL, and the visor auto-restores whatever fragment it finds after + * boot (docs/design.md "Routing"), so this — and every other + * reload-with-the-app-open site below — waits for that restore rather than + * pressing "Apps" itself, which would race the visor's own click. With no + * app open (a page that arrived at the bare origin) there is no fragment, + * and the press is the only way. */ async function remountTodoMvc(page: Page): Promise { + const bookmarked = await page.evaluate(() => + location.hash.startsWith("#app/") + ); await page.reload(); await visorReady(page); - await launchTodoMvc(page); + if (bookmarked) { + await page.waitForSelector("#app-zone iframe[sandbox]", { + timeout: 30_000, + }); + } else { + await launchTodoMvc(page); + } } /** @@ -992,7 +1009,9 @@ const scenarios: Scenario[] = [ // tab here would hide exactly the failure this scenario is for. await page.reload(); await visorReady(page); - await launchTodoMvc(page); + await page.waitForSelector("#app-zone iframe[sandbox]", { + timeout: 30_000, + }); const again = page.frameLocator("#app-zone iframe"); await again.getByText("write the gate").first().waitFor({ timeout: 30_000, @@ -1001,6 +1020,146 @@ const scenarios: Scenario[] = [ }, }, + { + // "This app, here" (docs/design.md "Routing"): the URL bar names a + // running session and the app's own route inside it, so a bookmark of + // it reopens the same app at the same filter — on this device only, + // because the token is sealed under a key only this user's devices + // hold (wit/app.wit `route`, internal.wit `apps.route-encode`). + name: "bookmark-round-trip", + async run(ctx, origin, browser) { + const page = await open(ctx, origin); + await visorReady(page); + // A device with no route key yet mints one on its first encode + // (README/dispatch: "minted on first use"); "kept" is not what that + // needs, but every other scenario that leans on a device surviving + // more than one page is kept first, and this one reloads twice. + await keepDevice(page, "the workbench"); + await launchTodoMvc(page); + // TodoMVC's footer — and the filter links in it — only renders with + // at least one todo (apps/todomvc/src/lib.rs: the footer is gated on + // `!items.read().is_empty()`), and the todo persists on this device, + // so one add here is enough for the reopened app in step (c) too. + await addTodo(page, "bookmark this filter"); + + const h0 = await page.evaluate(() => location.hash); + check( + /^#app\/[A-Za-z0-9_-]+$/.test(h0), + `a plain launch did not write a bookmarkable fragment: ${h0}`, + ); + + const filters = todoFrame(page).locator("ul.filters a"); + const active = filters.filter({ hasText: "Active" }); + const all = filters.filter({ hasText: "All" }); + + await active.click(); + await page.waitForFunction( + (want) => location.hash !== want, + h0, + { timeout: 10_000 }, + ); + const h1 = await page.evaluate(() => location.hash); + check(h1 !== h0, "the Active filter did not change the fragment"); + + // Deterministic encryption: the same (install, route) pair seals to + // the same token every time, so returning to "All" returns the URL + // to exactly H0 rather than to some other equally-valid encoding of + // the same plain launch. + await all.click(); + await page.waitForFunction( + (want) => location.hash === want, + h0, + { timeout: 10_000 }, + ); + await active.click(); + await page.waitForFunction( + (want) => location.hash === want, + h1, + { timeout: 10_000 }, + ); + + // Reopening H1 with no click at all: the visor decodes the fragment + // on boot, launches the app at the route it names, and the app + // starts already filtered — proving the route travelled through the + // URL and not through anything client-side kept warm. + await page.goto(origin + "/" + h1); + await page.reload(); + await visorReady(page); + await page.waitForSelector("#app-zone iframe[sandbox]", { + timeout: 30_000, + }); + eq( + await page.evaluate(() => location.hash), + h1, + "reopening a bookmark changed the fragment", + ); + const reopenedFilters = todoFrame(page).locator("ul.filters a"); + await reopenedFilters.filter({ hasText: "Active" }).evaluate((el) => + el.className + ).then((cls) => + check( + cls.includes("selected"), + `the reopened app was not filtered to Active: class="${cls}"`, + ) + ); + + // Closing the session is the glue's own act of clearing the bar + // (internal.wit `shell`: "clears it in close-frame") — no navigation + // involved, so this is the one place that is not also covered by the + // notice-only checks below. The strip's left half opens the running + // app's sheet, and "Close app" is in there. + await page.locator("#visor-app").click(); + await paneSettled(page); + await drawer(page).getByRole("button", { name: "Close app", exact: true }) + .click(); + await page.waitForFunction( + () => location.hash === "", + undefined, + { timeout: 10_000 }, + ); + + // A fragment that is not a token this device's route key sealed: + // `not-found`, and the visor says so in its own voice rather than + // opening anything. `goto` to a URL that differs only in its fragment + // is a same-document navigation — no boot, and the visor ignores + // `hashchange` by ruling (docs/design.md "Routing") — so the reload + // is what makes this a bookmark being opened rather than a bar edit. + await page.goto(origin + "/#app/not-a-real-token"); + await page.reload(); + await visorReady(page); + await page.waitForTimeout(2_000); + eq( + await page.locator("#app-zone iframe").count(), + 0, + "a bogus fragment opened a frame anyway", + ); + await drawer(page).getByText(/link/i).waitFor({ + timeout: 10_000, + }); + + // A second device — fresh context, fresh route key — cannot open + // H1 either: the token decrypts only under the key of the device + // (or its group) that sealed it, and this one has never paired. + const ctxB = await browser.newContext(); + try { + const b = await ctxB.newPage(); + await b.goto(origin + "/" + h1); + await visorReady(b); + await b.waitForTimeout(2_000); + eq( + await b.locator("#app-zone iframe").count(), + 0, + "another device's route key opened this bookmark", + ); + await drawer(b).getByText(/link/i).waitFor({ + timeout: 10_000, + }); + } finally { + await ctxB.close(); + } + }, + }, + { name: "frame-violation-ends-session", async run(ctx, origin) { @@ -1219,7 +1378,9 @@ const scenarios: Scenario[] = [ // fail for a reason it is not about. await b.reload(); await visorReady(b); - await launchTodoMvc(b); + await b.waitForSelector("#app-zone iframe[sandbox]", { + timeout: 30_000, + }); for (const title of ["from A", "from B"]) { await todoFrame(b).getByText(title).first().waitFor({ timeout: 30_000, @@ -1691,7 +1852,9 @@ async function main(): Promise { // context, not just this scenario's own: a scenario with a second // device fails at that device as often as at this one, and dumping // only `ctx` prints nothing at all when the failure is over there. - for (const page of browser?.contexts().flatMap((c) => c.pages()) ?? []) { + for ( + const page of browser?.contexts().flatMap((c) => c.pages()) ?? [] + ) { const dump = await page.evaluate(() => ({ strip: document.querySelector("#visor-strip")?.textContent ?? "", diff --git a/runtime/component/src/component.rs b/runtime/component/src/component.rs index 1d759a92..a4952031 100644 --- a/runtime/component/src/component.rs +++ b/runtime/component/src/component.rs @@ -703,6 +703,19 @@ impl guest::apps::Guest for Component { async fn asset(session: u32, handle: Vec) -> Result, Error> { kernel()?.asset(session, &handle).await.map_err(map_error) } + async fn route_encode(session: u32, route: String) -> Result { + kernel()? + .route_encode(session, route) + .await + .map_err(map_error) + } + async fn route_decode(fragment: String) -> Result { + let (app, route) = kernel()?.route_decode(fragment).await.map_err(map_error)?; + Ok(guest::apps::RouteTarget { + app: app_info(app), + route, + }) + } async fn close(session: u32) -> Result<(), Error> { kernel()?.close(session); Ok(()) diff --git a/runtime/crates/engine/src/doc.rs b/runtime/crates/engine/src/doc.rs index 737a8505..d6d72843 100644 --- a/runtime/crates/engine/src/doc.rs +++ b/runtime/crates/engine/src/doc.rs @@ -78,6 +78,13 @@ impl AppDoc { self.core.tree() } + /// The automerge machinery under this document, for a schema that is not + /// the tasks one: `crate::visor` writes its own keys at the same ROOT of + /// its own (reserved-id) app document. + pub(crate) const fn document(&mut self) -> &mut Document { + &mut self.core + } + pub fn save(&self) -> Vec { self.core.save() } diff --git a/runtime/crates/engine/src/lib.rs b/runtime/crates/engine/src/lib.rs index e3a5a18c..a5f76944 100644 --- a/runtime/crates/engine/src/lib.rs +++ b/runtime/crates/engine/src/lib.rs @@ -25,6 +25,7 @@ mod storage; mod transport; mod us; mod vault; +mod visor; pub use clock::EngineClock; pub use doc::{TaskSnapshot, TodoItem}; @@ -165,6 +166,14 @@ pub struct Engine + 'static> { /// `Rng` at every start (see [`Engine::new`]) — and this is that draw, /// kept until the group document is first opened. name_key_seed: [u8; 32], + /// This boot's fresh entropy, kept for the visor document's minting + /// (`crate::visor`): the route key is `mix(b"polyvisor:route-key", seed, + /// entropy)` and an install id is `sha256(b"polyvisor:install" ‖ seed ‖ + /// entropy ‖ app)`. The raw draw rather than one mixed value, because the + /// two formulas consume it differently; like `name_key_seed` it is only + /// ever read on the branch that mints, so a restored document keeps the + /// value it was founded with. + visor_entropy: [u8; 32], /// Restored-but-not-yet-hydrated state. `Engine::new` cannot talk to its /// own driver — the caller has not spawned it yet — so a restored /// snapshot's trees are handed to the driver on the first async call. @@ -265,6 +274,7 @@ impl + 'static> Engine { us: RefCell::new(us), name_key: RefCell::new(name_key), name_key_seed: mix(b"polyvisor:name-key", &seed, &entropy), + visor_entropy: entropy, members, conns: RefCell::new(Vec::new()), pending_hydration: RefCell::new((!hydrate.is_empty()).then_some(hydrate)), @@ -326,6 +336,63 @@ impl + 'static> Engine { self.mutate(app, move |doc| doc.remove(&id)).await } + // -- the visor document --------------------------------------------------- + + /// The user's route key, minted on first use. `wrote` says a local commit + /// was authored, which is the kernel's cue to checkpoint. + /// + /// Read and mint inside one mutation: the read that decides whether to + /// mint may not be separated from the write by an await, or two callers + /// racing the first use would mint twice and the second would overwrite + /// the first (see `crate::visor` on last-writer-wins). + pub async fn visor_route_key(&self) -> Result<([u8; 32], bool), String> { + let minted = mix(b"polyvisor:route-key", &self.seed, &self.visor_entropy); + self.mutate(visor::VISOR_APP, move |doc| { + Ok(match visor::route_key(doc) { + Some(key) => (key, false), + None => { + visor::set_route_key(doc, minted)?; + (minted, true) + } + }) + }) + .await + } + + /// The install id for `app` — the existing one, or a fresh one. + /// + /// Existing wins, and where two unpaired devices each minted one it is the + /// smallest that wins: both entries survive the merge, so the choice has + /// to be a rule both devices apply identically (`crate::visor`). + pub async fn visor_install(&self, app: &str) -> Result<([u8; 16], bool), String> { + let mut hasher = Sha256::new(); + hasher.update(b"polyvisor:install"); + hasher.update(self.seed); + hasher.update(self.visor_entropy); + hasher.update(app.as_bytes()); + let digest = hasher.finalize(); + let mut minted = [0u8; 16]; + minted.copy_from_slice(&digest[..16]); + let app = app.to_string(); + self.mutate(visor::VISOR_APP, move |doc| { + if let Some((id, _)) = visor::installs(doc) + .into_iter() + .find(|(_, held)| *held == app) + { + return Ok((id, false)); + } + visor::add_install(doc, minted, &app)?; + Ok((minted, true)) + }) + .await + } + + /// Every (install id, app id) the visor document holds. + pub async fn visor_installs(&self) -> Result, String> { + self.open_app(visor::VISOR_APP).await?; + self.with_app(visor::VISOR_APP, |doc| Ok(visor::installs(doc))) + } + // -- the user-system document -------------------------------------------- /// This device's group, oldest enrollment first. diff --git a/runtime/crates/engine/src/visor.rs b/runtime/crates/engine/src/visor.rs new file mode 100644 index 00000000..bc71fdbb --- /dev/null +++ b/runtime/crates/engine/src/visor.rs @@ -0,0 +1,146 @@ +//! The visor's own document: the user's route key and the install ids that +//! bookmarkable URLs are written against. +//! +//! ## Why it rides the app-document machinery +//! +//! This is not a third document kind. It is the `apps` entry under a reserved +//! app id, [`VISOR_APP`], whose tree is `tasks_tree("polyvisor:visor")` like +//! any other app's — so keyhive sealing, sync, snapshot/restore, compaction +//! and the adoption path all apply to it unchanged, and nothing in the engine +//! needs a case for it. The id is reserved by being unspellable as a real app +//! id: an app is named `polyvisor:app/`. +//! +//! ## Shape +//! +//! ```text +//! route-key: str # lowercase hex, 32 bytes +//! : { app: str } +//! ``` +//! +//! At the automerge ROOT, and for the reason `crate::doc`'s module docs give +//! for the tasks document: two devices that each create a nested map before +//! meeting create two distinct objects at one key, and automerge resolves that +//! by keeping one and silently dropping the loser's whole subtree. The root +//! object is automerge's own and is the same object everywhere. +//! +//! ## Two founders, and what it costs the loser +//! +//! `route-key` is a scalar, so two devices that each mint one before they are +//! paired do not merge: automerge picks a winner by last-writer-wins and the +//! other key is gone. That is deliberate — a merge would need a second key to +//! stay live, and then a URL would no longer name one key — and it has a +//! visible consequence: the losing device's bookmarks made before pairing no +//! longer decrypt, and open as "not a link this device can open". Bookmarks +//! taken after pairing are stable forever. +//! +//! Install ids do merge: both survive as separate root keys. Lookup by app +//! therefore picks the smallest id (a total order both devices agree on), and +//! lookup by id resolves either — an older URL keeps working. +//! +//! ## Why the key cannot live in `us` +//! +//! The user-system document is deliberately *not* enveloped (`crate::vault` +//! module docs: a device must be able to read `us` before it has any keyhive +//! state, so `us` is plaintext on the wire and in the store, and a relay sees +//! it). It gives away nothing today because it holds only endpoint public keys +//! and petnames. A route key put there would be handed to every relay and to +//! the user's own storage provider in the clear, and with it every bookmark's +//! app and route. This document is an app document, so it is sealed. + +use automerge::{ObjType, ROOT, ReadDoc, transaction::Transactable}; + +use crate::doc::AppDoc; + +/// The reserved app id the visor's own document lives under. +pub const VISOR_APP: &str = "polyvisor:visor"; + +/// The root key the user's route key is spelled at. +pub const ROUTE_KEY: &str = "route-key"; + +/// The field an install entry names its app in. +const APP: &str = "app"; + +/// The user's route key, if this document holds one. +/// +/// A value that is not 32 bytes of hex is read as absent: the only writer is +/// [`set_route_key`], so anything else is a document from a future schema, and +/// refusing to guess is better than handing the kernel a key that decrypts +/// nothing. +pub fn route_key(doc: &mut AppDoc) -> Option<[u8; 32]> { + let text = doc + .document() + .read() + .get(ROOT, ROUTE_KEY) + .ok() + .flatten() + .and_then(|(value, _)| value.to_str().map(str::to_string))?; + let bytes = unhex(&text)?; + <[u8; 32]>::try_from(bytes).ok() +} + +/// Write the user's route key. The caller has already established there is +/// none; writing over one would break every bookmark this user holds. +pub fn set_route_key(doc: &mut AppDoc, key: [u8; 32]) -> Result<(), String> { + let text = hex(&key); + doc.document() + .transact(move |tx| tx.put(ROOT, ROUTE_KEY, text).map_err(|e| e.to_string())) +} + +/// Every (install id, app id) this document holds, smallest id first. +pub fn installs(doc: &mut AppDoc) -> Vec<([u8; 16], String)> { + let read = doc.document().read(); + let mut found: Vec<([u8; 16], String)> = Vec::new(); + for key in read.keys(ROOT) { + if key == ROUTE_KEY { + continue; + } + let Some(id) = unhex(&key).and_then(|bytes| <[u8; 16]>::try_from(bytes).ok()) else { + continue; + }; + let Ok(Some((_value, entry))) = read.get(ROOT, &key) else { + continue; + }; + let Some(app) = read + .get(&entry, APP) + .ok() + .flatten() + .and_then(|(value, _)| value.to_str().map(str::to_string)) + else { + continue; + }; + found.push((id, app)); + } + // `keys` is automerge's own order over the root map; the smallest-id rule + // this schema promises is ours to impose. + found.sort(); + found +} + +/// Record `id` as an install of `app`. +pub fn add_install(doc: &mut AppDoc, id: [u8; 16], app: &str) -> Result<(), String> { + let key = hex(&id); + let app = app.to_string(); + doc.document().transact(move |tx| { + let entry = tx + .put_object(ROOT, &key, ObjType::Map) + .map_err(|e| e.to_string())?; + tx.put(&entry, APP, app).map_err(|e| e.to_string()) + }) +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +fn unhex(text: &str) -> Option> { + if !text.len().is_multiple_of(2) { + return None; + } + text.as_bytes() + .chunks(2) + .map(|pair| { + let pair = std::str::from_utf8(pair).ok()?; + u8::from_str_radix(pair, 16).ok() + }) + .collect() +} diff --git a/runtime/crates/engine/tests/converge.rs b/runtime/crates/engine/tests/converge.rs index 6e790969..c5bd077d 100644 --- a/runtime/crates/engine/tests/converge.rs +++ b/runtime/crates/engine/tests/converge.rs @@ -1495,3 +1495,108 @@ fn a_third_device_learns_the_first_era_from_the_second() { ); }); } + +#[test] +fn the_route_key_is_minted_once() { + let mut pool = LocalPool::new(); + let a = device(&pool, 40, None); + let ea = Rc::clone(&a.engine); + + pool.run_until(async move { + let (first, wrote) = ea.visor_route_key().await.unwrap(); + assert!(wrote, "the first call mints, and the kernel checkpoints"); + let (second, wrote) = ea.visor_route_key().await.unwrap(); + assert!(!wrote, "the second call finds the key already there"); + assert_eq!(first, second, "and it is the same key"); + }); +} + +#[test] +fn an_install_id_is_per_app_and_stable() { + let mut pool = LocalPool::new(); + let a = device(&pool, 41, None); + let ea = Rc::clone(&a.engine); + + pool.run_until(async move { + let (todo, wrote) = ea.visor_install("polyvisor:app/todomvc").await.unwrap(); + assert!(wrote); + let (again, wrote) = ea.visor_install("polyvisor:app/todomvc").await.unwrap(); + assert!( + !wrote, + "the same app resolves to the install it already has" + ); + assert_eq!(todo, again); + + let (other, wrote) = ea.visor_install("polyvisor:app/notes").await.unwrap(); + assert!(wrote); + assert_ne!(todo, other, "a different app is a different install"); + + let mut listed = ea.visor_installs().await.unwrap(); + listed.sort_by_key(|(_, app)| app.clone()); + assert_eq!( + listed, + vec![ + (other, "polyvisor:app/notes".to_string()), + (todo, "polyvisor:app/todomvc".to_string()), + ], + ); + }); +} + +#[test] +fn a_snapshot_restores_the_route_key_and_the_installs() { + let mut pool = LocalPool::new(); + let a = device(&pool, 42, None); + let ea = Rc::clone(&a.engine); + let (snapshot, key, installs) = pool.run_until(async move { + let (key, _wrote) = ea.visor_route_key().await.unwrap(); + let _install = ea.visor_install("polyvisor:app/todomvc").await.unwrap(); + ( + ea.snapshot().await.unwrap(), + key, + ea.visor_installs().await.unwrap(), + ) + }); + + let mut pool = LocalPool::new(); + let restored = device(&pool, 42, Some(snapshot)); + let engine = Rc::clone(&restored.engine); + pool.run_until(async move { + let (after, wrote) = engine.visor_route_key().await.unwrap(); + assert!(!wrote, "a restored device does not mint a second key"); + assert_eq!(after, key); + assert_eq!(engine.visor_installs().await.unwrap(), installs); + }); +} + +#[test] +fn two_founders_converge_on_one_route_key() { + // Each device mints before it has ever met the other, and the schema's + // answer is last-writer-wins on a scalar (`engine::visor`): after pairing + // both devices read one key — the loser's pre-pairing bookmarks are the + // documented cost. + let mut pool = LocalPool::new(); + let a = device(&pool, 43, None); + let b = device(&pool, 44, None); + let (ea, eb) = (Rc::clone(&a.engine), Rc::clone(&b.engine)); + + pool.run_until(async move { + let (key_a, _wrote) = ea.visor_route_key().await.unwrap(); + let (key_b, _wrote) = eb.visor_route_key().await.unwrap(); + assert_ne!(key_a, key_b, "two devices mint different keys"); + + wire(&ea, &eb).await; + + let (seen_a, seen_b) = until(|| async { + let (seen_a, _) = ea.visor_route_key().await.unwrap(); + let (seen_b, _) = eb.visor_route_key().await.unwrap(); + (seen_a == seen_b).then_some((seen_a, seen_b)) + }) + .await; + assert_eq!(seen_a, seen_b); + assert!( + seen_a == key_a || seen_a == key_b, + "the survivor is one of the two that were minted", + ); + }); +} diff --git a/runtime/crates/kernel/src/lib.rs b/runtime/crates/kernel/src/lib.rs index c513d44b..24cf9538 100644 --- a/runtime/crates/kernel/src/lib.rs +++ b/runtime/crates/kernel/src/lib.rs @@ -19,6 +19,7 @@ mod device; mod drive; mod events; mod pairing; +mod route; mod seal; mod store; mod sync; @@ -1027,6 +1028,72 @@ impl Kernel { }) } + // -- routes -------------------------------------------------------------- + + /// The fragment text (`app/`, no `#`) for this session's app at + /// `route` — internal.wit `apps.route-encode`. The app never sees the key + /// and never sees the token's construction; it says where it is and the + /// visor writes the URL. + pub async fn route_encode(&self, session: SessionId, route: String) -> Result { + self.open()?; + let app = self.session_app_id(session)?; + // Cloned out of the cell before the first await: nothing may hold a + // borrow of ours across a suspension point, because the host can + // re-enter while one is in flight (see `write_checkpoint`). + let engine = self.engine()?; + let (install, install_wrote) = engine.visor_install(&app).await.map_err(engine_failed)?; + let (key, key_wrote) = engine.visor_route_key().await.map_err(engine_failed)?; + // Both calls mint on first use, and a minted install id or route key + // that no checkpoint carries is a bookmark that stops resolving after + // a reload — same reason every `tasks_*` mutation checkpoints. + if install_wrote || key_wrote { + self.checkpoint().await?; + } + route::encode(&key, install, &route).map_err(|why| match why { + route::RouteError::TooLong => Error::new( + ErrorCode::Refused, + format!( + "this app's location is too long to put in a link ({} bytes; the limit is {})", + route.len(), + route::MAX_ROUTE + ), + ), + route::RouteError::Unreadable => { + Error::new(ErrorCode::Failed, "this link could not be written") + } + }) + } + + /// What a fragment names: the app, and the route the app wrote into it — + /// internal.wit `apps.route-decode`. Every way a fragment can fail to + /// open is one answer, because the honest thing to say about a link from + /// another user, another key or a flipped bit is the same (`crate::route`). + pub async fn route_decode(&self, fragment: String) -> Result<(AppInfo, String), Error> { + self.open()?; + let engine = self.engine()?; + let (key, wrote) = engine.visor_route_key().await.map_err(engine_failed)?; + if wrote { + self.checkpoint().await?; + } + let (install, route) = route::decode(&key, &fragment).map_err(|_| unopenable_link())?; + let installs = engine.visor_installs().await.map_err(engine_failed)?; + // An install id this device's visor document has never held decrypted + // under our key, so it is ours — but from a state we have not synced. + // That is the same "cannot open this" as a foreign link. + let app = installs + .into_iter() + .find(|(id, _)| *id == install) + .map(|(_, app)| app) + .ok_or_else(unopenable_link)?; + let info = self.registry.info(&app).ok_or_else(|| { + Error::new( + ErrorCode::UnknownApp, + "that link names an app that is no longer installed", + ) + })?; + Ok((info, route)) + } + // -- app services -------------------------------------------------------- pub async fn tasks_revision(&self, session: SessionId) -> Result { @@ -1277,3 +1344,17 @@ async fn open_or_mint( fn erased() -> Error { Error::new(ErrorCode::Unavailable, "this device has been erased") } + +/// The engine's failures reach the kernel as prose it passes on unread. +fn engine_failed(why: String) -> Error { + Error::new(ErrorCode::Failed, why) +} + +/// The one thing said about a fragment this device cannot open, whatever the +/// reason (`crate::route`). +fn unopenable_link() -> Error { + Error::new( + ErrorCode::NotFound, + "this link is not one this device can open", + ) +} diff --git a/runtime/crates/kernel/src/route.rs b/runtime/crates/kernel/src/route.rs new file mode 100644 index 00000000..26297561 --- /dev/null +++ b/runtime/crates/kernel/src/route.rs @@ -0,0 +1,287 @@ +//! Bookmarkable URLs: the grammar of the page fragment, and the sealing it +//! rides under (docs/design.md "Routing"). +//! +//! The grammar is one production and no options: +//! +//! ```text +//! fragment := "app/" token +//! token := base64url-nopad( 0x01 ‖ nonce(12) ‖ AES-256-GCM ciphertext ) +//! plaintext:= install-id(16) ‖ route-len(u16 BE) ‖ route ‖ zero pad to 256 +//! ``` +//! +//! `app/` is a *kind* prefix, not a path: a later kind is a new prefix, and a +//! fragment written by anything else is refused here rather than guessed at. +//! The key is the user's route key (`Engine::visor_route_key`), so a fragment +//! is meaningful only on this user's devices — sharing a link with someone +//! else is foreclosed by construction, not by a policy check that could be +//! forgotten. +//! +//! **Why deterministic.** The nonce is not drawn; it is +//! `HMAC-SHA256(k_siv, plaintext)[..12]` (SIV-style synthetic IV). Equal state +//! therefore gives an equal URL, which is what makes the fragment usable at +//! all: the glue rewrites it with `history.replaceState` on every `route.set` +//! an app relays, and a fresh nonce per write would make every keystroke a +//! new URL — history churn, and a bookmark that stops matching the page it +//! was taken from. Determinism is also what lets decode reject a +//! non-canonical encoding: it recomputes the nonce and requires equality, so +//! there is exactly one token per (install, route). +//! +//! **Why fixed length.** The plaintext is padded to 256 bytes, so every token +//! is the same length and the URL says nothing about how long the route is — +//! and a route is app state (a task id, a filter, a document name). What +//! padding does not close is that an update happened at a given moment, and +//! whether the state behind it changed (an unchanged route re-encodes to the +//! same token). That residual channel is accepted, named here, and not fixed. +//! +//! **Why the version byte is inside the AAD's protection.** `0x01` is the +//! first byte of the sealed blob and the AAD is `polyvisor:route:v1`, so the +//! version is authenticated twice over: an attacker cannot rewrite the byte +//! to steer a future runtime at a different construction, because the tag was +//! computed over an AAD naming this one. A v2 fragment is a new AAD, and a v1 +//! device reading it fails authentication rather than mis-parsing it. +//! +//! Every failure on the way in is one error — [`RouteError::Unreadable`] — +//! which the kernel answers `not-found` with a single message. A wrong key, a +//! flipped bit, a foreign prefix and a truncated token are not told apart, +//! because the answer to all four is the same: this device cannot open this +//! link. + +use aes_gcm::aead::{Aead, KeyInit, Payload}; +use aes_gcm::{Aes256Gcm, Nonce}; +use data_encoding::BASE64URL_NOPAD; +use hmac::{Hmac, Mac as _}; +use sha2::Sha256; + +type HmacSha256 = Hmac; + +/// The one fragment kind there is, with its separator. +const KIND: &str = "app/"; + +/// The version byte, first in the sealed blob. +const VERSION: u8 = 1; + +/// Authenticated, not encrypted: names the construction the tag was computed +/// under (see the module docs on the version byte). +const AAD: &[u8] = b"polyvisor:route:v1"; + +const INSTALL_LEN: usize = 16; +const NONCE_LEN: usize = 12; +const PLAINTEXT_LEN: usize = 256; + +/// The longest route that fits: the padded plaintext less the install id and +/// the length prefix (internal.wit `apps.route-encode`). +pub const MAX_ROUTE: usize = PLAINTEXT_LEN - INSTALL_LEN - 2; + +/// Why a fragment did not encode or decode. Two variants because the kernel +/// answers them differently: a route the caller can shorten is `refused` and +/// says so, and everything else is `not-found` and says nothing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RouteError { + /// The route is over [`MAX_ROUTE`] bytes. + TooLong, + /// Anything else: wrong prefix, wrong key, wrong version, bad base64, + /// a failed tag, a non-canonical nonce, non-zero padding, invalid UTF-8. + Unreadable, +} + +/// The whole fragment text for `install` at `route` — `app/`, without +/// the `#`, which belongs to whoever writes the URL. +pub fn encode( + key: &[u8; 32], + install: [u8; INSTALL_LEN], + route: &str, +) -> Result { + if route.len() > MAX_ROUTE { + return Err(RouteError::TooLong); + } + let mut plaintext = [0u8; PLAINTEXT_LEN]; + plaintext[..INSTALL_LEN].copy_from_slice(&install); + plaintext[INSTALL_LEN..INSTALL_LEN + 2].copy_from_slice(&(route.len() as u16).to_be_bytes()); + plaintext[INSTALL_LEN + 2..INSTALL_LEN + 2 + route.len()].copy_from_slice(route.as_bytes()); + + let (k_enc, k_siv) = subkeys(key); + let nonce = siv(&k_siv, &plaintext); + let ct = Aes256Gcm::new((&k_enc).into()) + .encrypt( + &Nonce::from(nonce), + Payload { + msg: &plaintext, + aad: AAD, + }, + ) + // Only a message longer than the AEAD's limit fails, and this one is + // a fixed 256 bytes. + .map_err(|_| RouteError::Unreadable)?; + + let mut blob = Vec::with_capacity(1 + NONCE_LEN + ct.len()); + blob.push(VERSION); + blob.extend_from_slice(&nonce); + blob.extend_from_slice(&ct); + Ok(format!("{KIND}{}", BASE64URL_NOPAD.encode(&blob))) +} + +/// The inverse: the install id the fragment names and the route it carries. +pub fn decode(key: &[u8; 32], fragment: &str) -> Result<([u8; INSTALL_LEN], String), RouteError> { + let token = fragment.strip_prefix(KIND).ok_or(RouteError::Unreadable)?; + let blob = BASE64URL_NOPAD + .decode(token.as_bytes()) + .map_err(|_| RouteError::Unreadable)?; + if blob.len() < 1 + NONCE_LEN || blob[0] != VERSION { + return Err(RouteError::Unreadable); + } + let (head, ct) = blob[1..].split_at(NONCE_LEN); + let nonce: [u8; NONCE_LEN] = head.try_into().expect("split at NONCE_LEN"); + + let (k_enc, k_siv) = subkeys(key); + let plaintext = Aes256Gcm::new((&k_enc).into()) + .decrypt(&Nonce::from(nonce), Payload { msg: ct, aad: AAD }) + .map_err(|_| RouteError::Unreadable)?; + if plaintext.len() != PLAINTEXT_LEN { + return Err(RouteError::Unreadable); + } + // Canonicality: a token whose nonce is not the one this plaintext derives + // is a second spelling of a fragment that already has one (module docs). + if siv(&k_siv, &plaintext) != nonce { + return Err(RouteError::Unreadable); + } + + let mut install = [0u8; INSTALL_LEN]; + install.copy_from_slice(&plaintext[..INSTALL_LEN]); + let len = u16::from_be_bytes([plaintext[INSTALL_LEN], plaintext[INSTALL_LEN + 1]]) as usize; + if len > MAX_ROUTE { + return Err(RouteError::Unreadable); + } + let body = &plaintext[INSTALL_LEN + 2..]; + let (route, pad) = body.split_at(len); + if pad.iter().any(|b| *b != 0) { + return Err(RouteError::Unreadable); + } + let route = std::str::from_utf8(route).map_err(|_| RouteError::Unreadable)?; + Ok((install, route.to_string())) +} + +/// The two subkeys, so the SIV computation and the encryption never share a +/// key: `k_enc = HMAC(route-key, "polyvisor:route:enc")`, `k_siv` likewise. +fn subkeys(key: &[u8; 32]) -> ([u8; 32], [u8; 32]) { + ( + mac(key, b"polyvisor:route:enc"), + mac(key, b"polyvisor:route:siv"), + ) +} + +fn mac(key: &[u8; 32], message: &[u8]) -> [u8; 32] { + let mut mac = HmacSha256::new_from_slice(key).expect("HMAC takes a key of any length"); + mac.update(message); + mac.finalize().into_bytes().into() +} + +/// The synthetic IV: the plaintext's own MAC, truncated. +fn siv(k_siv: &[u8; 32], plaintext: &[u8]) -> [u8; NONCE_LEN] { + let full = mac(k_siv, plaintext); + let mut nonce = [0u8; NONCE_LEN]; + nonce.copy_from_slice(&full[..NONCE_LEN]); + nonce +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Synthetic key material: all zeros, and an install id of 00 01 02… — + /// test vectors, never anything a device could have minted. + const KEY: [u8; 32] = [0u8; 32]; + const INSTALL: [u8; 16] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]; + + #[test] + fn round_trip() { + let fragment = encode(&KEY, INSTALL, "todo/active").unwrap(); + assert_eq!( + decode(&KEY, &fragment).unwrap(), + (INSTALL, "todo/active".to_string()) + ); + } + + #[test] + fn empty_route_round_trips() { + let fragment = encode(&KEY, INSTALL, "").unwrap(); + assert_eq!(decode(&KEY, &fragment).unwrap(), (INSTALL, String::new())); + } + + #[test] + fn deterministic() { + assert_eq!( + encode(&KEY, INSTALL, "todo/active").unwrap(), + encode(&KEY, INSTALL, "todo/active").unwrap() + ); + } + + #[test] + fn different_route_different_fragment() { + assert_ne!( + encode(&KEY, INSTALL, "todo/active").unwrap(), + encode(&KEY, INSTALL, "todo/done").unwrap() + ); + } + + #[test] + fn tampering_is_unreadable() { + let fragment = encode(&KEY, INSTALL, "todo/active").unwrap(); + // Flip one token character to another of the alphabet, well past the + // version byte, so the failure is the tag and not the framing. + let mut bytes = fragment.into_bytes(); + let last = bytes.len() - 1; + bytes[last] = if bytes[last] == b'A' { b'B' } else { b'A' }; + let tampered = String::from_utf8(bytes).unwrap(); + assert_eq!(decode(&KEY, &tampered), Err(RouteError::Unreadable)); + } + + #[test] + fn another_key_is_unreadable() { + let fragment = encode(&KEY, INSTALL, "todo/active").unwrap(); + let mut other = KEY; + other[0] = 1; + assert_eq!(decode(&other, &fragment), Err(RouteError::Unreadable)); + } + + #[test] + fn foreign_prefix_is_unreadable() { + let fragment = encode(&KEY, INSTALL, "todo/active").unwrap(); + let foreign = fragment.replace(KIND, "doc/"); + assert_eq!(decode(&KEY, &foreign), Err(RouteError::Unreadable)); + assert_eq!(decode(&KEY, ""), Err(RouteError::Unreadable)); + } + + #[test] + fn the_longest_route_fits_and_one_more_does_not() { + let route = "r".repeat(MAX_ROUTE); + let fragment = encode(&KEY, INSTALL, &route).unwrap(); + assert_eq!(decode(&KEY, &fragment).unwrap(), (INSTALL, route.clone())); + assert_eq!( + encode(&KEY, INSTALL, &format!("{route}r")), + Err(RouteError::TooLong) + ); + } + + /// The fragment goes into a URL unescaped, so every byte of it must be + /// one a fragment may hold verbatim. + #[test] + fn fragment_is_url_safe() { + for route in ["", "todo/active", &"r".repeat(MAX_ROUTE)] { + let fragment = encode(&KEY, INSTALL, route).unwrap(); + assert!( + fragment + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'/')), + "fragment holds a character a URL would have to escape" + ); + } + } + + /// Every route encodes to the same length: the padding's whole point. + #[test] + fn every_fragment_is_the_same_length() { + let short = encode(&KEY, INSTALL, "a").unwrap(); + let long = encode(&KEY, INSTALL, &"r".repeat(MAX_ROUTE)).unwrap(); + assert_eq!(short.len(), long.len()); + } +} diff --git a/runtime/wit/internal.wit b/runtime/wit/internal.wit index 04c23128..6fce5319 100644 --- a/runtime/wit/internal.wit +++ b/runtime/wit/internal.wit @@ -373,7 +373,27 @@ interface apps { plan: string, } + /// What a bookmark resolves to: the app it names and the route the app + /// wrote into it (`polyvisor:app/route`), "" for a plain launch. + record route-target { + app: app-info, + route: string, + } + installed: async func() -> result, error>; + /// The page fragment (without `#`) that names `session`'s app at + /// `route` — the bookmarkable form of "this app, here". The kernel + /// owns the grammar (`app/`) and the token is opaque: install + /// id and route sealed under the user's route key, deterministically, + /// so equal state is equal text. `refused` when `route` is over the + /// kernel's length cap. + route-encode: async func(session: session-id, route: string) -> result; + /// The inverse: a fragment the visor found on the page, resolved to an + /// app this device may launch. `not-found` for anything that is not a + /// fragment this user's route key sealed, including one from another + /// user's device; `unknown-app` when it names an app no longer + /// installed. + route-decode: async func(fragment: string) -> result; /// Start an instance. The glue binds a port to the returned session; /// the visor opens a frame for it through `shell.open-frame`, and the /// frame fetches `component`/`assets`/`asset` over that port, which @@ -459,9 +479,21 @@ interface shell { /// fetches the session's component bytes from the kernel, obtains the /// port the worker bound to the session, and posts both into a /// constant, hash-pinned srcdoc loader. At most one frame per session. - open-frame: async func(session: session-id) -> result<_, error>; + /// + /// `route` is what the frame answers `polyvisor:app/route.get` with: + /// the route a bookmark carried, or "" for a plain launch. The glue + /// also writes the page fragment for this session (`apps.route-encode`) + /// here, on every `route.set` the frame relays, and clears it in + /// `close-frame`: the URL bar belongs to the one open session, and the + /// glue owns it (`history.replaceState` only — an app never gets a + /// history entry; docs/design.md "Routing"). + open-frame: async func(session: session-id, route: string) -> result<_, error>; /// Tear the frame down. Idempotent. close-frame: async func(session: session-id) -> result<_, error>; + /// The page's fragment without its `#`, or `none` when there is none. + /// Read once per page load by the visor, after unseal, and handed to + /// `apps.route-decode`; the visor never parses it. + fragment: func() -> option; /// `location.reload()`. reload: func(); /// Re-anchor this tab to another device (or, with `none`, to a fresh diff --git a/visor/src/kernel.rs b/visor/src/kernel.rs index be875dbd..635c4fda 100644 --- a/visor/src/kernel.rs +++ b/visor/src/kernel.rs @@ -311,8 +311,44 @@ pub(crate) async fn close(session: SessionId) -> Result<(), String> { api::apps::close(session).await.map_err(message) } -pub(crate) async fn open_frame(session: SessionId) -> Result<(), String> { - api::shell::open_frame(session).await.map_err(message) +/// Show a session's frame. `route` is what the frame answers +/// `polyvisor:app/route.get` with (internal.wit `shell.open-frame`): the +/// route a bookmark carried, or "" for a plain launch. +pub(crate) async fn open_frame(session: SessionId, route: &str) -> Result<(), String> { + api::shell::open_frame(session, route.to_string()) + .await + .map_err(message) +} + +/// This page's fragment without its `#`, or `None` when there is none. +/// +/// Sync in the contract (internal.wit `shell.fragment`) because it is a +/// read of the page's own URL, which the glue already has. +pub(crate) fn fragment() -> Option { + api::shell::fragment() +} + +/// What a bookmark resolves to: the app to launch, and the route to hand +/// its frame. +/// +/// The visor never looks inside `fragment`. The kernel owns the grammar +/// and the token is opaque — install id and route sealed under the user's +/// route key (internal.wit `apps.route-decode`) — so a fragment this +/// device cannot open is a kernel `not-found`, with the kernel's own +/// framework-voice message, and not a shape this file could recognise. +pub(crate) async fn route_decode(fragment: &str) -> Result<(App, String), String> { + api::apps::route_decode(fragment.to_string()) + .await + .map_err(message) + .map(|t| { + ( + App { + id: t.app.id, + title: AppText::from_kernel(t.app.title), + }, + t.route, + ) + }) } pub(crate) async fn close_frame(session: SessionId) -> Result<(), String> { diff --git a/visor/src/ui.rs b/visor/src/ui.rs index a7aae1d7..27aa8dea 100644 --- a/visor/src/ui.rs +++ b/visor/src/ui.rs @@ -179,6 +179,96 @@ enum Notice { Ended { app: AppText, reason: String }, } +/// Launch `app` and give its frame the screen, with `route` as what the +/// frame answers `polyvisor:app/route.get` with (internal.wit +/// `shell.open-frame`): "" for a press on the app list, and the route a +/// bookmark carried for [`restore_bookmark`]. One function because the two +/// paths differ in that string and in nothing else — including the failure +/// handling, where a frame that will not open has to take its session with +/// it or every failure leaks a session id. +async fn open_app( + app: App, + route: String, + mut session: Signal>, + app_meta: Signal, + mut notice: Signal>, + apply: Callback, +) { + match kernel::launch(&app.id).await { + Err(e) => notice.set(Some(Notice::Plain(e))), + Ok(id) => match kernel::open_frame(id, &route).await { + Ok(()) => { + notice.set(None); + let app_id = app.id.clone(); + session.set(Some((id, app))); + // The strip's left half now speaks for this app. + read_app_meta(app_id, app_meta, notice).await; + // The frame gets the screen; the drawer never covers it. + // `apply` and not a bare `drawer.set`: it bumps `drawer_gate`, + // so a boot decision still in flight cannot reopen a drawer + // over the frame that just opened. + apply.call(Action::Close); + } + Err(e) => { + // The session outlived the frame that was to show it; + // leaving it live would leak a session id per failure. + let _ = kernel::close(id).await; + notice.set(Some(Notice::Plain(e))); + } + }, + } +} + +thread_local! { + /// Has this page load already spent its fragment? + /// + /// A `thread_local` and not a hook: [`restore_bookmark`] is reached from + /// [`read_identity`], which is a free function called from three different + /// callbacks, and the rule is about the *page load* rather than about any + /// one component's lifetime. The component realm is single-threaded + /// (one guest instance per page), so this is a plain `Cell`. + static FRAGMENT_SPENT: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +/// Open what the page's fragment names, once per page load. +/// +/// The fragment is read but never parsed: the kernel owns the grammar and +/// the token is opaque (internal.wit `apps.route-decode`), so the visor's +/// whole part is to hand the text over and act on the answer. A fragment +/// this device cannot open comes back as an error whose framework-voice +/// message is the kernel's, and it is shown as-is — the visor has no +/// sentence of its own to compose about a link it cannot read. +/// +/// Once per page load, because [`read_identity`] runs again after every +/// ceremony that changes the device (unseal, keep). Relaunching there +/// would put the bookmarked app back on screen over whatever the user has +/// since opened, so the flag is spent on the first run that gets this far +/// — before the first await, so two reads in flight together cannot both +/// claim it. +/// +/// It is never reached while the device is sealed: `read_identity` returns +/// at the seal, and every kernel call this makes would answer +/// `unavailable` anyway (internal.wit `device`). A device sealed at boot +/// restores its bookmark when the unseal ceremony succeeds, because +/// `on_unsealed` reads the identity again and the flag is still unspent. +async fn restore_bookmark( + session: Signal>, + app_meta: Signal, + mut notice: Signal>, + apply: Callback, +) { + if FRAGMENT_SPENT.with(|spent| spent.replace(true)) { + return; + } + let Some(f) = kernel::fragment() else { + return; + }; + match kernel::route_decode(&f).await { + Err(e) => notice.set(Some(Notice::Plain(e))), + Ok((app, route)) => open_app(app, route, session, app_meta, notice, apply).await, + } +} + /// Read the device's identity, and — unless the device is sealed — the app /// list and the user's own labels. Both are skipped while sealed on /// purpose: every kernel call other than `status`/`unseal`/`erase` answers @@ -189,12 +279,20 @@ enum Notice { /// /// Gated like [`read_status`]: this is called after the ceremonies that /// change the device, and a user write landing while it is out must win. +/// +/// The unsealed path ends in [`restore_bookmark`], which is the one place +/// a page's fragment is spent: it is the first moment the device is known +/// to be open, which is also the first moment `apps.route-decode` and +/// `apps.launch` will answer anything but `unavailable`. async fn read_identity( mut status: Signal>, mut apps: Signal>, mut notice: Signal>, mut user_meta: Signal, gate: CopyValue, + session: Signal>, + app_meta: Signal, + apply: Callback, ) { let token = gate.peek().begin(); match kernel::status().await { @@ -234,6 +332,13 @@ async fn read_identity( } Err(e) => notice.set(Some(Notice::Plain(e))), } + // Last, so the strip is whole — identity, labels, app list — before a + // bookmark's launch takes its turn. Inside the gate: a read this one + // superseded is not the one to spend the fragment, and the newer read + // will spend it instead. + if gate.peek().apply(token) { + restore_bookmark(session, app_meta, notice, apply).await; + } } /// Re-read the device identity alone, and only write it if it actually @@ -587,10 +692,24 @@ pub(crate) fn Visor() -> Element { // who pressed it then had the drawer changed under them when the boot // decided. So the decision applies only if the user has not touched the // drawer meanwhile — after which it is not the boot's business what is - // open. + // open. Nor `restore_bookmark`'s business: it closes the drawer through + // `apply`, which bumps the same gate, and a bookmark that opened is the + // strongest statement about what this page load is for — so the boot's + // own idea of which tenant to show is dropped by exactly the mechanism a + // user's press would have dropped it by. use_future(move || async move { let token = drawer_gate.peek().begin(); - read_identity(status, apps, notice, user_meta, status_gate).await; + read_identity( + status, + apps, + notice, + user_meta, + status_gate, + session, + app_meta, + apply, + ) + .await; let index = kernel::devices().await.unwrap_or_default(); // The user has taken over: neither the decision nor the index it // was based on is the newest thing on screen any more. `entries` is @@ -651,27 +770,10 @@ pub(crate) fn Visor() -> Element { } }); + // A press on the app list is a plain launch: no route, so the frame + // answers `route.get` with "" (internal.wit `shell.open-frame`). let open = move |app: App| async move { - match kernel::launch(&app.id).await { - Err(e) => notice.set(Some(Notice::Plain(e))), - Ok(id) => match kernel::open_frame(id).await { - Ok(()) => { - notice.set(None); - let app_id = app.id.clone(); - session.set(Some((id, app))); - // The strip's left half now speaks for this app. - read_app_meta(app_id, app_meta, notice).await; - // The frame gets the screen; the drawer never covers it. - apply.call(Action::Close); - } - Err(e) => { - // The session outlived the frame that was to show it; - // leaving it live would leak a session id per failure. - let _ = kernel::close(id).await; - notice.set(Some(Notice::Plain(e))); - } - }, - } + open_app(app, String::new(), session, app_meta, notice, apply).await }; let close_session = move |id: SessionId| async move { @@ -719,7 +821,17 @@ pub(crate) fn Visor() -> Element { // generation. status_gate.write().bump(); spawn(async move { - read_identity(status, apps, notice, user_meta, status_gate).await; + read_identity( + status, + apps, + notice, + user_meta, + status_gate, + session, + app_meta, + apply, + ) + .await; apply.call(Action::Close); }); }); @@ -734,7 +846,17 @@ pub(crate) fn Visor() -> Element { "the browser declined to persist storage".into(), ))); } - read_identity(status, apps, notice, user_meta, status_gate).await; + read_identity( + status, + apps, + notice, + user_meta, + status_gate, + session, + app_meta, + apply, + ) + .await; }); }); diff --git a/web/boot.ts b/web/boot.ts index 73c7f31c..13710475 100644 --- a/web/boot.ts +++ b/web/boot.ts @@ -315,6 +315,7 @@ const kernel = proxyInterfaces(control, [ const apps = kernel[I.apps] as { component(session: number): Promise; abort(session: number, reason: string): Promise; + routeEncode(session: number, route: string): Promise; }; function requestFramePort(session: number): Promise { @@ -379,6 +380,28 @@ async function frameSrcdoc(frameJs: string): Promise { const frames = new Map(); +/** Which session's route currently owns the page's URL fragment, or + * `undefined` when no open frame has written one yet. `close-frame` clears + * the fragment only when the closing session is this one — internal.wit + * `shell`: "the URL bar belongs to the one open session, and the glue owns + * it" — so a session that never wrote a fragment (or one that lost the bar + * to a later frame, which "at most one frame per session" makes impossible + * anyway) does not clear someone else's bookmark on its way out. */ +let fragmentOwner: number | undefined; + +/** `history.replaceState` only — never `pushState` (internal.wit `shell`: + * "an app never gets a history entry"). */ +function writeFragment(session: number, fragment: string): void { + fragmentOwner = session; + history.replaceState(null, "", "#" + fragment); +} + +function clearFragment(session: number): void { + if (fragmentOwner !== session) return; + fragmentOwner = undefined; + history.replaceState(null, "", location.pathname + location.search); +} + /** One listener for every frame's lifetime traffic, registered once: a * listener per frame would outlive the frame it closed over. The frame * cannot state its session — `ev.source` identifies it, and `frames` says @@ -386,7 +409,12 @@ const frames = new Map(); * supplied by the glue from the port a call arrived on, never taken from * the caller"). */ globalThis.addEventListener("message", (ev: MessageEvent) => { - if ((ev.data as { t?: string })?.t !== "error") return; + const data = ev.data as { t?: string }; + if (data?.t === "route") { + onFrameRoute(ev.source, String((data as { route: string }).route)); + return; + } + if (data?.t !== "error") return; let ended: number | undefined; for (const [session, iframe] of frames) { if (ev.source === iframe.contentWindow) ended = session; @@ -406,7 +434,67 @@ globalThis.addEventListener("message", (ev: MessageEvent) => { ); }); -async function openFrame(session: number, srcdoc: string): Promise { +/** Pending `route.set` relays, keyed by session: coalesced so a burst of + * clicks (TodoMVC's filter, e.g.) writes the encoder once per settle rather + * than once per click. 250ms: fast enough that a bookmark taken right after + * a click is fresh, slow enough that a click storm does not spend a + * `route-encode` (an AES-GCM seal) per keystroke. */ +const routeDebounce = new Map(); + +/** The newest encode asked for per session. Encodes are not ordered by the + * kernel — the first one on a device also mints the route key and + * checkpoints, so it can land after a `route.set` that followed it — and an + * older answer arriving later must not put an older route in the bar. */ +const routeSeq = new Map(); + +/** Encode `route` for `session` and, if it is still the newest ask and the + * frame is still up, write it to the bar. */ +function encodeFragment(session: number, route: string): void { + const seq = (routeSeq.get(session) ?? 0) + 1; + routeSeq.set(session, seq); + void apps.routeEncode(session, route).then((fragment) => { + if (!frames.has(session) || routeSeq.get(session) !== seq) return; + writeFragment(session, fragment); + }).catch((err: unknown) => { + console.error( + "polyvisor: route-encode failed:", + (err as Error)?.message ?? err, + ); + }); +} + +function onFrameRoute(source: MessageEventSource | null, route: string): void { + let session: number | undefined; + for (const [s, iframe] of frames) { + if (source === iframe.contentWindow) session = s; + } + if (session === undefined) return; // frame already gone + // The kernel refuses a route over its length cap (`refused`, 238 UTF-8 + // bytes: internal.wit `apps.route-encode`) — dropped here rather than + // sent, so a bug in an app's own route does not spend a round trip on a + // call whose answer is already known. + if (new TextEncoder().encode(route).length > 238) { + console.warn( + `polyvisor: session ${session} set a route over the encoder's cap; ignored`, + ); + return; + } + const existing = routeDebounce.get(session); + if (existing !== undefined) clearTimeout(existing); + routeDebounce.set( + session, + setTimeout(() => { + routeDebounce.delete(session); + encodeFragment(session, route); + }, 250) as unknown as number, + ); +} + +async function openFrame( + session: number, + route: string, + srcdoc: string, +): Promise { if (frames.has(session)) return; // at most one frame per session const [artifacts, port] = await Promise.all([ apps.component(session), @@ -437,6 +525,7 @@ async function openFrame(session: number, srcdoc: string): Promise { wasm: artifacts.wasm, plan: artifacts.plan, port, + route, }, // The frame's origin is opaque; "*" is the only target that names // it, and it is safe because `ev.source` identified the recipient. @@ -451,11 +540,28 @@ async function openFrame(session: number, srcdoc: string): Promise { }); frames.set(session, iframe); + + // The fragment for the state the frame was actually opened at — a plain + // launch ("") encodes just as well as a bookmarked one, so the URL always + // ends up naming this session once the frame is up, not only once the app + // has since called `route.set`. Not awaited: `open-frame` returning is + // what lets the visor record the session, and the first encode on a + // device also mints its route key and checkpoints — long enough that a + // frame which dies at mount (the hostile fixture) would report + // `session-ended` for a session the visor had not yet heard of. + encodeFragment(session, route); } function closeFrame(session: number): void { frames.get(session)?.remove(); frames.delete(session); + const pending = routeDebounce.get(session); + if (pending !== undefined) { + clearTimeout(pending); + routeDebounce.delete(session); + } + routeSeq.delete(session); + clearFragment(session); } // --------------------------------------------------------------------------- @@ -491,8 +597,8 @@ async function main(): Promise { // `ComponentException` payload (M1 context "Value mapping"). A raw // rejection would be a host fault instead of the refusal the WIT // declares. - openFrame: (session: number) => - openFrame(session, srcdoc).catch((err: unknown) => { + openFrame: (session: number, route: string) => + openFrame(session, route, srcdoc).catch((err: unknown) => { throw new ComponentException({ code: "failed", message: String((err as Error)?.message ?? err), @@ -507,6 +613,14 @@ async function main(): Promise { reload: () => { location.reload(); }, + // Also sync (internal.wit `shell.fragment`). `""` and `"#"` both read as + // `undefined`: `location.hash` is `""` with none, and is `"#"` for a + // literal bare `#` — neither names a fragment `apps.route-decode` could + // ever accept, so there is nothing to hand it. + fragment: (): string | undefined => { + const hash = location.hash; + return hash === "" || hash === "#" ? undefined : hash.slice(1); + }, // Also sync. Re-anchoring is all this does — the worker is named after // the anchor, so the reload is what actually moves the tab to the other // device (docs/design.md "Devices": "Switching devices is a reload"). diff --git a/web/frame.ts b/web/frame.ts index 879243f5..24055aa5 100644 --- a/web/frame.ts +++ b/web/frame.ts @@ -22,6 +22,7 @@ import { proxyInterfaces } from "./rpc.ts"; const I = { tasks: "polyvisor:app/tasks@0.1.0", apps: "polyvisor:internal/apps@0.1.0", + route: "polyvisor:app/route@0.1.0", } as const; interface AssetInfo { @@ -40,6 +41,14 @@ let mounted: MountedProducer | undefined; * exists an await later, which is not a guard. */ let mounting = false; +/** `polyvisor:app/route.get`'s answer: the route the mount message carried + * ("" for a plain launch), fixed for this frame's lifetime — `wit/app.wit` + * `route`: "the route the app was launched at". Served locally, never over + * the session port: the port answers `tasks`/`apps`, and route is a + * page-URL concern the glue owns on the other side of `postMessage`, not a + * kernel call (internal.wit `shell.open-frame` docs). */ +let route = ""; + function teardown(message: string): void { mounted?.dispose(); mounted = undefined; @@ -53,6 +62,19 @@ async function mount( port: MessagePort, ): Promise { const imports = proxyInterfaces(port, [I.tasks]); + // `route` is local, not proxied over `port`: `get` reads the value the + // mount message carried and `set` relays to the parent, which is the one + // side that can touch `location` at all (internal.wit `shell.open-frame`: + // "the glue also writes the page fragment ... on every `route.set` the + // frame relays"). Same shape as a proxied interface — a record of + // camelCase methods, keyed by the verbatim WIT interface id — so it merges + // into `imports` exactly where a proxy would have gone. + imports[I.route] = { + get: () => route, + set: (r: string) => { + parent.postMessage({ t: "route", route: r }, "*"); + }, + }; const appsClient = proxyInterfaces(port, [I.apps])[I.apps] as { assets(): Promise; asset(handle: Uint8Array): Promise; @@ -99,7 +121,12 @@ globalThis.addEventListener("message", (ev: MessageEvent) => { if ((data as { t?: string }).t !== "mount") return; if (mounting) return; // one mount per frame mounting = true; - const { wasm, plan } = data as { wasm: Uint8Array; plan: string }; + const { wasm, plan, route: launchRoute } = data as { + wasm: Uint8Array; + plan: string; + route: string; + }; + route = launchRoute; const port = ev.ports[0]; mount(wasm, plan, port).catch((err: unknown) => teardown(String((err as Error)?.message ?? err)) diff --git a/web/worker.ts b/web/worker.ts index 8478674c..718a22be 100644 --- a/web/worker.ts +++ b/web/worker.ts @@ -463,6 +463,15 @@ self.onconnect = (ev: MessageEvent) => { [I.apps]: { installed: async () => (await ready)[I.apps].installed(), launch: async (app: string) => (await ready)[I.apps].launch(app), + // Control port only, both: `route-encode` names a session the tab's + // glue owns the URL bar for, and `route-decode` resolves a fragment + // the visor read off the page (internal.wit `apps`). A session port + // gets neither — an app reaches the URL through `route.set`, which + // the frame relays and the glue encodes on its behalf. + routeEncode: async (s: number, route: string) => + (await ready)[I.apps].routeEncode(s, route), + routeDecode: async (fragment: string) => + (await ready)[I.apps].routeDecode(fragment), sessionApp: async (s: number) => (await ready)[I.apps].sessionApp(s), component: async (s: number) => (await ready)[I.apps].component(s), assets: async (s: number) => (await ready)[I.apps].assets(s), diff --git a/wit/app.wit b/wit/app.wit index 9b70accc..b28ac96b 100644 --- a/wit/app.wit +++ b/wit/app.wit @@ -48,10 +48,31 @@ interface tasks { remove: async func(id: string) -> result<_, string>; } +/// The app's place in the visor's bookmarkable URL. +/// +/// The visor's page fragment names the running app and carries one opaque +/// string the app chose: its route. The visor seals the pair under a key +/// only this user's devices hold before it reaches the URL bar, so the +/// route is never readable off the page, and a route this app is handed +/// is one it wrote itself on one of the user's own devices — not +/// attacker-typed input. What the app puts in it is its own business +/// (TodoMVC: its filter). Bounded: the framework refuses a route over its +/// cap (a few hundred bytes) and the URL does not move. +/// +/// `set` replaces the URL; it never pushes history. The back button is +/// the visor's, not the app's. +interface route { + /// The route the app was launched at: "" for a plain launch. + get: func() -> string; + /// Replace the route in the page's URL. Fire-and-forget. + set: func(route: string); +} + /// An application. Renders through the stream-dom producer contract into /// a receiver the framework owns (a sandboxed frame under a vocabulary /// policy); everything else it can reach is an import listed here. world app { include polymorph:stream-dom/producer@0.1.0; import tasks; + import route; }