diff --git a/src/frontend/src/lib/utils/describeBrowser.test.ts b/src/frontend/src/lib/utils/describeBrowser.test.ts new file mode 100644 index 0000000000..aa2be3e40f --- /dev/null +++ b/src/frontend/src/lib/utils/describeBrowser.test.ts @@ -0,0 +1,349 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { describeBrowser } from "./describeBrowser"; +import type { + BrowserBrand, + BrowserDescription, + FormFactor, + OperatingSystem, +} from "$lib/generated/internet_identity_types"; + +const CHROME_ANDROID = + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Mobile Safari/537.36"; + +const stub = (props: Record): void => { + for (const [name, value] of Object.entries(props)) { + Object.defineProperty(navigator, name, { value, configurable: true }); + } +}; + +const describing = ( + agent: string, + maxTouchPoints = 0, +): Promise => { + stub({ userAgent: agent, maxTouchPoints }); + return describeBrowser(); +}; + +afterEach(() => { + stub({ userAgentData: undefined }); +}); + +/** + * Real agent strings throughout: what makes these come out right is how the tokens sit + * relative to one another, which a hand-made string would not reproduce. + */ +describe("brand", () => { + /// The six this interface names. Each is read from a token more specific than the + /// `Chrome/` and `Safari/` every one of them also carries. + it.each([ + { + name: "Chrome on iOS, which says CriOS", + agent: + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/125.0.6422.80 Mobile/15E148 Safari/604.1", + brand: { Chrome: null } satisfies BrowserBrand, + }, + { + name: "Firefox on iOS, which says FxiOS", + agent: + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/126.1 Mobile/15E148 Safari/605.1.15", + brand: { Firefox: null } satisfies BrowserBrand, + }, + { + name: "Edge on iOS, which says EdgiOS", + agent: + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 EdgiOS/125.2535.60 Mobile/15E148 Safari/605.1.15", + brand: { Edge: null } satisfies BrowserBrand, + }, + { + name: "Opera on iOS, which says OPT", + agent: + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) OPT/4.4.0 Mobile/15E148 Safari/604.1", + brand: { Opera: null } satisfies BrowserBrand, + }, + { + name: "Safari, whose own token is the WebKit build", + agent: + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + brand: { Safari: null } satisfies BrowserBrand, + }, + { + name: "Firefox on a Mac", + agent: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:126.0) Gecko/20100101 Firefox/126.0", + brand: { Firefox: null } satisfies BrowserBrand, + }, + { + name: "Edge on Windows", + agent: + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 Edg/124.0.0.0", + brand: { Edge: null } satisfies BrowserBrand, + }, + { + name: "Opera on Windows", + agent: + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 OPR/110.0.0.0", + brand: { Opera: null } satisfies BrowserBrand, + }, + { + name: "Samsung Internet", + agent: + "Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/23.0 Chrome/115.0.0.0 Mobile Safari/537.36", + brand: { SamsungInternet: null } satisfies BrowserBrand, + }, + { + name: "plain Chrome, whose last token is Safari", + agent: + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36", + brand: { Chrome: null } satisfies BrowserBrand, + }, + ])("names $name", async ({ agent, brand }) => { + await expect(describing(agent)).resolves.toMatchObject({ brand }); + }); + + /// A browser outside the six is named by the token it appends, not by the agent it + /// borrowed — otherwise every Chromium fork reads as Chrome. Read from the agent + /// rather than listed, so one this frontend has never heard of still arrives named. + it.each([ + { + name: "Vivaldi", + agent: + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36 Vivaldi/6.7.3329.41", + other: "Vivaldi", + }, + { + name: "Yandex, which this frontend has never been taught", + agent: + "Mozilla/5.0 (Windows NT 10.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 YaBrowser/24.4.1 Safari/537.36", + other: "YaBrowser", + }, + ])("names $name by its own token", async ({ agent, other }) => { + await expect(describing(agent)).resolves.toMatchObject({ + brand: { Other: other }, + }); + }); + + /// Brave ships Chrome's agent byte for byte, deliberately. There is no token to find + /// and nothing to report but Chrome — which is the honest answer, not a gap. + it("reads a browser that hides itself as the one it imitates", async () => { + await expect( + describing( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36", + ), + ).resolves.toMatchObject({ brand: { Chrome: null } }); + }); + + /// The canister fixes a description at registration, so a version captured here would + /// sit frozen at whichever build first signed in. + it("keeps the version out of the name", async () => { + const { brand } = await describing( + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36 Vivaldi/6.7.3329.41", + ); + + expect(brand).toEqual({ Other: "Vivaldi" }); + }); +}); + +describe("operating system", () => { + it.each([ + { + name: "Chromebook", + agent: + "Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36", + os: { ChromeOs: null } satisfies OperatingSystem, + }, + { + name: "Android", + agent: CHROME_ANDROID, + os: { Android: null } satisfies OperatingSystem, + }, + { + name: "iPhone", + agent: + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + os: { Ios: null } satisfies OperatingSystem, + }, + { + name: "iPad", + agent: + "Mozilla/5.0 (iPad; CPU OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + os: { Ipados: null } satisfies OperatingSystem, + }, + { + name: "Windows", + agent: + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36", + os: { Windows: null } satisfies OperatingSystem, + }, + { + name: "Linux", + agent: + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36", + os: { Linux: null } satisfies OperatingSystem, + }, + ])("reads $name", async ({ agent, os }) => { + await expect(describing(agent)).resolves.toMatchObject({ os }); + }); + + /// An iPad in desktop mode sends a Mac agent and exposes no hints, so the touch points + /// are the only thing telling it from a Mac. A Mac reports none. + it.each([ + { name: "a Mac", touchPoints: 0, os: { Macos: null } }, + { + name: "an iPad pretending to be one", + touchPoints: 5, + os: { Ipados: null }, + }, + ])("tells $name apart by its touch points", async ({ touchPoints, os }) => { + await expect( + describing( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15", + touchPoints, + ), + ).resolves.toMatchObject({ os }); + }); + + /// A platform none of the seven names is still named by the agent, in the first + /// segment of its parenthesised block — the whole agent is not an operating system. + it("takes an unknown platform from where the agent states it", async () => { + await expect( + describing( + "Mozilla/5.0 (Haiku; U; Haiku BePC) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36", + ), + ).resolves.toMatchObject({ os: { Other: "Haiku" } }); + }); +}); + +describe("form factor", () => { + it.each([ + { + name: "a phone from its agent", + agent: CHROME_ANDROID, + touchPoints: 5, + form_factor: { Mobile: null } satisfies FormFactor, + }, + { + name: "an Android tablet, which says Android without saying Mobile", + agent: + "Mozilla/5.0 (Linux; Android 13; SM-X710) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36", + touchPoints: 5, + form_factor: { Tablet: null } satisfies FormFactor, + }, + { + name: "a desktop", + agent: + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36", + touchPoints: 0, + form_factor: { Desktop: null } satisfies FormFactor, + }, + ])("reads $name", async ({ agent, touchPoints, form_factor }) => { + await expect(describing(agent, touchPoints)).resolves.toMatchObject({ + form_factor, + }); + }); +}); + +describe("client hints", () => { + const withHints = (high: unknown, mobile = true) => { + stub({ + userAgent: CHROME_ANDROID, + maxTouchPoints: 5, + userAgentData: { + mobile, + getHighEntropyValues: () => Promise.resolve(high), + }, + }); + return describeBrowser(); + }; + + it("takes the model the platform reports", async () => { + await expect(withHints({ model: "Pixel 5" })).resolves.toMatchObject({ + model: ["Pixel 5"], + }); + }); + + /// Android reports the field with nothing in it off a phone, and an absent model has + /// to stay absent rather than becoming an empty string in the record. + it("treats an empty model as no model", async () => { + await expect(withHints({ model: "" })).resolves.toMatchObject({ + model: [], + }); + }); + + it("takes a stated form factor", async () => { + await expect(withHints({ formFactors: ["Tablet"] })).resolves.toMatchObject( + { + form_factor: { Tablet: null }, + }, + ); + }); + + /// The canister names desktops, mobiles and tablets. A device stating anything else is + /// none of the three, and saying so beats what `mobile` alone would have guessed: a + /// watch reports `mobile: true` and would read as a phone, an e-reader reports + /// `mobile: false` and would read as a desktop. + it.each(["Watch", "XR", "Automotive", "EInk"])( + "leaves a %s unnamed rather than guessing from `mobile`", + async (factor) => { + await expect(withHints({ formFactors: [factor] })).resolves.toMatchObject( + { + form_factor: { Unknown: null }, + }, + ); + }, + ); + + /// Both stated, so the one the canister can name wins over the one it cannot. + it("prefers a tablet to an unnameable factor stated beside it", async () => { + await expect( + withHints({ formFactors: ["EInk", "Tablet"] }), + ).resolves.toMatchObject({ + form_factor: { Tablet: null }, + }); + }); + + it("still describes the browser when the platform refuses the question", async () => { + stub({ + userAgent: CHROME_ANDROID, + maxTouchPoints: 5, + userAgentData: { + mobile: true, + getHighEntropyValues: () => Promise.reject(new Error("not allowed")), + }, + }); + + await expect(describeBrowser()).resolves.toEqual({ + brand: { Chrome: null }, + os: { Android: null }, + form_factor: { Mobile: null }, + model: [], + }); + }); +}); + +/// The canister refuses a token over its cap, so a resolver never offers one. +describe("token limits", () => { + it("caps a brand it read off the agent", async () => { + const { brand } = await describing( + `Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) ${"B".repeat(200)}/1.0`, + ); + + const token = "Other" in brand ? brand.Other : ""; + expect(new TextEncoder().encode(token).length).toBeLessThanOrEqual(64); + }); + + it("caps a model the platform reported", async () => { + stub({ + userAgent: CHROME_ANDROID, + maxTouchPoints: 5, + userAgentData: { + mobile: true, + getHighEntropyValues: () => Promise.resolve({ model: "M".repeat(200) }), + }, + }); + + const { model } = await describeBrowser(); + expect(new TextEncoder().encode(model[0] ?? "").length).toBeLessThanOrEqual( + 64, + ); + }); +}); diff --git a/src/frontend/src/lib/utils/describeBrowser.ts b/src/frontend/src/lib/utils/describeBrowser.ts new file mode 100644 index 0000000000..b68ae4818b --- /dev/null +++ b/src/frontend/src/lib/utils/describeBrowser.ts @@ -0,0 +1,231 @@ +import type { + BrowserBrand, + BrowserDescription, + FormFactor, + OperatingSystem, +} from "$lib/generated/internet_identity_types"; + +/** + * What this browser is, resolved into the tokens the canister stores. + * + * Resolved here rather than read back and parsed later: the canister keeps tokens and + * never interprets them, so the wording a user reads lives entirely in this frontend and + * a rename reaches every stored record at once. + * + * The user agent carries the brand and the system on every engine. Client hints are + * Chromium-only — Safari and Firefox expose nothing — so they are used for the two things + * an agent cannot give: the hardware model, and a form factor the browser states rather + * than one inferred from its agent. + */ + +/** The canister refuses a longer token, so a resolver never offers one. */ +const MAX_BROWSER_TOKEN_BYTES = 64; + +/** Ordered most specific first: every later token also appears in the earlier ones' agents. */ +const BRANDS: [RegExp, BrowserBrand][] = [ + [/CriOS\//, { Chrome: null }], + [/FxiOS\//, { Firefox: null }], + [/EdgiOS\//, { Edge: null }], + [/OPiOS\/|OPT\//, { Opera: null }], + [/Firefox\//, { Firefox: null }], + [/EdgA\/|Edg\//, { Edge: null }], + [/OPR\//, { Opera: null }], + [/SamsungBrowser\//, { SamsungInternet: null }], +]; + +/** + * The two every Chromium and WebKit browser carries, whoever built it. Consulted last, + * because matching one says only which engine is underneath — a fork that names itself + * is named by its own token instead. + */ +const ENGINE_BRANDS: [RegExp, BrowserBrand][] = [ + [/Chrome\//, { Chrome: null }], + [/Safari\//, { Safari: null }], +]; + +/// Tokens every agent carries whoever built the browser: the engine chain, the platform +/// marker, and the two Chromium ships. A browser that names itself does so with a token +/// that is none of these. +const SHARED_TOKENS = new Set([ + "Mozilla", + "AppleWebKit", + "KHTML", + "Gecko", + "Chrome", + "Chromium", + "Safari", + "Version", + "Mobile", +]); + +/** + * The product a browser names itself by, where it names one at all. + * + * An agent is a chain of `product/version` tokens and whether the browser's own is among + * them is the vendor's choice: Vivaldi, Opera, Yandex and DuckDuckGo append theirs, while + * Brave and Arc ship Chrome's agent unchanged and cannot be told from it. Read here + * rather than listed, so a browser this frontend has never heard of still arrives under + * its own name instead of under the one it borrowed. + * + * The version is dropped: the canister fixes a description at registration, so a version + * captured here would sit frozen at whichever build first signed in. + */ +const productToken = (agent: string): string | undefined => { + // `product/version` pairs only. Bare words are not products — an agent carries several + // in its parenthesised block, and `(KHTML, like Gecko)` alone would otherwise offer + // "like" as a browser name. + const products = [...agent.matchAll(/([A-Za-z][\w.-]*)\/[\w.]+/g)].map( + ([, name]) => name, + ); + // The last one, because a browser that names itself appends its token after the + // engine's and Chromium's. + return products.filter((name) => !SHARED_TOKENS.has(name)).pop(); +}; + +/** Truncated on a character boundary, because the cap the canister enforces is in bytes. */ +const capped = (token: string): string => { + const encoder = new TextEncoder(); + let capped = token; + while (encoder.encode(capped).length > MAX_BROWSER_TOKEN_BYTES) { + capped = capped.slice(0, -1); + } + return capped; +}; + +/** + * A named variant where this frontend has one, otherwise the browser's own token. + * + * Three passes, in this order. A specific token wins outright — `CriOS/` is Chrome on + * iOS, and no fork borrows it. Failing that, a token the browser named itself by, so a + * Chromium fork reads as itself rather than as Chrome. Only then the engine tokens every + * one of them carries. + */ +const brandOf = (agent: string): BrowserBrand => { + const named = BRANDS.find(([token]) => token.test(agent))?.[1]; + if (named !== undefined) { + return named; + } + const own = productToken(agent); + if (own !== undefined) { + return { Other: capped(own) }; + } + return ( + ENGINE_BRANDS.find(([token]) => token.test(agent))?.[1] ?? { + Other: capped(agent), + } + ); +}; + +const systemOf = (agent: string, touchPoints: number): OperatingSystem => { + if (/CrOS/.test(agent)) return { ChromeOs: null }; + if (/Android/.test(agent)) return { Android: null }; + if (/iPhone|iPod/.test(agent)) return { Ios: null }; + if (/iPad/.test(agent)) return { Ipados: null }; + // An iPad in desktop mode sends a Mac agent and exposes no hints, so the touch points + // are the only thing that tells it from a Mac. A Mac reports none. + if (/Macintosh|Mac OS X/.test(agent)) + return touchPoints > 0 ? { Ipados: null } : { Macos: null }; + if (/Windows/.test(agent)) return { Windows: null }; + if (/Linux|X11/.test(agent)) return { Linux: null }; + return { Other: capped(platformToken(agent) ?? agent) }; +}; + +/** + * The platform a user agent names, for the systems above that none of the known ones + * matched: it is the first segment of the first parenthesised block. + * + * Barely reachable — `X11` and `Linux` sweep up almost everything the seven above miss, + * and this code only ever runs inside a browser — but where it is reached the block + * still names the system, and the whole agent is not a system. + */ +const platformToken = (agent: string): string | undefined => { + const named = agent + .match(/\(([^)]*)\)/)?.[1] + .split(";")[0] + .trim(); + return named === undefined || named.length === 0 ? undefined : named; +}; + +/** + * Stated form factors the canister has no variant for. A device reporting one of these is + * neither a desktop, a mobile nor a tablet, so it is named as none of them: left to fall + * through, `mobile` alone would call a watch a phone and an e-reader a desktop. + */ +const UNNAMEABLE_FORM_FACTORS = ["Watch", "XR", "Automotive", "EInk"]; + +const formFactorOf = ( + agent: string, + system: OperatingSystem, + hints: { mobile?: boolean; formFactors?: string[] }, +): FormFactor => { + // Before the unnameable check, so a device stating both keeps the variant that exists. + if (hints.formFactors?.includes("Tablet") === true) return { Tablet: null }; + if ( + hints.formFactors?.some((factor) => + UNNAMEABLE_FORM_FACTORS.includes(factor), + ) === true + ) + return { Unknown: null }; + if ("Ipados" in system) return { Tablet: null }; + if (hints.mobile === true) return { Mobile: null }; + if ("Ios" in system) return { Mobile: null }; + if ("Android" in system) + return /Mobile/.test(agent) ? { Mobile: null } : { Tablet: null }; + if (hints.mobile === false) return { Desktop: null }; + if ( + "Macos" in system || + "Windows" in system || + "Linux" in system || + "ChromeOs" in system + ) + return { Desktop: null }; + return { Unknown: null }; +}; + +/** + * The hints an agent cannot supply. Absent off Chromium, and the call can be refused, + * so every field is optional and a refusal is the same answer as no support. + */ +const highEntropyHints = async (): Promise<{ + mobile?: boolean; + formFactors?: string[]; + model?: string; +}> => { + const data = ( + navigator as Navigator & { + userAgentData?: { + mobile?: boolean; + getHighEntropyValues?: (hints: string[]) => Promise<{ + model?: string; + formFactors?: string[]; + }>; + }; + } + ).userAgentData; + if (data === undefined) { + return {}; + } + try { + const high = await data.getHighEntropyValues?.(["model", "formFactors"]); + return { + mobile: data.mobile, + formFactors: high?.formFactors, + // Empty off Android, which reports the field but has no model to put in it. + model: high?.model === "" ? undefined : high?.model, + }; + } catch { + return { mobile: data.mobile }; + } +}; + +export const describeBrowser = async (): Promise => { + const agent = navigator.userAgent; + const hints = await highEntropyHints(); + const os = systemOf(agent, navigator.maxTouchPoints); + return { + brand: brandOf(agent), + os, + form_factor: formFactorOf(agent, os, hints), + model: hints.model === undefined ? [] : [capped(hints.model)], + }; +};