From 27a0693d02acb45aa555b69ea71f142109a86ae6 Mon Sep 17 00:00:00 2001 From: kipavy Date: Wed, 19 Aug 2026 17:21:57 +0000 Subject: [PATCH] refactor(deep-link): classify verified as an attenuated route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `silent` claims a route carries no capability at all. That is only true of `verified` by accident of how the link is built today: the portal spends the verification token server-side and hands the app an inert user id. Once the mail link opens the app directly the app receives the raw token, and `verified` leaves `silent` under its own definition — with `confirm` the only other home, which puts a consent sheet on a tap the user just asked for. Add a fourth trust class for a capability worn down by scope, lifetime and blast radius until acting on it unprompted is safe, and argue `verified` into it in the TRUST doc comment beside the other three. `silent` stays, unpopulated, for a route that genuinely carries nothing. No behaviour change: `verified` still carries `u=` and still runs unprompted. The four class guards collapse into one `isOfClass` factory rather than gaining a fourth copy, and `isUnpromptedIntent` lists its classes so a class added later must be admitted deliberately. --- src/services/deepLinkUrl.test.ts | 10 +++-- src/services/deepLinkUrl.ts | 68 +++++++++++++++++++++++--------- 2 files changed, 56 insertions(+), 22 deletions(-) diff --git a/src/services/deepLinkUrl.test.ts b/src/services/deepLinkUrl.test.ts index 2f8a00e42..f6cac0748 100644 --- a/src/services/deepLinkUrl.test.ts +++ b/src/services/deepLinkUrl.test.ts @@ -1,5 +1,5 @@ import { test, expect } from "vitest"; -import { intentKey, isConfirmIntent, isNavigateIntent, isSilentIntent, parseDeepLink, buildDeepLink, DEFAULT_PLUGIN_SOURCE_ID } from "./deepLinkUrl"; +import { intentKey, isAttenuatedIntent, isConfirmIntent, isNavigateIntent, isSilentIntent, isUnpromptedIntent, parseDeepLink, buildDeepLink, DEFAULT_PLUGIN_SOURCE_ID } from "./deepLinkUrl"; const SESSION = "3f2504e0-4f89-11d3-9a0c-0305e82c3301"; const TOKEN = "deadbeefdeadbeefdeadbeefdeadbeef"; @@ -76,9 +76,13 @@ test("intent keys match for the same link parsed twice", () => { expect(intentKey(a)).toBe(intentKey(b)); }); -test("join is a confirm route and verified is a silent one", () => { +test("join is a confirm route and verified is an attenuated one", () => { + const verified = parseDeepLink(`voltius://verified?u=${USER}`)!; expect(isConfirmIntent(parseDeepLink(`voltius://join?s=${SESSION}&t=${TOKEN}`)!)).toBe(true); - expect(isSilentIntent(parseDeepLink(`voltius://verified?u=${USER}`)!)).toBe(true); + expect(isAttenuatedIntent(verified)).toBe(true); + expect(isSilentIntent(verified)).toBe(false); + // Attenuated still acts without a prompt; that is the whole point of the class. + expect(isUnpromptedIntent(verified)).toBe(true); }); test("builds a join link in both forms", () => { diff --git a/src/services/deepLinkUrl.ts b/src/services/deepLinkUrl.ts index f5f2c31a3..f2b1e746f 100644 --- a/src/services/deepLinkUrl.ts +++ b/src/services/deepLinkUrl.ts @@ -20,7 +20,7 @@ export type DeepLinkIntent = | SnippetInstallIntent | PluginInstallIntent; -type TrustClass = "confirm" | "silent" | "navigate"; +type TrustClass = "confirm" | "attenuated" | "silent" | "navigate"; type Route = DeepLinkIntent["route"]; /** @@ -28,10 +28,38 @@ type Route = DeepLinkIntent["route"]; * * - `confirm` routes carry a capability and nothing happens until the user * accepts. - * - `silent` routes carry no capability and run a side effect unprompted. - * `verified` is silent because the token died server-side before the link was - * built and the user id authorises nothing, so a hostile link costs a session - * refresh, which is a no-op. + * - `attenuated` routes carry a real capability, worn down by scope, lifetime + * and blast radius until acting on it unprompted is safe. The bar is not "the + * link is probably genuine": it is that a forged link, tapped by the wrong + * person at the wrong moment, still costs the tapping user nothing. + * + * `verified` is the only member, and is classified on the strongest form it + * will carry rather than the weakest form it carries today. Today the portal + * spends the verification token server-side and the app receives an inert + * user id, which would also satisfy `silent`; once the mail link opens the app + * directly the app receives the raw token instead. That capability is a + * single-use, short-lived token, bound server-side to one account, proving an + * address is reachable — no session, no key material, no grant. So a hostile + * `verified` link carries the *attacker's* own token: tapping it verifies the + * attacker's address, which the attacker can already do unaided, and touches + * nothing of the tapper's. A consent sheet on a tap the user asked for by + * pressing "verify my email" buys none of that back, and spends the user's + * willingness to read the sheets that do carry a capability. + * + * The class stops being honest the moment a `verified` link carries something + * a forged one could spend *against* the tapper — a session, a wrapped key, an + * account-scoped grant. That route is `confirm`, not `attenuated`. + * + * Open, and it gates putting these links in mail rather than this + * classification: enterprise rewriters (Outlook SafeLinks, Defender ATP) + * re-encode the link onto their own logging host. The tap then resolves + * against *that* host, so no App Link fires and the mail client's own browser + * follows the redirect — the app never sees the link at all, and whatever the + * rewriter kept of it sits in the gateway's logs. Whether a fragment survives + * the round trip is undocumented and untested here. + * - `silent` routes carry no capability at all and run a side effect unprompted. + * Currently unpopulated: it is the narrower claim `verified` used to make, + * kept for a route that genuinely carries nothing. * - `navigate` routes only move the user to a screen they could already reach. * The worst a hostile link achieves is an unexpected panel, so they need no * prompt — but they must never *act*: `billing` opens the account section and @@ -42,7 +70,7 @@ const TRUST = { // Grants a stranger access to a live terminal, so nothing happens until the // host accepts. invite: "confirm", - verified: "silent", + verified: "attenuated", notification: "navigate", settings: "navigate", billing: "navigate", @@ -60,10 +88,12 @@ type RouteOfClass = { }[Route]; export type ConfirmIntent = Extract }>; +export type AttenuatedIntent = Extract }>; +/** `never` while `silent` has no members; kept so a future route has a home. */ export type SilentIntent = Extract }>; export type NavigateIntent = Extract }>; /** Everything that runs without asking the user first. */ -export type UnpromptedIntent = SilentIntent | NavigateIntent; +export type UnpromptedIntent = AttenuatedIntent | SilentIntent | NavigateIntent; /** * One codec per route, both directions declared together so the builder and the @@ -181,7 +211,6 @@ const ROUTES: { [K in Route]: RouteCodec } = { }, }; - export type LinkForm = "https" | "scheme"; /** The landing site that bridges an `https` link back to the scheme. */ @@ -270,19 +299,20 @@ export function intentKey(intent: DeepLinkIntent): string { .join("&"); } -export function isConfirmIntent(intent: DeepLinkIntent): intent is ConfirmIntent { - return TRUST[intent.route] === "confirm"; -} - -export function isSilentIntent(intent: DeepLinkIntent): intent is SilentIntent { - return TRUST[intent.route] === "silent"; -} +/** One guard per trust class, all reading the same single declaration. */ +const isOfClass = + (trust: C) => + (intent: DeepLinkIntent): intent is Extract }> => + TRUST[intent.route] === trust; -export function isNavigateIntent(intent: DeepLinkIntent): intent is NavigateIntent { - return TRUST[intent.route] === "navigate"; -} +export const isConfirmIntent = isOfClass("confirm"); +export const isAttenuatedIntent = isOfClass("attenuated"); +export const isSilentIntent = isOfClass("silent"); +export const isNavigateIntent = isOfClass("navigate"); /** A route that acts without a prompt, whether it navigates or runs a side effect. */ export function isUnpromptedIntent(intent: DeepLinkIntent): intent is UnpromptedIntent { - return isSilentIntent(intent) || isNavigateIntent(intent); + // Listed class by class rather than as "not `confirm`", so a class added + // later has to be admitted here deliberately instead of by default. + return isAttenuatedIntent(intent) || isSilentIntent(intent) || isNavigateIntent(intent); }