Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions src/services/deepLinkUrl.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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", () => {
Expand Down
68 changes: 49 additions & 19 deletions src/services/deepLinkUrl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,46 @@ export type DeepLinkIntent =
| SnippetInstallIntent
| PluginInstallIntent;

type TrustClass = "confirm" | "silent" | "navigate";
type TrustClass = "confirm" | "attenuated" | "silent" | "navigate";
type Route = DeepLinkIntent["route"];

/**
* The single declaration of trust:
*
* - `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
Expand All @@ -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",
Expand All @@ -60,10 +88,12 @@ type RouteOfClass<C extends TrustClass> = {
}[Route];

export type ConfirmIntent = Extract<DeepLinkIntent, { route: RouteOfClass<"confirm"> }>;
export type AttenuatedIntent = Extract<DeepLinkIntent, { route: RouteOfClass<"attenuated"> }>;
/** `never` while `silent` has no members; kept so a future route has a home. */
export type SilentIntent = Extract<DeepLinkIntent, { route: RouteOfClass<"silent"> }>;
export type NavigateIntent = Extract<DeepLinkIntent, { route: RouteOfClass<"navigate"> }>;
/** 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
Expand Down Expand Up @@ -181,7 +211,6 @@ const ROUTES: { [K in Route]: RouteCodec<K> } = {
},
};


export type LinkForm = "https" | "scheme";

/** The landing site that bridges an `https` link back to the scheme. */
Expand Down Expand Up @@ -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 =
<C extends TrustClass>(trust: C) =>
(intent: DeepLinkIntent): intent is Extract<DeepLinkIntent, { route: RouteOfClass<C> }> =>
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);
}