From b0425069f41775af10e8e42c531aa8efb9a35cfd Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Tue, 10 Nov 2026 15:25:46 -0700 Subject: [PATCH] Yaak Proxy: HTTPS decryption UI, and Info and Body in the card's tabs The strip, quiet encrypted rows, warning rows for refused handshakes, the encrypted card with named actions, trust at the first Decrypt, the toast with Undo and Reconnect, the pill's Decrypt everything switch, Settings' Decryption section, the Encrypted sidebar row, and two-step setup. Both body tabs read Body with a type badge; Info leads the request half with the timeline folded in. Co-Authored-By: Claude Opus 5.5 --- .../components/DecryptListEditor.tsx | 215 ++++++++-- apps/yaak-proxy/components/EncryptedCard.tsx | 274 ++++++++++++ apps/yaak-proxy/components/FirstRun.tsx | 124 +----- apps/yaak-proxy/components/FlowCard.tsx | 46 +- apps/yaak-proxy/components/FlowDetail.tsx | 7 +- apps/yaak-proxy/components/FlowInfo.tsx | 61 ++- apps/yaak-proxy/components/FlowList.tsx | 170 +++++++- apps/yaak-proxy/components/FlowListEmpty.tsx | 13 +- apps/yaak-proxy/components/FlowTimeline.tsx | 71 --- apps/yaak-proxy/components/HttpsStrip.tsx | 174 ++++++++ apps/yaak-proxy/components/NotDecrypted.tsx | 97 ----- apps/yaak-proxy/components/ProxyLayout.tsx | 2 + apps/yaak-proxy/components/ProxyStatus.tsx | 132 +++--- apps/yaak-proxy/components/SettingsProxy.tsx | 4 +- apps/yaak-proxy/components/Sidebar.tsx | 80 +++- apps/yaak-proxy/harness/backend.ts | 111 ++++- apps/yaak-proxy/harness/httpsFixtures.ts | 172 ++++++++ apps/yaak-proxy/hooks/useDecrypt.tsx | 172 ++++++++ apps/yaak-proxy/hooks/useDecryptList.ts | 18 - apps/yaak-proxy/lib/body.test.ts | 25 ++ apps/yaak-proxy/lib/body.ts | 39 ++ apps/yaak-proxy/lib/decrypt.test.ts | 53 ++- apps/yaak-proxy/lib/decrypt.ts | 90 +++- apps/yaak-proxy/lib/decryptChoice.test.ts | 119 ++++++ apps/yaak-proxy/lib/decryptChoice.ts | 79 ++++ apps/yaak-proxy/lib/defaultTab.test.ts | 38 +- apps/yaak-proxy/lib/defaultTab.ts | 13 +- apps/yaak-proxy/lib/encryptedCard.test.ts | 335 +++++++++++++++ apps/yaak-proxy/lib/encryptedCard.ts | 404 ++++++++++++++++++ apps/yaak-proxy/lib/firstRun.test.ts | 20 +- apps/yaak-proxy/lib/firstRun.ts | 21 - apps/yaak-proxy/lib/hideSystem.test.ts | 46 +- apps/yaak-proxy/lib/httpsStrip.test.ts | 83 ++++ apps/yaak-proxy/lib/httpsStrip.ts | 107 +++++ apps/yaak-proxy/lib/query.test.ts | 13 +- apps/yaak-proxy/lib/query.ts | 14 +- apps/yaak-proxy/lib/sidebarTree.test.ts | 21 +- apps/yaak-proxy/lib/sidebarTree.ts | 74 ++-- apps/yaak-proxy/lib/store.ts | 45 +- packages/ui/src/components/CountBadge.tsx | 3 +- packages/ui/src/components/Dropdown.tsx | 7 +- .../components/http/HttpRequestPaneView.tsx | 7 +- .../components/http/HttpResponsePaneView.tsx | 6 +- 43 files changed, 3019 insertions(+), 586 deletions(-) create mode 100644 apps/yaak-proxy/components/EncryptedCard.tsx delete mode 100644 apps/yaak-proxy/components/FlowTimeline.tsx create mode 100644 apps/yaak-proxy/components/HttpsStrip.tsx delete mode 100644 apps/yaak-proxy/components/NotDecrypted.tsx create mode 100644 apps/yaak-proxy/harness/httpsFixtures.ts create mode 100644 apps/yaak-proxy/hooks/useDecrypt.tsx create mode 100644 apps/yaak-proxy/lib/decryptChoice.test.ts create mode 100644 apps/yaak-proxy/lib/decryptChoice.ts create mode 100644 apps/yaak-proxy/lib/encryptedCard.test.ts create mode 100644 apps/yaak-proxy/lib/encryptedCard.ts create mode 100644 apps/yaak-proxy/lib/httpsStrip.test.ts create mode 100644 apps/yaak-proxy/lib/httpsStrip.ts diff --git a/apps/yaak-proxy/components/DecryptListEditor.tsx b/apps/yaak-proxy/components/DecryptListEditor.tsx index 779b4d323..04d107cae 100644 --- a/apps/yaak-proxy/components/DecryptListEditor.tsx +++ b/apps/yaak-proxy/components/DecryptListEditor.tsx @@ -1,56 +1,205 @@ import { Prec } from "@codemirror/state"; import { type EditorView, keymap } from "@codemirror/view"; import type { DecryptListEntry, SeenTargets } from "@yaakapp-internal/proxy-lib"; -import { CONTROL_TOOLTIP_DELAY, Icon, IconButton, Input, Tooltip } from "@yaakapp-internal/ui"; +import { + Button, + CONTROL_TOOLTIP_DELAY, + Icon, + IconButton, + Input, + SettingRow, + SettingRowBoolean, + Tooltip, +} from "@yaakapp-internal/ui"; import { filterAutocomplete } from "@yaakapp-internal/ui/editor"; import classNames from "classnames"; import { useMemo, useRef, useState } from "react"; -import { useDecryptList, useEditDecryptList } from "../hooks/useDecryptList"; +import { canTrustHere, useDecrypt, useTrustCertificate, useTrusting } from "../hooks/useDecrypt"; +import { useDecryptList } from "../hooks/useDecryptList"; import { useRpcQuery } from "../hooks/useRpcQuery"; -import { decryptFilterOptions, entryLabel, normalizeEntry } from "../lib/decrypt"; +import { + decryptFilterOptions, + decryptsEverything, + entryLabel, + entrySubject, + EVERYTHING, + everythingEdit, + normalizeEntry, +} from "../lib/decrypt"; +import { revealPath } from "../lib/opener"; +import { useShowToast } from "../lib/toast"; /** - * What HTTPS is decrypted: one row per entry, the user's in order and then the built-in - * exclusions, each removable, and an input that adds one, suggesting `host:`, `app:` and `!`, - * then the hosts and apps seen in the capture. Saves as it changes. Placed by Settings' Proxy - * tab. + * Settings' Decryption section: the certificate, Decrypt everything as a switch over `*`, then + * the person's own hosts and apps in order, each removable, an input that adds one (suggesting + * `host:`, `app:` and `!`, then the hosts and apps seen), and the built-in exclusions folded + * into one row. Every change goes through Decrypt, so the first that decrypts something trusts + * the certificate first. */ -export function DecryptListEditor({ className }: { className?: string }) { +export function DecryptionSettings() { const list = useDecryptList(); - const save = useEditDecryptList(); + const { apply } = useDecrypt(); + const [busy, setBusy] = useState(false); // Read when the input opens its suggestions, so new traffic shows without rebuilding it. const seen = useRpcQuery("list_seen", {}, { staleTime: 0, refetchOnMount: "always" }); const seenRef = useRef(seen.data); seenRef.current = seen.data; + const everything = decryptsEverything(list); + const own = list.filter((e) => !e.builtIn && e.entry !== EVERYTHING); + const builtIns = list.filter((e) => e.builtIn); - // Each change replaces the whole list, so one waits for the last to land rather than undo it. - const remove = (entry: string) => { - if (save.isPending) return; - save.mutate({ add: [], remove: [entry] }); + // One change at a time, each applied to the list as it is when it lands. + const change = async ( + edit: { add: string[]; remove: string[] }, + message: string, + sub?: string, + ) => { + if (busy) return; + setBusy(true); + try { + await apply({ edit, message, sub }); + } finally { + setBusy(false); + } }; + const remove = (entry: string) => + void change( + { add: [], remove: [entry] }, + entry.startsWith("!") + ? `Stopped excluding ${entrySubject(entry.slice(1))}` + : `Stopped decrypting ${entrySubject(entry)}`, + ); return ( -
- save.mutate({ add: [entry], remove: [] })} + <> + + + void change( + everythingEdit(on), + on ? "Decrypting everything" : "Stopped decrypting everything", + on ? "Except Apple services and apps that pin" : undefined, + ) + } /> -
- {list.length === 0 && ( -
Nothing decrypted
+
+
+ Hosts and apps + New connections +
+ + void change( + { add: [entry], remove: [] }, + entry.startsWith("!") + ? `Excluding ${entrySubject(entry.slice(1))}` + : `Decrypting ${entrySubject(entry)}`, + ) + } + /> +
+ {own.length === 0 && ( +
Nothing listed
+ )} + {own.map((e) => ( + remove(e.entry)} /> + ))} + {builtIns.length > 0 && } +
+
+ + ); +} + +/** The certificate: trusted, with Reveal for devices and tools with their own stores, or Trust */ +function CertificateRow() { + const caStatus = useRpcQuery("ca_status", {}); + const trust = useTrustCertificate(); + const trusting = useTrusting(); + const showToast = useShowToast(); + const status = caStatus.data; + if (status == null) + return ( + + {null} + + ); + return ( + +
+ {status.trusted ? ( + + + Trusted + + ) : ( + Not trusted )} - {list.map((e) => ( + {!status.trusted && canTrustHere() ? ( + + ) : ( + + )} +
+
+ ); +} + +/** The built-in exclusions, folded into one row that opens to each, with its kind */ +function BuiltIns({ + entries, + disabled, + onRemove, +}: { + entries: DecryptListEntry[]; + disabled: boolean; + onRemove: (entry: string) => void; +}) { + const [open, setOpen] = useState(false); + const kinds = new Set(entries.map((e) => e.kind)); + const summary = [kinds.has("apple") ? "Apple" : null, kinds.has("pinned") ? "pinned apps" : null] + .filter((k) => k != null) + .join(", "); + return ( + <> + + {open && + entries.map((e) => ( remove(e.entry)} + disabled={disabled} + onRemove={() => onRemove(e.entry)} /> ))} -
-
+ ); } @@ -81,12 +230,10 @@ function EntryRow({ {entryLabel(entry.entry)} - {entry.builtIn && ( - - - built-in - - + {entry.builtIn && entry.kind != null && ( + + {entry.kind === "apple" ? "Apple" : "Pinned"} + )} setError(null)} /> diff --git a/apps/yaak-proxy/components/EncryptedCard.tsx b/apps/yaak-proxy/components/EncryptedCard.tsx new file mode 100644 index 000000000..d9be5359f --- /dev/null +++ b/apps/yaak-proxy/components/EncryptedCard.tsx @@ -0,0 +1,274 @@ +import { formatSize } from "@yaakapp-internal/lib/formatSize"; +import type { Flow } from "@yaakapp-internal/proxy-lib"; +import { + Button, + Dropdown, + formatMillis, + Icon, + IconButton, + type IconProps, + InlineCode, +} from "@yaakapp-internal/ui"; +import classNames from "classnames"; +import { type ReactNode, useEffect, useState } from "react"; +import { canTrustHere, useDecrypt, usePassthrough, useTrustCertificate } from "../hooks/useDecrypt"; +import { useDecryptList } from "../hooks/useDecryptList"; +import { useRpcMutation } from "../hooks/useRpcMutation"; +import { useRpcQuery } from "../hooks/useRpcQuery"; +import { + type CardAction, + connectionOf, + encryptedCard, + isOpen, + isRejection, +} from "../lib/encryptedCard"; +import { useFlowActions } from "../lib/flowActions"; +import { utcTimestamp } from "../lib/flowViews"; +import { useShowToast } from "../lib/toast"; +import { AppIcon } from "./AppIcon"; +import { useCopyEnvironment } from "./ProxyStatus"; + +/** + * The card for HTTPS the proxy did not decrypt, or a client's refused handshake: the flow card's + * header lines, with ENCRYPTED or REJECTED where the status goes, then why in one line and the + * changes that would decrypt the next connection like it, each showing the entry it writes. + */ +export function EncryptedCard({ flow }: { flow: Flow }) { + const list = useDecryptList(); + const caStatus = useRpcQuery("ca_status", {}); + const passthrough = usePassthrough(); + const rejection = isRejection(flow); + // A tunnel passed through after a rejection names the client that refused. + const rejectedById = flow.tlsRejected == null ? flow.tunnelRejectedBy : null; + const rejectedBy = useRpcQuery( + "get_flows", + { ids: rejectedById == null ? [] : [rejectedById] }, + { enabled: rejectedById != null }, + ).data?.[0]; + const model = encryptedCard({ + flow, + list, + trusted: caStatus.data?.trusted ?? null, + canTrust: canTrustHere(), + passthrough, + rejectedBy, + }); + const { apply, trusting } = useDecrypt(); + const trust = useTrustCertificate(); + const copyEnvironment = useCopyEnvironment(); + const showToast = useShowToast(); + const fail = (err: Error) => + showToast({ id: "encrypted-card", color: "danger", message: err.message }); + const forget = useRpcMutation("forget_passthrough", { onError: fail }); + const reconnect = useRpcMutation("reconnect_tunnels", { onError: fail }); + const connection = connectionOf(flow); + const [busy, setBusy] = useState(null); + + const run = async (action: CardAction) => { + switch (action.kind) { + case "decrypt": + case "stop": + if (action.edit == null) return; + setBusy(action.label); + await apply({ + edit: action.edit, + message: action.toast?.message ?? action.label, + sub: action.toast?.sub, + }); + setBusy(null); + return; + case "reconnect": + reconnect.mutate({ flowId: flow.id }); + return; + case "trust": + trust.mutate({}); + return; + case "try_again": + forget.mutate({ address: connection.address }); + return; + case "copy_environment": + await copyEnvironment(); + forget.mutate({ address: connection.address }); + return; + } + }; + + return ( +
+
+ + + {connection.host.includes(":") ? `[${connection.host}]` : connection.host} + :{connection.port} + +
+ +
+
+
+ {model.why} + {model.whyEntry != null && ( + <> + {" "} + {model.whyEntry} + + )} + {model.whySub != null && · {model.whySub}} +
+ {model.fact != null && ( +
{model.fact}
+ )} + {model.actions.map((action) => ( + void run(action)} + /> + ))} + {model.foot.length > 0 && ( +
{model.foot.join(" · ")}
+ )} + {flow.error != null && flow.tlsRejected == null && ( +
{flow.error}
+ )} +
+
+
+ ); +} + +const ICONS: Record, IconProps["icon"]> = { + host: "globe", + everything: "layers", + lock: "lock", + lock_open: "lock_open", + refresh: "refresh", + shield: "shield", + terminal: "square_terminal", +}; + +function ActionButton({ + action, + flow, + loading, + disabled, + onClick, +}: { + action: CardAction; + flow: Flow; + loading: boolean; + disabled: boolean; + onClick: () => void; +}) { + const icon: ReactNode = + action.icon === "app" ? ( + flow.clientPath == null ? ( + + ) : ( + + ) + ) : ( + + ); + return ( + + ); +} + +/** ENCRYPTED or REJECTED, the duration, or how long it has been open, and the bytes each way */ +function StatusLine({ flow, rejected }: { flow: Flow; rejected: boolean }) { + const flowActions = useFlowActions(); + const open = isOpen(flow); + const openFor = useOpenFor(flow, open); + return ( +
+
+ + {rejected ? "REJECTED" : "ENCRYPTED"} + + • + {open ? ( + open {openFor == null ? "" : formatMillis(openFor)} + ) : ( + <> + {flow.elapsedMs == null ? "--" : formatMillis(flow.elapsedMs)} + • + + + {formatSize(flow.requestSize)} + + {formatSize(flow.responseSize)} + + + )} +
+
+ + + +
+
+ ); +} + +/** How long an open tunnel has been open, ticking each second */ +function useOpenFor(flow: Flow, open: boolean): number | null { + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + if (!open) return; + const timer = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(timer); + }, [open]); + const started = Date.parse(utcTimestamp(flow.startedAt)); + return Number.isFinite(started) ? Math.max(0, now - started) : null; +} diff --git a/apps/yaak-proxy/components/FirstRun.tsx b/apps/yaak-proxy/components/FirstRun.tsx index 81484f6a9..3c31f8e57 100644 --- a/apps/yaak-proxy/components/FirstRun.tsx +++ b/apps/yaak-proxy/components/FirstRun.tsx @@ -1,18 +1,8 @@ -import { - Button, - CONTROL_TOOLTIP_DELAY, - Icon, - InlineCode, - LoadingIcon, - Tooltip, -} from "@yaakapp-internal/ui"; +import { Button, Icon, InlineCode, LoadingIcon } from "@yaakapp-internal/ui"; import classNames from "classnames"; import { useAtomValue } from "jotai"; import type { ReactNode } from "react"; -import { useDecryptList, useEditDecryptList } from "../hooks/useDecryptList"; import { useRpcMutation } from "../hooks/useRpcMutation"; -import { everythingEdit } from "../lib/decrypt"; -import { decryptStep } from "../lib/firstRun"; import { encryptingPercent } from "../lib/encrypting"; import { proxyStateAtom } from "../lib/store"; import { useCopyEnvironment, useProxySetup, useSystemProxySwitch } from "./ProxyStatus"; @@ -20,36 +10,27 @@ import { useCopyEnvironment, useProxySetup, useSystemProxySwitch } from "./Proxy type StepState = "todo" | "next" | "wait" | "done" | "fail"; /** - * The list's empty state until traffic arrives: trust the certificate, turn on the system proxy, - * choose what to decrypt, then see traffic. Not dismissable and nothing remembered; the first flow replaces it. Copy - * environment beside it serves someone who wants one terminal proxied and nothing system-wide. + * The list's empty state until traffic arrives: turn on the system proxy, then see traffic. The + * certificate is trusted at the first Decrypt, not here, as nothing needs it before. Not + * dismissable and nothing remembered; the first flow replaces it. Copy environment beside it + * serves someone who wants one terminal proxied and nothing system-wide. */ export function FirstRun() { const proxyState = useAtomValue(proxyStateAtom); const setup = useProxySetup(); - const { caStatus, trust, systemProxy, setSystemProxy } = setup; + const { systemProxy, setSystemProxy } = setup; const systemSwitch = useSystemProxySwitch(setup); const execute = useRpcMutation("execute_action"); const retry = useRpcMutation("take_over"); const copyEnvironment = useCopyEnvironment(); - const decryptList = useDecryptList(); - const saveDecryptList = useEditDecryptList(); const running = proxyState?.state === "running"; // Changed only by the process that owns the engine, so not from here while the CLI does. const otherOwner = proxyState?.otherOwner != null; const recording = proxyState?.recording !== false; - const trusted = caStatus.data?.trusted === true; const systemSupported = systemProxy.data?.supported !== false; const systemOn = systemProxy.data?.enabled === true; - const trustState: StepState = trusted - ? "done" - : trust.isPending - ? "wait" - : trust.error != null - ? "fail" - : "next"; const systemState: StepState = setSystemProxy.isPending ? "wait" : systemOn @@ -57,10 +38,6 @@ export function FirstRun() { : systemSwitch.failed ? "fail" : "next"; - const decrypt = decryptStep(decryptList, trusted); - const everything = decrypt.choice === "everything"; - const setEverything = (on: boolean) => saveDecryptList.mutate(everythingEdit(on)); - // The certificate is optional here: nothing needs it until something is decrypted. const ready = systemOn || !systemSupported; const encrypting = !running && !otherOwner ? encryptingPercent(proxyState) : null; const trafficState: StepState = @@ -71,39 +48,14 @@ export function FirstRun() {
Set up Yaak Proxy
- {systemSupported ? "Four" : "Three"} steps, once. Then every app on this Mac sends its - traffic here. + {systemSupported + ? "Two steps, once. Then every app on this Mac sends its traffic here." + : "Traffic appears here as apps send it."}
- trust.mutate({})} - > - {trustState === "fail" ? "Try Again" : "Trust Certificate"} - - ) : null - } - /> {systemSupported && ( )} - Everything - - ) : ( - - Pick as they appear - - ) - } - action={ - everything ? ( - - ) : ( - - ) - } - /> - ( + () => [{ tab: { value: REQUEST_TAB_INFO, label: "Info" }, content: }], + [flow], + ); + const contentType = findHeader(flow.requestHeaders, "content-type"); + const badge = bodyTypeBadge(contentType, flow.requestSize, flow.graphqlOperation != null); + const bodyTab = useMemo(() => bodyTabItem(REQUEST_TAB_BODY, badge), [badge]); const available = [ + REQUEST_TAB_INFO, REQUEST_TAB_BODY, REQUEST_TAB_PARAMS, REQUEST_TAB_HEADERS, @@ -169,8 +176,11 @@ function FlowRequestHalf({ flow }: { flow: Flow }) { request={request} fullHeight forceUpdateKey={forceUpdateKey} - tabsStorageKey="proxy_request_tabs" + // Info joined as the first tab: an order dragged before it would put it last + tabsStorageKey="proxy_request_tabs.2" bodyLoader={loader} + bodyTab={bodyTab} + leadingTabs={leadingTabs} extraTabs={extraTabs} defaultTab={defaultTab} activeTab={activeTab} @@ -208,6 +218,15 @@ function FlowRequestHalf({ flow }: { flow: Flow }) { ); } +/** A half's body tab: Body, with the body's type beside it as the Headers count is, if any */ +function bodyTabItem(value: string, badge: string | null): TabItem { + return { + value, + label: "Body", + rightSlot: badge == null ? null : , + }; +} + /** The method and URL, at the status line's height so the two header lines align */ function UrlLine({ flow }: { flow: Flow }) { return ( @@ -244,14 +263,11 @@ function FlowResponseHalf({ flow, className }: { flow: Flow; className: string } const flowActions = useFlowActions(); const messagesLabel = messagesTabLabel(flow); + const badge = bodyTypeBadge(flow.contentType, flow.responseSize); + const bodyTab = useMemo(() => bodyTabItem(RESPONSE_TAB_BODY, badge), [badge]); const extraTabs = useMemo( - () => [ - { - tab: { value: RESPONSE_TAB_TIMELINE, label: "Timeline" }, - content: , - }, - { tab: { value: RESPONSE_TAB_INFO, label: "Info" }, content: }, - ...(messagesLabel == null + () => + messagesLabel == null ? [] : [ { @@ -269,16 +285,13 @@ function FlowResponseHalf({ flow, className }: { flow: Flow; className: string } }, content: , }, - ]), - ], + ], [flow, messagesLabel], ); const available = [ RESPONSE_TAB_BODY, RESPONSE_TAB_HEADERS, RESPONSE_TAB_COOKIES, - RESPONSE_TAB_TIMELINE, - RESPONSE_TAB_INFO, ...(messagesLabel == null ? [] : [RESPONSE_TAB_MESSAGES]), ]; const { activeTab, defaultTab, onTabChange } = useShapeTab("response", flow, available); @@ -299,6 +312,7 @@ function FlowResponseHalf({ flow, className }: { flow: Flow; className: string } activeTab={activeTab} onTabChange={onTabChange} extraTabs={extraTabs} + bodyTab={bodyTab} renderBodyViewer={() => } statusLineCenter={ flow.responseTruncated ? ( diff --git a/apps/yaak-proxy/components/FlowDetail.tsx b/apps/yaak-proxy/components/FlowDetail.tsx index 3a2edbf90..f87526c19 100644 --- a/apps/yaak-proxy/components/FlowDetail.tsx +++ b/apps/yaak-proxy/components/FlowDetail.tsx @@ -2,9 +2,10 @@ import classNames from "classnames"; import { useAtomValue } from "jotai"; import type { CSSProperties } from "react"; import { selectedFlowAtom } from "../lib/atoms"; +import { isRejection } from "../lib/encryptedCard"; +import { EncryptedCard } from "./EncryptedCard"; import { EmptyState } from "./FlowBodyViewer"; import { FlowCard } from "./FlowCard"; -import { NotDecrypted } from "./NotDecrypted"; /** * The detail pane: the selected flow's card, or what the pane holds before one is selected. The @@ -35,8 +36,8 @@ export function FlowDetail({ style, sideBySide }: { style?: CSSProperties; sideB
- ) : flow.tunnelled ? ( - + ) : flow.tunnelled || isRejection(flow) ? ( + ) : ( )} diff --git a/apps/yaak-proxy/components/FlowInfo.tsx b/apps/yaak-proxy/components/FlowInfo.tsx index 7286729d9..83b1081c3 100644 --- a/apps/yaak-proxy/components/FlowInfo.tsx +++ b/apps/yaak-proxy/components/FlowInfo.tsx @@ -1,16 +1,26 @@ import { formatSize } from "@yaakapp-internal/lib/formatSize"; import type { Flow } from "@yaakapp-internal/proxy-lib"; -import { DetailsBanner, formatMillis, KeyValueRow, KeyValueRows } from "@yaakapp-internal/ui"; +import { + CONTROL_TOOLTIP_DELAY, + DetailsBanner, + formatMillis, + Icon, + type IconProps, + KeyValueRow, + KeyValueRows, + Tooltip, +} from "@yaakapp-internal/ui"; import { format } from "date-fns"; -import type { HTMLAttributes, ReactElement, ReactNode } from "react"; +import { Fragment, type HTMLAttributes, type ReactElement, type ReactNode, useMemo } from "react"; import { utcTimestamp } from "../lib/flowViews"; +import { flowPhases, type PhaseKind } from "../lib/phases"; import { folderOf } from "../lib/search"; import { AppIcon } from "./AppIcon"; /** * Where a flow came from and how it travelled: in a proxy you didn't send the request, so the * connection on each leg, the app and its process, timing, sizes and the flow's record are part - * of the answer. + * of the answer. Then the timeline: each phase of the exchange and how long it took. */ export function FlowInfo({ flow }: { flow: Flow }) { const folder = folderOf(flow); @@ -72,10 +82,55 @@ export function FlowInfo({ flow }: { flow: Flow }) { row("saved", "Saved As", flow.savedRequestId, { copy: true }), ]} +
); } +const PHASE_ICONS: Record = { + start: { icon: "send_horizontal", color: "secondary" }, + step: { icon: "arrow_right", color: "secondary" }, + end: { icon: "check", color: "success" }, + error: { icon: "alert_triangle", color: "danger" }, + cancelled: { icon: "circle_off", color: "secondary" }, +}; + +/** The flow's phases, each with how long it took and when, from the start */ +function Timeline({ flow }: { flow: Flow }) { + const phases = useMemo(() => flowPhases(flow), [flow]); + if (phases.length === 0) return null; + return ( + Timeline}> +
+ {phases.map((p) => { + const { icon, color } = PHASE_ICONS[p.kind]; + return ( + + + {p.detail == null ? ( + {p.label} + ) : ( + + + {p.label} + {` ${p.detail}`} + + + )} + + {p.kind === "start" ? "" : `+${formatMillis(p.tookMs)}`} + + + {formatMillis(p.atMs)} + + + ); + })} +
+
+ ); +} + /** A row as `KeyValueRows` takes it */ type Row = ReactElement>; diff --git a/apps/yaak-proxy/components/FlowList.tsx b/apps/yaak-proxy/components/FlowList.tsx index 6a79643a1..a1f1b4043 100644 --- a/apps/yaak-proxy/components/FlowList.tsx +++ b/apps/yaak-proxy/components/FlowList.tsx @@ -55,6 +55,7 @@ import { compareFlows } from "../lib/flowWindow"; import { countNewFlows, isAtEnd, resumesFollowing } from "../lib/follow"; import { applyFilter, goToPlace } from "../lib/navigation"; import { asOneTerm } from "../lib/query"; +import { connectionOf } from "../lib/encryptedCard"; import { folderOf } from "../lib/search"; import { columnPrefsAtom, @@ -853,6 +854,9 @@ const FlowRow = memo(function FlowRow({ // A failure's reason, or Cancelled, takes the type and size cells together, side by side. const typeAt = ids.indexOf("type"); const spanReason = noResponse && typeAt !== -1 && ids[typeAt + 1] === "size"; + // An encrypted connection has no path, so its host takes the room when Path is beside it. + const hostAt = ids.indexOf("host"); + const spanHost = flow.tunnelled && hostAt !== -1 && ids[hostAt + 1] === "path"; const sinceArrival = arrivedAt == null ? null : Date.now() - arrivedAt; return ( @@ -873,8 +877,8 @@ const FlowRow = memo(function FlowRow({ : {}), }} className={classNames( - "absolute top-0 left-1.5 right-1.5 grid items-center px-1.5 rounded-md whitespace-nowrap", - "tabular-nums", + "group/row absolute top-0 left-1.5 right-1.5 grid items-center px-1.5 rounded-md", + "whitespace-nowrap tabular-nums", flow.cancelled && "text-text-subtlest", selected ? "text-text bg-surface-highlight group-focus-within/flows:bg-surface-active" @@ -885,10 +889,16 @@ const FlowRow = memo(function FlowRow({ {columns.map((c) => { if (spanReason && c.id === "size") return null; + if (spanHost && c.id === "path") return null; return ( // Names the cell's column for its menu, without a box of its own in the row's grid
- +
); })} @@ -896,22 +906,41 @@ const FlowRow = memo(function FlowRow({ ); }); +/** Why an encrypted row is, for its mark's tooltip */ +function encryptedReason(flow: Flow): string { + switch (flow.tunnelReason) { + case "excluded": + return flow.tunnelExcludedBuiltIn || flow.tunnelExcludedBy == null + ? "Encrypted: excluded by default" + : `Encrypted: excluded by ${flow.tunnelExcludedBy}`; + case "rejected": + return "Encrypted: passed through after a client rejected the certificate"; + default: + return "Encrypted: not on the decrypt list"; + } +} + /** - * One mark per row, by a fixed priority: failed, cancelled, not decrypted, streaming, cut, saved. - * Its tooltip lists every state the flow has. Agent reads join it once the engine records them. + * One mark per row, by a fixed priority: rejected, failed, cancelled, encrypted, streaming, cut, + * saved. Its tooltip lists every state the flow has. Agent reads join it once the engine records + * them. */ function FlowMark({ flow }: { flow: Flow }) { const open = isOpenStream(flow); + const rejected = flow.tlsRejected != null; const states: string[] = []; - if (flow.error != null) states.push(`Failed: ${flow.error}`); + if (rejected) states.push("Rejected the proxy's certificate"); + else if (flow.error != null) states.push(`Failed: ${flow.error}`); if (flow.cancelled) states.push("Cancelled by the app"); - if (flow.tunnelled) states.push("Not decrypted"); + if (flow.tunnelled) states.push(encryptedReason(flow)); if (open) states.push("Streaming"); if (flow.responseTruncated) states.push("Body cut at the capture limit"); if (flow.savedRequestId != null) states.push("Saved to Yaak"); const mark = ( - {flow.error != null ? ( + {rejected ? ( + + ) : flow.error != null ? ( ) : flow.cancelled ? ( @@ -958,7 +987,122 @@ function isOpenStream(flow: Flow): boolean { return flow.streamKind != null && flow.elapsedMs == null && flow.error == null && !flow.cancelled; } -function FlowCell({ column, flow, span }: { column: ColumnId; flow: Flow; span: boolean }) { +interface CellProps { + column: ColumnId; + flow: Flow; + /** Takes the next cell's room too */ + span: boolean; + selected: boolean; +} + +/** + * An encrypted connection's cell: quiet, and only what is known, so its host, how much it + * carried and for how long; nothing read from inside it, so no method, path, status, type, + * query or field. Null for the cells that show as on any row. + */ +function encryptedCell({ column, flow, span, selected }: CellProps): ReactElement | null { + const quiet = classNames(cell, mono, "text-text-subtlest"); + switch (column) { + case "number": + case "started": + return null; + case "app": + if (flow.clientPath == null) return ; + return ( + + + + + + ); + case "host": { + const { label } = connectionOf(flow); + const colon = label.lastIndexOf(":"); + const port = colon === -1 || label.endsWith("]") ? null : label.slice(colon); + const host = port == null ? label : label.slice(0, colon); + return ( + + + + {host} + {port != null && {port}} + + + + ); + } + case "size": + return ( + + {isOpenTunnel(flow) ? "" : formatSize(flow.responseSize)} + + ); + case "requestSize": + return ( + + {isOpenTunnel(flow) ? "" : formatSize(flow.requestSize)} + + ); + case "time": { + const ms = isOpenTunnel(flow) + ? Date.now() - Date.parse(utcTimestamp(flow.startedAt)) + : flow.elapsedMs; + return ( + + {ms == null || !Number.isFinite(ms) ? "" : formatMillis(Math.max(0, ms))} + + ); + } + default: + return ; + } +} + +/** + * A refused handshake's cell: its host as any row's, the refusal across Type and Size in + * warning, and nothing for the request it never made. Null for the cells that show as on any + * row. + */ +function rejectedCell({ column, span }: CellProps): ReactElement | null { + switch (column) { + case "method": + case "path": + case "status": + case "query": + case "size": + return ; + case "type": + return ( + + Rejected certificate + + ); + default: + return null; + } +} + +function FlowCell(props: CellProps) { + const { column, flow, span } = props; + const special = flow.tunnelled + ? encryptedCell(props) + : flow.tlsRejected != null + ? rejectedCell(props) + : null; + if (special != null) return special; const failed = flow.error != null; const noResponse = (failed || flow.cancelled) && flow.status == null; switch (column) { @@ -1005,10 +1149,6 @@ function FlowCell({ column, flow, span }: { column: ColumnId; flow: Flow; span: ); } case "path": { - // A tunnel's only path is its host; what it carried is unread. - if (flow.tunnelled) { - return Not decrypted; - } const path = pathOf(flow.url); if (flow.streamKind == null) { return ( @@ -1034,7 +1174,7 @@ function FlowCell({ column, flow, span }: { column: ColumnId; flow: Flow; span: ); } case "status": - return (flow.cancelled || flow.tunnelled) && flow.status == null ? ( + return flow.cancelled && flow.status == null ? ( -- ) : ( @@ -1104,7 +1244,7 @@ function FlowCell({ column, flow, span }: { column: ColumnId; flow: Flow; span: ); case "query": { - const query = flow.tunnelled ? "" : (urlParts(flow.url).query ?? ""); + const query = urlParts(flow.url).query ?? ""; return ( {query} diff --git a/apps/yaak-proxy/components/FlowListEmpty.tsx b/apps/yaak-proxy/components/FlowListEmpty.tsx index 1b71d059f..839d79de4 100644 --- a/apps/yaak-proxy/components/FlowListEmpty.tsx +++ b/apps/yaak-proxy/components/FlowListEmpty.tsx @@ -6,7 +6,13 @@ import { appliedFilterAtom } from "../lib/atoms"; import { applyFilter } from "../lib/navigation"; import { rpc } from "../lib/rpc"; import { activeQuery, widerQueries } from "../lib/query"; -import { hideSystemAtom, pausedAtAtom, proxyStateAtom, searchWindowAtom } from "../lib/store"; +import { + hideEncryptedAtom, + hideSystemAtom, + pausedAtAtom, + proxyStateAtom, + searchWindowAtom, +} from "../lib/store"; /** Nothing to list: no flows yet, recording paused, or a query nothing matches */ export function FlowListEmpty() { @@ -64,6 +70,7 @@ export function PausedRow({ style }: { style?: React.CSSProperties }) { */ function NoResults({ text }: { text: string }) { const hideSystem = useAtomValue(hideSystemAtom); + const hideEncrypted = useAtomValue(hideEncryptedAtom); const store = useStore(); const setText = (wider: string) => applyFilter(store, wider); const [wider, setWider] = useState<{ text: string; count: number }[]>([]); @@ -73,7 +80,7 @@ function NoResults({ text }: { text: string }) { const candidates = widerQueries(text); void Promise.all( candidates.map(async (candidate) => { - const query = activeQuery(candidate, hideSystem); + const query = activeQuery(candidate, hideSystem, hideEncrypted); const res = await rpc("list_flows", { ...query, limit: 0 }); return { text: candidate, count: query == null ? res.total : (res.matching ?? res.total) }; }), @@ -87,7 +94,7 @@ function NoResults({ text }: { text: string }) { return () => { current = false; }; - }, [text, hideSystem]); + }, [text, hideSystem, hideEncrypted]); return ( = { - start: { icon: "send_horizontal", color: "secondary" }, - step: { icon: "arrow_right", color: "secondary" }, - end: { icon: "check", color: "success" }, - error: { icon: "alert_triangle", color: "danger" }, - cancelled: { icon: "circle_off", color: "secondary" }, -}; - -/** Yaak's event list with the flow's phases as rows, each saying how long it took */ -export function FlowTimeline({ flow }: { flow: Flow }) { - const phases = useMemo(() => flowPhases(flow), [flow]); - return ( - - events={phases} - getEventKey={(p) => p.key} - splitLayoutStorageKey="proxy_flow_timeline" - enableKeyboardNav={false} - emptyMessage="No timings recorded" - renderRow={({ event, isActive, onClick }) => { - const { icon, color } = ICONS[event.kind]; - return ( - } - content={ - event.detail == null ? ( - {event.label} - ) : ( - - - {event.label} - {` ${event.detail}`} - - - ) - } - columns="auto minmax(0,1fr) 6rem 6rem" - cells={{ - trailing: [ - - {event.kind === "start" ? "" : `+${formatMillis(event.tookMs)}`} - , - - {formatMillis(event.atMs)} - , - ], - }} - /> - ); - }} - /> - ); -} diff --git a/apps/yaak-proxy/components/HttpsStrip.tsx b/apps/yaak-proxy/components/HttpsStrip.tsx new file mode 100644 index 000000000..632aa594e --- /dev/null +++ b/apps/yaak-proxy/components/HttpsStrip.tsx @@ -0,0 +1,174 @@ +import { + Button, + CONTROL_TOOLTIP_DELAY, + Dropdown, + type DropdownItem, + Icon, + IconButton, + Tooltip, +} from "@yaakapp-internal/ui"; +import { useAtomValue } from "jotai"; +import { useMemo, useState } from "react"; +import { useLocalStorage } from "react-use"; +import { canTrustHere, useDecrypt, useTrustCertificate, useTrusting } from "../hooks/useDecrypt"; +import { useDecryptList } from "../hooks/useDecryptList"; +import { useRpcQuery } from "../hooks/useRpcQuery"; +import { everythingEdit, listEdit, setDecrypted } from "../lib/decrypt"; +import { chooseOptions, connectionCount, stripState } from "../lib/httpsStrip"; +import { sidebarFlowsAtom } from "../lib/store"; +import { AppIcon } from "./AppIcon"; +import { SETTINGS_TAB_PROXY, useOpenSettings } from "./Settings"; + +const STRIP = + "flex items-center gap-2.5 h-[2.2rem] mx-2 mb-1 pl-3 pr-1.5 rounded-md bg-surface-highlight/45 border border-border-subtle text-sm text-text-subtle min-w-0"; + +/** + * Above the list while nothing is decrypted: says once that HTTPS is encrypted, and offers + * Decrypt Everything, or Choose… for apps and sites one at a time. Goes once anything is on + * the list, or when dismissed; comes back, with Trust, while the list decrypts something the + * certificate isn't trusted for. + */ +export function HttpsStrip() { + const list = useDecryptList(); + const flows = useAtomValue(sidebarFlowsAtom); + const caStatus = useRpcQuery("ca_status", {}); + const [dismissed, setDismissed] = useLocalStorage("https_strip_dismissed", false); + // Kept while Choose… is open, so checking a row there leaves the menu where it is. + const [choosing, setChoosing] = useState(false); + const { apply } = useDecrypt(); + const trusting = useTrusting(); + const trust = useTrustCertificate(); + const openSettings = useOpenSettings(); + const encrypted = useMemo(() => flows.filter((f) => f.tunnelled).length, [flows]); + const state = stripState({ + list, + encrypted, + dismissed: dismissed ?? false, + trusted: caStatus.data?.trusted ?? null, + canTrust: canTrustHere(), + }); + const options = useMemo(() => chooseOptions(flows, list), [flows, list]); + + if (state === "hidden" && !choosing) return null; + + if (state === "broken") { + return ( +
+ + Certificate not trusted + Required to decrypt + +
+ ); + } + + const items: DropdownItem[] = [ + { type: "separator", label: "Apps", hidden: options.apps.length === 0 }, + ...options.apps.map((app): DropdownItem => ({ + label: + app.path == null ? ( + app.name + ) : ( + + + {app.name} + + ), + keepOpenOnSelect: true, + leftSlot: , + rightSlot: , + onSelect: async () => { + const subject = { kind: "app", name: app.name, bundleId: app.bundleId } as const; + await apply({ + edit: listEdit(list, setDecrypted(list, [subject], !app.checked)), + message: `${app.checked ? "Stopped decrypting" : "Decrypting"} ${app.name}`, + }); + }, + })), + { type: "separator", label: "Sites", hidden: options.sites.length === 0 }, + ...options.sites.map((site): DropdownItem => ({ + label: *.{site.site}, + keepOpenOnSelect: true, + leftSlot: , + rightSlot: , + onSelect: async () => { + const subject = { kind: "site", site: site.site } as const; + await apply({ + edit: listEdit(list, setDecrypted(list, [subject], !site.checked)), + message: `${site.checked ? "Stopped decrypting" : "Decrypting"} *.${site.site}`, + }); + }, + })), + { type: "separator" }, + { + label: "Edit List in Settings…", + leftSlot: , + onSelect: () => openSettings(SETTINGS_TAB_PROXY), + }, + ]; + return ( +
+ + HTTPS is encrypted + + {connectionCount(encrypted)} + +
+ + + + setChoosing(true)} onClose={() => setChoosing(false)}> + + + setDismissed(true)} + /> +
+
+ ); +} + +function Check({ on }: { on: boolean }) { + return on ? ( + + ) : ( + + ); +} + +function Count({ n }: { n: number }) { + return {n.toLocaleString()}; +} diff --git a/apps/yaak-proxy/components/NotDecrypted.tsx b/apps/yaak-proxy/components/NotDecrypted.tsx deleted file mode 100644 index b45e4bd8f..000000000 --- a/apps/yaak-proxy/components/NotDecrypted.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import { formatSize } from "@yaakapp-internal/lib/formatSize"; -import type { Flow } from "@yaakapp-internal/proxy-lib"; -import { Button, CONTROL_TOOLTIP_DELAY, formatMillis, Icon, Tooltip } from "@yaakapp-internal/ui"; -import { useDecryptList, useEditDecryptList } from "../hooks/useDecryptList"; -import { type DecryptSubject, detailActions, listEdit, setDecrypted } from "../lib/decrypt"; -import { sidebarPath } from "../lib/search"; -import { EmptyState } from "./FlowBodyViewer"; - -/** - * The detail of HTTPS passed through undecrypted: its sizes and time, and buttons that add its - * host or its app to the decrypt list, from the next connection on. - */ -export function NotDecrypted({ flow }: { flow: Flow }) { - const list = useDecryptList(); - const save = useEditDecryptList(); - const host = sidebarPath(flow.url).split("/")[0] ?? flow.url; - const hostSubject: DecryptSubject = { kind: "host", host }; - const appSubject: DecryptSubject | null = - flow.clientName == null - ? null - : { kind: "app", name: flow.clientName, bundleId: flow.clientBundleId }; - const app = flow.clientName; - const actions = detailActions( - list, - host, - app == null ? null : { name: app, bundleId: flow.clientBundleId }, - ); - const open = flow.elapsedMs == null && flow.error == null; - - const decrypt = (subject: DecryptSubject) => - save.mutate(listEdit(list, setDecrypted(list, [subject], true))); - - const facts = [ - `${formatSize(Number(flow.requestSize))} sent`, - `${formatSize(Number(flow.responseSize))} received`, - open ? "open" : flow.elapsedMs == null ? null : formatMillis(flow.elapsedMs), - ].filter((f) => f != null); - - return ( - -
-
- - Not decrypted -
-
{facts.join(" · ")}
- {flow.error != null &&
{flow.error}
} - {actions.listed ? ( - -
Decrypting new connections
-
- ) : actions.blocked != null ? ( - -
- Excluded by {actions.blocked} -
-
- ) : ( -
- {actions.host && ( - - )} - {actions.app && appSubject != null && ( - - )} -
- )} -
-
- ); -} diff --git a/apps/yaak-proxy/components/ProxyLayout.tsx b/apps/yaak-proxy/components/ProxyLayout.tsx index a6fc701a2..3649615d9 100644 --- a/apps/yaak-proxy/components/ProxyLayout.tsx +++ b/apps/yaak-proxy/components/ProxyLayout.tsx @@ -31,6 +31,7 @@ import { SavedSearchDialog } from "./SavedSearchDialog"; import { SearchBar } from "./SearchBar"; import { SettingsDialogFallback, useOpenSettings } from "./Settings"; import { Sidebar } from "./Sidebar"; +import { HttpsStrip } from "./HttpsStrip"; import { TakeOverBanner } from "./TakeOverBanner"; export function ProxyLayout() { @@ -166,6 +167,7 @@ export function ProxyLayout() { >
+
diff --git a/apps/yaak-proxy/components/ProxyStatus.tsx b/apps/yaak-proxy/components/ProxyStatus.tsx index e4b524544..a2f6b67c8 100644 --- a/apps/yaak-proxy/components/ProxyStatus.tsx +++ b/apps/yaak-proxy/components/ProxyStatus.tsx @@ -1,5 +1,5 @@ import { useQueryClient } from "@tanstack/react-query"; -import type { CaStatus, SystemProxyStatus } from "@yaakapp-internal/proxy-lib"; +import type { SystemProxyStatus } from "@yaakapp-internal/proxy-lib"; import { Button, Checkbox, @@ -13,10 +13,13 @@ import classNames from "classnames"; import { useAtom, useAtomValue } from "jotai"; import type { HTMLAttributes, ReactNode, Ref } from "react"; import { useEffect, useRef, useState } from "react"; +import { useDecrypt, useTrustCertificate } from "../hooks/useDecrypt"; +import { useDecryptList } from "../hooks/useDecryptList"; import { useRpcEvent } from "../hooks/useRpcEvent"; import { useRpcMutation } from "../hooks/useRpcMutation"; import { useRpcQuery } from "../hooks/useRpcQuery"; import { helperRegisteredAtAtom } from "../lib/atoms"; +import { decryptsAnything, decryptsEverything, everythingEdit, listSummary } from "../lib/decrypt"; import { encryptingPercent } from "../lib/encrypting"; import { environmentExports } from "../lib/env"; import { proxyStatusOf } from "../lib/proxyStatus"; @@ -27,9 +30,10 @@ import { useShowToast } from "../lib/toast"; /** * The header's state pill and its menu: the listening address, which copies itself, the state, - * Pause or Resume Recording, Override system proxy, and the certificate. The engine runs for as - * long as the app does, so the pill shows whether flows are being stored. A certificate that is not trusted steps out of the - * menu beside the pill until it is. Everything set once and left alone lives in Settings. + * Pause or Resume Recording, Override system proxy, and Decrypt everything. The engine runs for + * as long as the app does, so the pill shows whether flows are being stored. A certificate that + * is not trusted while something is listed to decrypt steps out of the menu beside the pill + * until it is. Everything set once and left alone lives in Settings. */ export function ProxyStatus() { const dropdownRef = useRef(null); @@ -38,14 +42,16 @@ export function ProxyStatus() { const setup = useProxySetup(); const execute = useRpcMutation("execute_action"); const retry = useRpcMutation("take_over"); - const { caStatus, trust, systemProxy, setSystemProxy } = setup; + const { caStatus, systemProxy, setSystemProxy } = setup; const systemSwitch = useSystemProxySwitch(setup); const running = proxyState?.state === "running"; const recording = proxyState?.recording !== false; const other = proxyState?.otherOwner ?? null; const port = proxyState?.port ?? other?.proxyPort ?? null; - const untrusted = caStatus.data != null && !caStatus.data.trusted; + const decryptList = useDecryptList(); + // Something is listed to decrypt but the certificate isn't trusted, so it can't be. + const broken = caStatus.data != null && !caStatus.data.trusted && decryptsAnything(decryptList); // The same status the header's tint takes its colour from const status = proxyStatusOf(proxyState); const encrypting = status === "encrypting" ? encryptingPercent(proxyState) : null; @@ -166,44 +172,7 @@ export function ProxyStatus() { /> ), }, - { - type: "content", - label: caStatus.data?.trusted ? ( - } - title="Certificate trusted" - description="Yaak Proxy CA, in your login keychain." - /> - ) : ( - } - title="Certificate not trusted" - description={ - trust.error != null ? ( - {trust.error.message} - ) : trust.isPending ? ( - "Waiting for your password in the macOS prompt." - ) : ( - "So HTTPS reads as plain text. macOS asks for your login password once." - ) - } - action={ - - } - /> - ), - }, + { type: "content", label: }, ]; return ( @@ -235,7 +204,7 @@ export function ProxyStatus() { - {untrusted && ( + {broken && ( dropdownRef.current?.open()}> Certificate not trusted @@ -245,6 +214,73 @@ export function ProxyStatus() { ); } +/** + * Decrypt everything, `*` on the list, as a switch: what else is listed when off, and Trust + * while something is listed and the certificate isn't trusted. + */ +function DecryptEverythingRow({ broken }: { broken: boolean }) { + const list = useDecryptList(); + const { apply, trusting } = useDecrypt(); + const trust = useTrustCertificate(); + const on = decryptsEverything(list); + if (broken) { + return ( + } + title="Decrypt everything" + description={Certificate not trusted} + action={ + + } + /> + ); + } + return ( + } + title="Decrypt everything" + description={ + trusting + ? "Waiting for macOS" + : on + ? "Except Apple services and apps that pin" + : (listSummary(list) ?? "Nothing listed") + } + action={ + + + void apply( + checked + ? { + edit: everythingEdit(true), + message: "Decrypting everything", + sub: "Except Apple services and apps that pin", + } + : { edit: everythingEdit(false), message: "Stopped decrypting everything" }, + ) + } + /> + + } + /> + ); +} + /** * The certificate and the system proxy, read once and shared by the header and the first-run * card, with the actions that change them. @@ -265,10 +301,6 @@ export function useProxySetup() { // The user comes back from System Settings, maybe having just allowed the helper. const { refetch: refetchSystemProxy } = systemProxy; useEffect(() => onWindowFocus(() => void refetchSystemProxy()), [refetchSystemProxy]); - const trust = useRpcMutation("ca_trust", { - mutationKey: ["ca_trust"], - onSuccess: (status) => queryClient.setQueryData(["ca_status", {}], status), - }); const setSystemProxy = useRpcMutation("system_proxy_set", { mutationKey: ["system_proxy_set"], onSuccess: (status) => { @@ -284,7 +316,7 @@ export function useProxySetup() { useEffect(() => { if (on && !everOn) setEverOn(true); }, [on, everOn, setEverOn]); - return { caStatus, trust, systemProxy, setSystemProxy }; + return { caStatus, systemProxy, setSystemProxy }; } /** The system proxy switch's state, from where it points and the change in flight */ diff --git a/apps/yaak-proxy/components/SettingsProxy.tsx b/apps/yaak-proxy/components/SettingsProxy.tsx index 41ee662c4..1838d4e7f 100644 --- a/apps/yaak-proxy/components/SettingsProxy.tsx +++ b/apps/yaak-proxy/components/SettingsProxy.tsx @@ -40,7 +40,7 @@ import { helperRow } from "../lib/systemProxy"; import { proxyStateAtom, refreshProxyState } from "../lib/store"; import { getOsType } from "../lib/tauri"; import { useShowToast } from "../lib/toast"; -import { DecryptListEditor } from "./DecryptListEditor"; +import { DecryptionSettings } from "./DecryptListEditor"; /** * The proxy's own settings tab: how it listens and the system proxy, what it decrypts, what @@ -89,7 +89,7 @@ export function SettingsProxy() { - + diff --git a/apps/yaak-proxy/components/Sidebar.tsx b/apps/yaak-proxy/components/Sidebar.tsx index cef3aecfa..1d5f6d8b5 100644 --- a/apps/yaak-proxy/components/Sidebar.tsx +++ b/apps/yaak-proxy/components/Sidebar.tsx @@ -10,21 +10,30 @@ import { import { atom, useAtom, useAtomValue, useSetAtom, useStore } from "jotai"; import { atomWithStorage } from "jotai/utils"; import { type PointerEvent, type SyntheticEvent, useCallback, useEffect } from "react"; -import { useDecryptList, useEditDecryptList } from "../hooks/useDecryptList"; +import { useDecrypt } from "../hooks/useDecrypt"; +import { useDecryptList } from "../hooks/useDecryptList"; import { useRpcMutation } from "../hooks/useRpcMutation"; import { appliedFilterAtom, SIDEBAR_TREE_ID } from "../lib/atoms"; -import { blockerOf, decryptMenuAction, isDecrypted, listEdit, setDecrypted } from "../lib/decrypt"; +import { + blockerOf, + decryptMenuAction, + decryptsEverything, + everythingEdit, + listEdit, + setDecrypted, +} from "../lib/decrypt"; import { revealLabel, revealPath } from "../lib/opener"; import { applyCollapsed, type CollapsedMap, collapsedMap, folderIds } from "../lib/sidebarCollapse"; import { copyRowsText, + ENCRYPTED_ID, filterWithRows, type SidebarItem, SYSTEM_ID, valueRows, } from "../lib/sidebarTree"; import { applyFilter, noteSidebarInput, noteSidebarMenu } from "../lib/navigation"; -import { hideSystemAtom, sidebarTreeAtom } from "../lib/store"; +import { hideEncryptedAtom, hideSystemAtom, sidebarTreeAtom } from "../lib/store"; import { getOsType } from "../lib/tauri"; import { useShowToast } from "../lib/toast"; import { AppIcon } from "./AppIcon"; @@ -73,7 +82,7 @@ function ItemInner({ item }: { item: SidebarItem }) { return (
{item.kind === "all" && } - {item.kind === "tunnelled" && } + {item.kind === "encrypted" && } {item.kind === "saved" && } {item.kind === "folder" && } {item.appPath != null && } @@ -88,7 +97,11 @@ function ItemInner({ item }: { item: SidebarItem }) { {item.sublabel != null && ( {item.sublabel} )} - {item.decrypt != null && } + {item.encryptedOnly && ( + + + + )}
); @@ -117,14 +130,12 @@ function CloseAtOnce({ onClose }: { onClose: () => void }) { return null; } -/** A small open lock on a host, site or app the decrypt list decrypts */ -function DecryptedMark({ item }: { item: SidebarItem }) { - const list = useDecryptList(); - if (item.decrypt == null || !isDecrypted(list, item.decrypt)) return null; - return ( - - - +/** A checkable menu item's mark: a check when on, the same room when off */ +function checkMark(on: boolean) { + return on ? ( + + ) : ( + ); } @@ -137,9 +148,9 @@ export function Sidebar() { [], ); const [hideSystem, setHideSystem] = useAtom(hideSystemAtom); + const [hideEncrypted, setHideEncrypted] = useAtom(hideEncryptedAtom); const decryptList = useDecryptList(); - const setDecryptList = useEditDecryptList(); - const { mutate: saveDecryptList } = setDecryptList; + const { apply: applyDecrypt } = useDecrypt(); const showToast = useShowToast(); const setSavedDialog = useSetAtom(savedSearchDialogAtom); const { mutate: deleteSaved } = useRpcMutation("delete_saved_query", { @@ -210,15 +221,35 @@ export function Sidebar() { return [ { label: "Show in All Traffic", - leftSlot: hideSystem ? ( - - ) : ( - - ), + leftSlot: checkMark(!hideSystem), onSelect: () => setHideSystem(!hideSystem), }, ]; } + if (items.length === 1 && items[0]?.id === ENCRYPTED_ID) { + return withSeparators([ + [ + { + label: "Decrypt Everything", + leftSlot: , + hidden: decryptsEverything(decryptList), + onSelect: () => + void applyDecrypt({ + edit: everythingEdit(true), + message: "Decrypting everything", + sub: "Except Apple services and apps that pin", + }), + }, + ], + [ + { + label: "Show in All Traffic", + leftSlot: checkMark(!hideEncrypted), + onSelect: () => setHideEncrypted(!hideEncrypted), + }, + ], + ]); + } const paths = items.length === 1 ? pathItems(items[0]) : []; const saved = items.flatMap((i) => (i.savedId == null ? [] : [{ ...i, id: i.savedId }])); if (saved.length > 0) { @@ -265,7 +296,10 @@ export function Sidebar() { // Only what this changes, applied to the list as it is then, so a second change // made before this one lands keeps it. const next = setDecrypted(decryptList, subjects, action.on); - saveDecryptList(listEdit(decryptList, next)); + void applyDecrypt({ + edit: listEdit(decryptList, next), + message: `${action.on ? "Decrypting" : "Stopped decrypting"} ${action.subject}`, + }); }, }, ]; @@ -273,7 +307,7 @@ export function Sidebar() { }, [ decryptList, - saveDecryptList, + applyDecrypt, showToast, setSavedDialog, deleteSaved, @@ -281,6 +315,8 @@ export function Sidebar() { filterItems, hideSystem, setHideSystem, + hideEncrypted, + setHideEncrypted, ], ); const renderContextMenu = useCallback["renderContextMenu"]>>( diff --git a/apps/yaak-proxy/harness/backend.ts b/apps/yaak-proxy/harness/backend.ts index 1ec7bc83d..e70f4392c 100644 --- a/apps/yaak-proxy/harness/backend.ts +++ b/apps/yaak-proxy/harness/backend.ts @@ -1,4 +1,5 @@ import type { + DecryptListEntry, Flow, FlowSortKey, GetProxyStateResponse, @@ -6,16 +7,59 @@ import type { } from "@yaakapp-internal/proxy-lib"; import { sortFlows } from "../lib/columns"; import { appIconPng, fixtures } from "./fixtures"; +import { BUILT_IN_EXCLUSIONS, httpsFlows } from "./httpsFixtures"; + +const params = new URLSearchParams(location.search); + +/** + * The decrypt list and the certificate as the harness holds them, from `?list=` (`none`, + * `host`, `everything`, `apps`) and `?ca=untrusted`. `?trust=cancel` cancels the password + * prompt; `?still=n` is how many open connections each change leaves encrypted. + */ +const https = { + list: initialList(params.get("list")), + trusted: params.get("ca") !== "untrusted", + passthrough: params.get("scene") === "mixed" ? ["sync.acme.dev:443"] : ([] as string[]), +}; + +function initialList(which: string | null): DecryptListEntry[] { + const own = (...entries: string[]) => entries.map((entry) => ({ entry, builtIn: false })); + switch (which) { + case "none": + return BUILT_IN_EXCLUSIONS; + case "host": + return [...own("host:www.google.com"), ...BUILT_IN_EXCLUSIONS]; + case "everything": + return [...own("*"), ...BUILT_IN_EXCLUSIONS]; + case "apps": + return [...own("app:Dia", "app:Google Chrome", "app:Acme Sync"), ...BUILT_IN_EXCLUSIONS]; + case "excluded": + return [...own("*", "!host:*.slack.com"), ...BUILT_IN_EXCLUSIONS]; + default: + return [ + { entry: "host:api.stripe.com", builtIn: false }, + { entry: "app:Google Chrome", builtIn: false }, + { entry: "!host:*.apple.com", builtIn: true }, + ]; + } +} /** * Dev only: answers the engine's RPCs the harness pages need, from fixtures, so the card and * settings render outside the app as they do inside it. */ export function installFixtureBackend() { + // `?firstrun`: setup not done yet, so the first-run card shows + if (params.has("firstrun")) localStorage.setItem("system_proxy_ever_on", "false"); + else if (params.has("window")) localStorage.setItem("system_proxy_ever_on", "true"); const internals = (window as unknown as { __TAURI_INTERNALS__: Record }) .__TAURI_INTERNALS__; // For checking the window as traffic arrives: `__harnessEmit("model_write", payload)` (window as unknown as { __harnessEmit: typeof emit }).__harnessEmit = emit; + // Unlistening, which the app does as its components unmount + ( + window as unknown as { __TAURI_EVENT_PLUGIN_INTERNALS__: unknown } + ).__TAURI_EVENT_PLUGIN_INTERNALS__ = { unregisterListener: () => {} }; internals.transformCallback = (callback: (event: unknown) => void) => { const id = callbacks.size + 1; callbacks.set(id, callback); @@ -138,11 +182,16 @@ function matches(flow: Flow, query: string): boolean { if (field === "host") return host.toLowerCase().includes(value.replaceAll("*", "")); if (field === "method") return flow.method.toLowerCase() === value; if (field === "status") return String(flow.status ?? "").startsWith(value.replace(/x+$/, "")); + if (field === "is" && value === "encrypted") return flow.tunnelled; + if (field === "is" && value === "rejected") return flow.tlsRejected != null; return field == null ? text.includes(term) : true; }); } function allFlows(): Flow[] { + if (params.has("firstrun")) return []; + const scene = httpsFlows(params.get("scene")); + if (scene != null) return scene.map((f, i) => ({ ...f, number: i + 1 })); const many = Number(new URLSearchParams(location.search).get("many") ?? 0); return (many > 0 ? manyFlows(many) : fixtures.map((f) => f.flow)).map((f, i) => ({ ...f, @@ -150,7 +199,15 @@ function allFlows(): Flow[] { })); } -function answer(cmd: string, payload: unknown): unknown { +function caStatus() { + return { + trusted: https.trusted, + installed: https.trusted, + certPath: "/Users/you/Library/Application Support/app.yaak.proxy/ca.pem", + }; +} + +async function answer(cmd: string, payload: unknown): Promise { const req = (payload ?? {}) as Record; switch (cmd) { case "get_flows": { @@ -231,14 +288,45 @@ function answer(cmd: string, payload: unknown): unknown { case "get_storage_size": return { bytes: 412 * 1024 * 1024 }; case "ca_status": + return caStatus(); + case "ca_trust": { + // A moment for the prompt, so the controls that asked show it + await new Promise((r) => setTimeout(r, Number(params.get("prompt") ?? 900))); + if (params.get("trust") === "cancel") return { ...caStatus(), cancelled: true }; + https.trusted = true; + return { ...caStatus(), cancelled: false }; + } + case "edit_decrypt_list": { + const { add = [], remove = [] } = req as { add?: string[]; remove?: string[] }; + const own = https.list.filter((e) => !e.builtIn && !remove.includes(e.entry)); + for (const entry of add) { + if (!own.some((e) => e.entry === entry)) own.push({ entry, builtIn: false }); + } + const builtIns = BUILT_IN_EXCLUSIONS.filter( + (e) => https.list.some((l) => l.entry === e.entry) && !remove.includes(e.entry), + ); + https.list = [...own, ...builtIns]; + emit("decrypt_list_changed", { list: { entries: https.list } }); + const decrypts = add.some((e) => !e.startsWith("!")) || remove.some((e) => e.startsWith("!")); return { - trusted: true, - installed: true, - certPath: "/Users/you/Library/Application Support/app.yaak.proxy/ca.pem", + entries: https.list, + stillEncrypted: decrypts ? Number(params.get("still") ?? 3) : 0, }; + } + case "list_passthrough": + return { addresses: https.passthrough }; + case "forget_passthrough": { + const address = String(req.address); + const forgotten = https.passthrough.includes(address); + https.passthrough = https.passthrough.filter((a) => a !== address); + emit("passthrough_changed", { addresses: https.passthrough }); + return { forgotten, addresses: https.passthrough }; + } + case "reconnect_tunnels": + return { closed: 1 }; case "system_proxy_status": return { - enabled: true, + enabled: !params.has("firstrun"), port: 9090, services: [], backend: "helper", @@ -246,13 +334,7 @@ function answer(cmd: string, payload: unknown): unknown { supported: true, }; case "get_decrypt_list": - return { - entries: [ - { entry: "host:api.stripe.com", builtIn: false }, - { entry: "app:Google Chrome", builtIn: false }, - { entry: "!host:*.apple.com", builtIn: true }, - ], - }; + return { entries: https.list }; case "list_actions": // As the engine lists them on macOS return { @@ -270,7 +352,8 @@ function answer(cmd: string, payload: unknown): unknown { case "list_flows": { const all = allFlows(); const query = typeof req.query === "string" ? req.query : ""; - const matching = all.filter((f) => matches(f, query)); + const hideEncrypted = req.hideEncrypted === true && !query.includes("@is:encrypted"); + const matching = all.filter((f) => matches(f, query) && !(hideEncrypted && f.tunnelled)); // In a sort's order, as the database lists it, all in one page const sort = (req.sort ?? []) as FlowSortKey[]; const flows = sort.length > 0 ? sortFlows(matching, sort) : matching.reverse(); @@ -278,7 +361,7 @@ function answer(cmd: string, payload: unknown): unknown { return { flows: page, total: all.length, - matching: query === "" ? null : matching.length, + matching: query === "" && !hideEncrypted ? null : matching.length, cursor: null, }; } diff --git a/apps/yaak-proxy/harness/httpsFixtures.ts b/apps/yaak-proxy/harness/httpsFixtures.ts new file mode 100644 index 000000000..4fa7a659c --- /dev/null +++ b/apps/yaak-proxy/harness/httpsFixtures.ts @@ -0,0 +1,172 @@ +import type { DecryptListEntry, Flow } from "@yaakapp-internal/proxy-lib"; +import { fixtures } from "./fixtures"; + +/** + * Dev only: traffic for HTTPS decryption's states, by `?scene=`: `encrypted`, the first minute + * with nothing decrypted, and `mixed`, some decrypted, a refused handshake and the tunnel passed + * through after it. + */ +export type HttpsScene = "encrypted" | "mixed"; + +export const BUILT_IN_EXCLUSIONS: DecryptListEntry[] = [ + { entry: "!host:*.apple.com", builtIn: true, kind: "apple" }, + { entry: "!host:*.icloud.com", builtIn: true, kind: "apple" }, + { entry: "!host:*.mzstatic.com", builtIn: true, kind: "apple" }, + { entry: "!host:*.1password.com", builtIn: true, kind: "pinned" }, + { entry: "!host:*.dropbox.com", builtIn: true, kind: "pinned" }, +]; + +const template = fixtures[0]?.flow as Flow; +const start = Date.parse("2026-11-09T09:00:00Z"); + +const apps = { + dia: { + clientName: "Dia", + clientBundleId: "company.thebrowser.dia", + clientPath: "/Applications/Dia.app", + }, + chrome: { + clientName: "Google Chrome", + clientBundleId: "com.google.Chrome", + clientPath: "/Applications/Google Chrome.app", + }, + slack: { + clientName: "Slack", + clientBundleId: "com.tinyspeck.slackmacgap", + clientPath: "/Applications/Slack.app", + }, + onePassword: { + clientName: "1Password", + clientBundleId: "com.1password.1password", + clientPath: "/Applications/1Password.app", + }, + acme: { + clientName: "Acme Sync", + clientBundleId: "dev.acme.sync", + clientPath: "/Applications/Acme Sync.app", + }, + cloudd: { + clientName: "cloudd", + clientBundleId: null, + clientPath: "/System/Library/PrivateFrameworks/CloudKitDaemon.framework/Support/cloudd", + }, +} as const; + +type App = (typeof apps)[keyof typeof apps]; + +function tunnel(i: number, host: string, app: App, over: Partial = {}): Flow { + return { + ...template, + ...app, + id: `fl_https_${i}`, + method: "CONNECT", + url: `${host}:443`, + startedAt: new Date(start + i * 1100).toISOString().replace("Z", ""), + status: null, + contentType: null, + requestHeaders: [], + responseHeaders: [], + elapsedHeadersMs: null, + elapsedConnectMs: null, + elapsedTlsMs: null, + elapsedRequestMs: null, + elapsedMs: 120 + ((i * 97) % 900), + clientHttpVersion: "HTTP/1.1", + upstreamHttpVersion: null, + upstreamTlsVersion: null, + upstreamTlsCipher: null, + requestSize: 600 + ((i * 331) % 3000), + responseSize: 1_200 + ((i * 7919) % 120_000), + tunnelled: true, + tunnelReason: "not_listed", + ...over, + // An open tunnel has been open a little while, counting up from now + ...(over.elapsedMs === null + ? { startedAt: new Date(Date.now() - 22_000 - i * 1000).toISOString().replace("Z", "") } + : {}), + }; +} + +function decrypted(i: number, source: number, url: string, app: App): Flow { + const flow = fixtures[source]?.flow ?? template; + return { + ...flow, + ...app, + // The fixture's id first, so the backend finds its bodies + id: `${flow.id}~https_${i}`, + url, + startedAt: new Date(start + i * 1100).toISOString().replace("Z", ""), + }; +} + +const builtInExcluded = { + tunnelReason: "excluded" as const, + tunnelExcludedBy: "!host:*.1password.com", + tunnelExcludedBuiltIn: true, +}; + +/** The first minute: every HTTPS connection encrypted, two still open */ +function encryptedScene(): Flow[] { + return [ + tunnel(1, "clientstream.launchdarkly.com", apps.dia, { elapsedMs: null }), + tunnel(2, "browser-intake-datadoghq.com", apps.dia), + tunnel(3, "api.typefully.com", apps.dia), + tunnel(4, "js.stripe.com", apps.dia), + tunnel(5, "edgeapi.slack.com", apps.slack), + tunnel(6, "wss-primary.slack.com", apps.slack, { elapsedMs: null }), + tunnel(7, "gateway.icloud.com", apps.cloudd, { + tunnelReason: "excluded", + tunnelExcludedBy: "!host:*.icloud.com", + tunnelExcludedBuiltIn: true, + }), + tunnel(8, "my.1password.com", apps.onePassword, builtInExcluded), + tunnel(9, "www.google.com", apps.dia), + tunnel(10, "fonts.gstatic.com", apps.dia), + tunnel(11, "clients2.google.com", apps.chrome), + tunnel(12, "home.taila714c.ts.net:8317", apps.dia, { url: "home.taila714c.ts.net:8317" }), + ]; +} + +/** + * Some decrypted, a refused handshake from an app that pins, and the tunnel passed through + * after it + */ +function mixedScene(): Flow[] { + return [ + tunnel(1, "my.1password.com", apps.onePassword, builtInExcluded), + decrypted(2, 0, "https://api.stripe.com/v1/payment_intents", apps.dia), + decrypted(3, 5, "https://api.github.com/graphql", apps.chrome), + tunnel(4, "edgeapi.slack.com", apps.slack), + decrypted(5, 2, "https://cdn.example.com/avatars/42.png?size=2", apps.dia), + tunnel(6, "gateway.icloud.com", apps.cloudd, { + tunnelReason: "excluded", + tunnelExcludedBy: "!host:*.icloud.com", + tunnelExcludedBuiltIn: true, + }), + { + ...tunnel(7, "sync.acme.dev", apps.acme), + method: "", + url: "https://sync.acme.dev", + tunnelled: false, + tunnelReason: null, + tlsRejected: "bad_certificate", + error: "Rejected the proxy's certificate", + elapsedMs: 6, + requestSize: 517, + responseSize: 0, + }, + tunnel(8, "sync.acme.dev", apps.acme, { + tunnelReason: "rejected", + tunnelRejectedBy: "fl_https_7", + elapsedMs: null, + }), + decrypted(9, 0, "https://api.typefully.com/v1/drafts", apps.dia), + tunnel(10, "www.google.com", apps.dia, { elapsedMs: null }), + ]; +} + +export function httpsFlows(scene: string | null): Flow[] | null { + if (scene === "encrypted") return encryptedScene(); + if (scene === "mixed") return mixedScene(); + return null; +} diff --git a/apps/yaak-proxy/hooks/useDecrypt.tsx b/apps/yaak-proxy/hooks/useDecrypt.tsx new file mode 100644 index 000000000..39d5da10e --- /dev/null +++ b/apps/yaak-proxy/hooks/useDecrypt.tsx @@ -0,0 +1,172 @@ +import { useIsMutating, useQueryClient } from "@tanstack/react-query"; +import type { CaStatus, PassthroughResponse } from "@yaakapp-internal/proxy-lib"; +import { Button } from "@yaakapp-internal/ui"; +import { useCallback } from "react"; +import { inverseEdit } from "../lib/decrypt"; +import { applyChoice, choiceToast, type DecryptChoice, type ListEdit } from "../lib/decryptChoice"; +import { rpc } from "../lib/rpc"; +import { getOsType } from "../lib/tauri"; +import { useShowToast } from "../lib/toast"; +import { announceEngineChange } from "../lib/windowSync"; +import { useRpcEvent } from "./useRpcEvent"; +import { useRpcMutation } from "./useRpcMutation"; +import { useRpcQuery } from "./useRpcQuery"; + +const CA_KEY = ["ca_status", {}]; +const LIST_KEY = ["get_decrypt_list", {}]; +const PASSTHROUGH_KEY = ["list_passthrough", {}]; +const TRUST_KEY = ["ca_trust"]; + +/** The app trusts its certificate itself only on macOS; elsewhere it is installed by hand */ +export function canTrustHere(): boolean { + return getOsType() === "macos"; +} + +/** Whether macOS's password prompt is up, from any control that asked for it */ +export function useTrusting(): boolean { + return useIsMutating({ mutationKey: TRUST_KEY }) > 0; +} + +/** Asks macOS to trust the certificate, the status read from its answer */ +export function useTrustCertificate() { + const queryClient = useQueryClient(); + return useRpcMutation("ca_trust", { + mutationKey: TRUST_KEY, + onSuccess: ({ trusted, installed, certPath }) => + queryClient.setQueryData(CA_KEY, { trusted, installed, certPath }), + }); +} + +/** + * Every Decrypt and Stop Decrypting in the app: trusts the certificate first when the change can + * decrypt something and it isn't yet (macOS's password prompt), then writes the change, then a + * toast with Undo, and with Reconnect while open connections the list now decrypts stay + * encrypted. A cancelled prompt changes nothing and says nothing. + */ +export function useDecrypt() { + const queryClient = useQueryClient(); + const showToast = useShowToast(); + const trusting = useTrusting(); + const { mutateAsync: trust } = useTrustCertificate(); + + const run = useCallback( + async (edit: ListEdit) => { + try { + return await applyChoice( + { + canTrust: canTrustHere(), + readTrusted: async () => { + const status = await rpc("ca_status", {}); + queryClient.setQueryData(CA_KEY, status); + return status.trusted; + }, + trust: () => trust({}), + save: async (edit) => { + try { + return await rpc("edit_decrypt_list", edit); + } finally { + // Read again rather than taken from the answer, which can land after a newer + // change's event and would put the older list back. + void queryClient.invalidateQueries({ queryKey: LIST_KEY }); + announceEngineChange(); + } + }, + }, + edit, + ); + } catch (err) { + showToast({ + id: "decrypt", + color: "danger", + message: err instanceof Error ? err.message : String(err), + }); + return null; + } + }, + [queryClient, showToast, trust], + ); + + const reconnect = useCallback( + (entries: string[]) => { + for (const entry of entries) { + rpc("reconnect_tunnels", { entry }).catch((err) => + showToast({ id: "reconnect", color: "danger", message: String(err) }), + ); + } + }, + [showToast], + ); + + /** Applies a choice; true once it is written */ + const apply = useCallback( + async (choice: DecryptChoice): Promise => { + const result = await run(choice.edit); + if (result == null || result.status === "cancelled") return false; + if (result.status === "untrusted") { + showToast({ + id: "decrypt", + color: "warning", + message: "Certificate not trusted", + }); + return false; + } + const toast = choiceToast(choice, result.stillEncrypted); + showToast({ + id: "decrypt", + message: ( +
+
{toast.message}
+ {toast.sub != null && ( +
{toast.sub}
+ )} +
+ ), + action: ({ hide }) => ( +
+ + {toast.reconnect.length > 0 && ( + + )} +
+ ), + }); + return true; + }, + [run, reconnect, showToast], + ); + + return { apply, trusting }; +} + +/** + * The addresses passed through after clients rejected the certificate, kept current as the + * engine adds and forgets them. Null until read. + */ +export function usePassthrough(): string[] | null { + const queryClient = useQueryClient(); + const onChanged = useCallback( + (payload: PassthroughResponse) => + queryClient.setQueryData(PASSTHROUGH_KEY, payload), + [queryClient], + ); + useRpcEvent("passthrough_changed", onChanged); + return useRpcQuery("list_passthrough", {}).data?.addresses ?? null; +} diff --git a/apps/yaak-proxy/hooks/useDecryptList.ts b/apps/yaak-proxy/hooks/useDecryptList.ts index c8d67bfbe..04d6f3058 100644 --- a/apps/yaak-proxy/hooks/useDecryptList.ts +++ b/apps/yaak-proxy/hooks/useDecryptList.ts @@ -5,9 +5,7 @@ import type { DecryptListEntry, } from "@yaakapp-internal/proxy-lib"; import { useCallback } from "react"; -import { useShowToast } from "../lib/toast"; import { useRpcEvent } from "./useRpcEvent"; -import { useRpcMutation } from "./useRpcMutation"; import { useRpcQuery } from "./useRpcQuery"; const KEY = ["get_decrypt_list", {}]; @@ -27,19 +25,3 @@ export function useDecryptList(): DecryptListEntry[] { useRpcEvent("decrypt_list_changed", onChanged); return useRpcQuery("get_decrypt_list", {}).data?.entries ?? EMPTY; } - -/** - * Adds and removes entries, `edit_decrypt_list` applying them to the list as it is when they - * land, so two changes made in quick succession both stick. Everything that reads the list - * then reads it again. A failure, such as an entry that does not parse, is a toast. - */ -export function useEditDecryptList() { - const queryClient = useQueryClient(); - const showToast = useShowToast(); - return useRpcMutation("edit_decrypt_list", { - // Read again rather than taken from the answer, which can land after a newer change's - // event and would put the older list back. - onSuccess: () => queryClient.invalidateQueries({ queryKey: KEY }), - onError: (e) => showToast({ id: "decrypt-list", color: "danger", message: e.message }), - }); -} diff --git a/apps/yaak-proxy/lib/body.test.ts b/apps/yaak-proxy/lib/body.test.ts index b253dd4f5..548775161 100644 --- a/apps/yaak-proxy/lib/body.test.ts +++ b/apps/yaak-proxy/lib/body.test.ts @@ -2,6 +2,7 @@ import { QueryClient, QueryObserver } from "@tanstack/react-query"; import type { Flow } from "@yaakapp-internal/proxy-lib"; import { describe, expect, test, vi } from "vite-plus/test"; import { + bodyTypeBadge, bodyAsText, bodyFileName, bodyImageType, @@ -243,3 +244,27 @@ describe("a body that isn't there", () => { expect(emptyBodyText("request", body.note)).toBe("No request body"); }); }); + +describe("bodyTypeBadge", () => { + test("names a body by its type, as short as the Type column", () => { + expect(bodyTypeBadge("application/json; charset=utf-8", 10)).toBe("JSON"); + expect(bodyTypeBadge("application/problem+json", 10)).toBe("JSON"); + expect(bodyTypeBadge("text/xml", 10)).toBe("XML"); + expect(bodyTypeBadge("text/html", 10)).toBe("HTML"); + expect(bodyTypeBadge("text/csv", 10)).toBe("CSV"); + expect(bodyTypeBadge("application/x-www-form-urlencoded", 10)).toBe("Form"); + expect(bodyTypeBadge("multipart/form-data; boundary=x", 10)).toBe("Multipart"); + expect(bodyTypeBadge("image/png", 10)).toBe("Image"); + expect(bodyTypeBadge("image/svg+xml", 10)).toBe("SVG"); + expect(bodyTypeBadge("text/plain", 10)).toBe("Text"); + expect(bodyTypeBadge("application/grpc", 10)).toBe("Protobuf"); + expect(bodyTypeBadge("application/octet-stream", 10)).toBe("Binary"); + expect(bodyTypeBadge(null, 10)).toBe("Binary"); + }); + + test("GraphQL when the request is an operation, and nothing without a body", () => { + expect(bodyTypeBadge("application/json", 10, true)).toBe("GraphQL"); + expect(bodyTypeBadge("application/json", 0)).toBeNull(); + expect(bodyTypeBadge(null, 0, true)).toBeNull(); + }); +}); diff --git a/apps/yaak-proxy/lib/body.ts b/apps/yaak-proxy/lib/body.ts index 3fdb468bf..8bc3b50d7 100644 --- a/apps/yaak-proxy/lib/body.ts +++ b/apps/yaak-proxy/lib/body.ts @@ -208,6 +208,45 @@ export function shortContentType(contentType: string | null): string | null { return suffix.replace(/^x-/, ""); } +/** + * The short name a body tab's badge gives a body's type, as the Type column and the Pretty and + * Raw switch read it: JSON, XML, HTML, CSV, Form, Multipart, GraphQL, Image, Binary and so on. + * Null when there is no body. + */ +export function bodyTypeBadge( + contentType: string | null, + size: number, + graphql = false, +): string | null { + if (size <= 0) return null; + if (graphql) return "GraphQL"; + const essence = mimeEssence(contentType); + if (essence == null) return "Binary"; + const [type = "", subtype = ""] = essence.split("/"); + const suffix = subtype.split("+").pop() ?? subtype; + if (type === "multipart") return "Multipart"; + if (essence === "application/x-www-form-urlencoded") return "Form"; + if (essence === "application/graphql" || suffix === "graphql") return "GraphQL"; + if (essence === "image/svg+xml") return "SVG"; + if (type === "image") return "Image"; + if (type === "audio") return "Audio"; + if (type === "video") return "Video"; + if (type === "font") return "Font"; + if (suffix === "json" || suffix === "x-ndjson" || suffix === "ndjson") return "JSON"; + if (suffix === "xml") return "XML"; + if (suffix === "html" || suffix === "xhtml") return "HTML"; + if (suffix === "csv") return "CSV"; + if (suffix === "tab-separated-values") return "TSV"; + if (suffix === "javascript" || suffix === "ecmascript") return "JS"; + if (suffix === "css") return "CSS"; + if (suffix === "markdown") return "Markdown"; + if (suffix === "event-stream") return "SSE"; + if (suffix === "pdf") return "PDF"; + if (/grpc|protobuf|proto/.test(suffix)) return "Protobuf"; + if (type === "text") return "Text"; + return "Binary"; +} + /** Extensions for media types whose subtype isn't one */ const EXTENSIONS: Record = { "application/javascript": "js", diff --git a/apps/yaak-proxy/lib/decrypt.test.ts b/apps/yaak-proxy/lib/decrypt.test.ts index b14d48ac5..02a453adb 100644 --- a/apps/yaak-proxy/lib/decrypt.test.ts +++ b/apps/yaak-proxy/lib/decrypt.test.ts @@ -11,8 +11,12 @@ import { decryptMenuAction, decrypts, decryptsEverything, + editDecrypts, entryLabel, + entrySubject, everythingEdit, + inverseEdit, + listSummary, isDecrypted, listEdit, normalizeEntry, @@ -170,15 +174,31 @@ describe("the sidebar menu", () => { const a = { kind: "host", host: "a.com" } as const; const b = { kind: "host", host: "b.com" } as const; - test("offers Decrypt unless every selected row is decrypted", () => { + test("offers Decrypt unless every selected row is decrypted, naming what it changes", () => { expect(decryptMenuAction(user("host:a.com"), [a])).toEqual({ - label: "Stop Decrypting", + label: "Stop Decrypting a.com", on: false, + subject: "a.com", + }); + expect(decryptMenuAction(user("host:a.com"), [a, b])).toEqual({ + label: "Decrypt 2 Hosts", + on: true, + subject: "2 hosts", }); - expect(decryptMenuAction(user("host:a.com"), [a, b])).toEqual({ label: "Decrypt", on: true }); expect(decryptMenuAction([], [])).toBeNull(); }); + test("names sites, apps and mixed rows", () => { + const site = { kind: "site", site: "google.com" } as const; + const dia = { kind: "app", name: "Dia", bundleId: null } as const; + expect(decryptMenuAction([], [site])?.label).toBe("Decrypt *.google.com"); + expect(decryptMenuAction([], [dia])?.label).toBe("Decrypt Dia"); + expect(decryptMenuAction([], [site, { kind: "site", site: "x.com" }])?.label).toBe( + "Decrypt 2 Sites", + ); + expect(decryptMenuAction([], [dia, a, site])?.label).toBe("Decrypt 3 Selected"); + }); + test("applies to every selected row, leaving those already so", () => { const next = setDecrypted(user("host:a.com"), [a, b], true).map((e) => e.entry); expect(next).toEqual(["host:a.com", "host:b.com"]); @@ -225,8 +245,8 @@ describe("the Not decrypted detail's buttons", () => { }); }); -describe("the first-run step", () => { - test("Everything adds *, and Pick as they appear takes it out", () => { +describe("Decrypt Everything", () => { + test("adds *, and the switch off takes it out", () => { expect(decryptsEverything(BUILT_INS)).toBe(false); expect(decryptsEverything([...user("*"), ...BUILT_INS])).toBe(true); expect(everythingEdit(true)).toEqual({ add: ["*"], remove: [] }); @@ -303,3 +323,26 @@ describe("the list's autocomplete", () => { }); }); }); + +describe("what a change says", () => { + test("the pill counts the person's own positive entries", () => { + expect(listSummary([...user("app:Dia", "app:Slack", "host:*.google.com"), ...BUILT_INS])).toBe( + "2 apps, 1 host", + ); + expect(listSummary([...user("*", "!host:a.com"), ...BUILT_INS])).toBeNull(); + }); + + test("names an entry's subject", () => { + expect(entrySubject("host:www.google.com")).toBe("www.google.com"); + expect(entrySubject("host:*.google.com")).toBe("*.google.com"); + expect(entrySubject("app:Dia")).toBe("Dia"); + expect(entrySubject("*")).toBe("Everything"); + }); + + test("an edit can decrypt when it adds a positive entry or drops an exclusion", () => { + expect(editDecrypts({ add: ["host:a.com"], remove: [] })).toBe(true); + expect(editDecrypts({ add: [], remove: ["!host:a.com"] })).toBe(true); + expect(editDecrypts({ add: ["!app:Dia"], remove: ["host:a.com"] })).toBe(false); + expect(inverseEdit({ add: ["a"], remove: ["b"] })).toEqual({ add: ["b"], remove: ["a"] }); + }); +}); diff --git a/apps/yaak-proxy/lib/decrypt.ts b/apps/yaak-proxy/lib/decrypt.ts index d8a554222..6d0afc237 100644 --- a/apps/yaak-proxy/lib/decrypt.ts +++ b/apps/yaak-proxy/lib/decrypt.ts @@ -163,6 +163,12 @@ function matches(e: ParsedEntry, q: DecryptQuery): boolean { } } +/** Whether one entry, as stored, names `q`, whichever way it points */ +export function entryMatches(entry: string, q: DecryptQuery): boolean { + const parsed = parseEntry(entry); + return parsed != null && matches(parsed, q); +} + function specificity(e: ParsedEntry): number { if (e.kind === "everything") return 0; if (e.kind === "app") return Number.POSITIVE_INFINITY; @@ -193,6 +199,52 @@ export function decryptsAnything(list: DecryptListEntry[]): boolean { return list.some((e) => !e.entry.trim().startsWith("!")); } +/** What a list entry is called in a button: its host or pattern, its app, or Everything */ +export function entrySubject(entry: string): string { + const parsed = parseEntry(entry); + if (parsed == null) return entry; + if (parsed.kind === "everything") return "Everything"; + if (parsed.kind === "app") return parsed.pattern; + return formatEntry({ ...parsed, negated: false }).slice("host:".length); +} + +/** + * The person's own positive entries, counted for the pill: `2 apps, 1 host`, sites counted as + * hosts, `*` left out as the switch beside it shows it. Null when there are none. + */ +export function listSummary(list: DecryptListEntry[]): string | null { + let apps = 0; + let hosts = 0; + for (const e of list) { + if (e.builtIn) continue; + const parsed = parseEntry(e.entry); + if (parsed == null || parsed.negated) continue; + if (parsed.kind === "app") apps++; + if (parsed.kind === "host") hosts++; + } + const parts = [ + apps > 0 ? `${apps} ${apps === 1 ? "app" : "apps"}` : null, + hosts > 0 ? `${hosts} ${hosts === 1 ? "host" : "hosts"}` : null, + ].filter((p) => p != null); + return parts.length === 0 ? null : parts.join(", "); +} + +/** Whether an edit can make something decrypt: it adds a positive entry or drops an exclusion */ +export function editDecrypts(edit: { add: string[]; remove: string[] }): boolean { + return ( + edit.add.some((e) => !e.trim().startsWith("!")) || + edit.remove.some((e) => e.trim().startsWith("!")) + ); +} + +/** The edit that puts back what `edit` changed, for Undo */ +export function inverseEdit(edit: { add: string[]; remove: string[] }): { + add: string[]; + remove: string[]; +} { + return { add: edit.remove, remove: edit.add }; +} + /** What a sidebar row or the detail's buttons decrypt: a host, a site, or an app */ export type DecryptSubject = | { kind: "host"; host: string } @@ -335,10 +387,44 @@ export function setDecrypted( export function decryptMenuAction( list: DecryptListEntry[], subjects: DecryptSubject[], -): { label: "Decrypt" | "Stop Decrypting"; on: boolean } | null { +): { label: string; on: boolean; subject: string } | null { if (subjects.length === 0) return null; const on = !subjects.every((s) => isDecrypted(list, s)); - return { label: on ? "Decrypt" : "Stop Decrypting", on }; + const subject = subjectsName(subjects); + return { + label: `${on ? "Decrypt" : "Stop Decrypting"} ${subject.title}`, + on, + subject: subject.text, + }; +} + +/** What a subject is called: its host, `*.site`, or its app */ +export function subjectName(subject: DecryptSubject): string { + switch (subject.kind) { + case "host": + return subject.host; + case "site": + return `*.${subject.site}`; + case "app": + return subject.name; + } +} + +/** + * Several subjects named together: one by its name, several of a kind counted, `3 Sites` in a + * menu and `3 sites` in a toast, and mixed kinds as `3 Selected` + */ +function subjectsName(subjects: DecryptSubject[]): { title: string; text: string } { + const [first] = subjects; + if (subjects.length === 1 && first != null) { + const name = subjectName(first); + return { title: name, text: name }; + } + const kinds = new Set(subjects.map((s) => s.kind)); + const n = subjects.length; + if (kinds.size > 1) return { title: `${n} Selected`, text: `${n} selected` }; + const noun = first?.kind === "app" ? "Apps" : first?.kind === "site" ? "Sites" : "Hosts"; + return { title: `${n} ${noun}`, text: `${n} ${noun.toLowerCase()}` }; } function builtIns(list: DecryptListEntry[]): DecryptListEntry[] { diff --git a/apps/yaak-proxy/lib/decryptChoice.test.ts b/apps/yaak-proxy/lib/decryptChoice.test.ts new file mode 100644 index 000000000..217c40958 --- /dev/null +++ b/apps/yaak-proxy/lib/decryptChoice.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, test, vi } from "vite-plus/test"; +import { applyChoice, type ChoiceDeps, choiceToast } from "./decryptChoice"; + +function deps(over: Partial = {}) { + const calls: string[] = []; + const d: ChoiceDeps = { + canTrust: true, + readTrusted: vi.fn(async () => { + calls.push("ca_status"); + return false; + }), + trust: vi.fn(async () => { + calls.push("ca_trust"); + return { cancelled: false, trusted: true }; + }), + save: vi.fn(async () => { + calls.push("edit_decrypt_list"); + return { stillEncrypted: 2 }; + }), + ...over, + }; + return { d, calls }; +} + +const decrypt = { add: ["host:www.google.com"], remove: [] }; + +describe("trust at the first Decrypt", () => { + test("trusts first while untrusted, then writes the change", async () => { + const { d, calls } = deps(); + expect(await applyChoice(d, decrypt)).toEqual({ status: "applied", stillEncrypted: 2 }); + expect(calls).toEqual(["ca_status", "ca_trust", "edit_decrypt_list"]); + }); + + test("a cancelled prompt writes nothing", async () => { + const { d, calls } = deps({ + trust: async () => { + calls.push("ca_trust"); + return { cancelled: true, trusted: false }; + }, + }); + expect(await applyChoice(d, decrypt)).toEqual({ status: "cancelled" }); + expect(calls).toEqual(["ca_status", "ca_trust"]); + }); + + test("a prompt answered without trust writes nothing either", async () => { + const { d } = deps({ trust: async () => ({ cancelled: false, trusted: false }) }); + expect(await applyChoice(d, decrypt)).toEqual({ status: "untrusted" }); + expect(d.save).not.toHaveBeenCalled(); + }); + + test("a failed prompt fails the choice, nothing written", async () => { + const { d } = deps({ + trust: async () => { + throw new Error("Not supported"); + }, + }); + await expect(applyChoice(d, decrypt)).rejects.toThrow("Not supported"); + expect(d.save).not.toHaveBeenCalled(); + }); + + test("asks nothing once trusted", async () => { + const { d, calls } = deps({ + readTrusted: async () => { + calls.push("ca_status"); + return true; + }, + }); + await applyChoice(d, decrypt); + expect(calls).toEqual(["ca_status", "edit_decrypt_list"]); + }); + + test("stopping never asks, and dropping an exclusion does", async () => { + const stop = deps(); + await applyChoice(stop.d, { add: ["!app:Dia"], remove: ["host:www.google.com"] }); + expect(stop.calls).toEqual(["edit_decrypt_list"]); + + const unexclude = deps(); + await applyChoice(unexclude.d, { add: [], remove: ["!host:*.chase.com"] }); + expect(unexclude.calls).toEqual(["ca_status", "ca_trust", "edit_decrypt_list"]); + }); + + test("where the app can't trust the certificate itself, the entry is written at once", async () => { + const { d, calls } = deps({ canTrust: false }); + await applyChoice(d, decrypt); + expect(calls).toEqual(["edit_decrypt_list"]); + }); +}); + +describe("the toast after a choice", () => { + const choice = { edit: decrypt, message: "Decrypting www.google.com" }; + + test("says what was chosen, and nothing to reconnect when nothing open stays encrypted", () => { + expect(choiceToast(choice, 0)).toEqual({ + message: "Decrypting www.google.com", + sub: null, + reconnect: [], + }); + expect( + choiceToast( + { edit: { add: ["*"], remove: [] }, message: "Decrypting everything", sub: "Except" }, + 0, + ).sub, + ).toBe("Except"); + }); + + test("offers Reconnect for the entry when open connections stay encrypted", () => { + expect(choiceToast(choice, 3)).toEqual({ + message: "Decrypting www.google.com", + sub: "3 open connections stay encrypted", + reconnect: ["host:www.google.com"], + }); + expect(choiceToast(choice, 1).sub).toBe("1 open connection stays encrypted"); + }); + + test("Reconnect for an exclusion taken off names the exclusion, as the engine reads it", () => { + const stopExcluding = { edit: { add: [], remove: ["!host:*.chase.com"] }, message: "x" }; + expect(choiceToast(stopExcluding, 1).reconnect).toEqual(["!host:*.chase.com"]); + }); +}); diff --git a/apps/yaak-proxy/lib/decryptChoice.ts b/apps/yaak-proxy/lib/decryptChoice.ts new file mode 100644 index 000000000..c913f4e08 --- /dev/null +++ b/apps/yaak-proxy/lib/decryptChoice.ts @@ -0,0 +1,79 @@ +import { editDecrypts } from "./decrypt"; + +/** A change to the decrypt list, as `edit_decrypt_list` takes it */ +export interface ListEdit { + add: string[]; + remove: string[]; +} + +/** A Decrypt or Stop Decrypting chosen somewhere in the app, with what its toast says */ +export interface DecryptChoice { + edit: ListEdit; + /** "Decrypting www.google.com" */ + message: string; + /** Under it, when nothing more pressing is */ + sub?: string; +} + +/** What applying a choice needs from the app: the certificate's state and the engine */ +export interface ChoiceDeps { + /** The app can trust the certificate itself, as on macOS; elsewhere the entry is written at once */ + canTrust: boolean; + /** Reads whether the certificate is trusted now */ + readTrusted: () => Promise; + /** macOS's password prompt */ + trust: () => Promise<{ cancelled: boolean; trusted: boolean }>; + save: (edit: ListEdit) => Promise<{ stillEncrypted: number }>; +} + +export type ChoiceResult = + | { status: "applied"; stillEncrypted: number } + /** The password prompt was cancelled: nothing changed, and nothing to say */ + | { status: "cancelled" } + /** The prompt was answered but the certificate is still not trusted */ + | { status: "untrusted" }; + +/** + * Trust at the first Decrypt: a change that can make something decrypt first trusts the + * certificate while it isn't, then writes the change only once that succeeded. A change that + * only stops decrypting never asks. + */ +export async function applyChoice(deps: ChoiceDeps, edit: ListEdit): Promise { + if (deps.canTrust && editDecrypts(edit) && !(await deps.readTrusted())) { + const result = await deps.trust(); + if (result.cancelled) return { status: "cancelled" }; + if (!result.trusted) return { status: "untrusted" }; + } + const saved = await deps.save(edit); + return { status: "applied", stillEncrypted: saved.stillEncrypted }; +} + +/** The toast after a choice was applied */ +export interface ChoiceToast { + message: string; + sub: string | null; + /** + * The entries Reconnect closes the open connections of, one `reconnect_tunnels` each: those + * the change added, and exclusions it took off. Empty when nothing open stays encrypted. + */ + reconnect: string[]; +} + +/** + * Says what was chosen, with Undo; when open connections the list now decrypts stay encrypted, + * how many, with Reconnect for them. Nothing reconnects without that click. + */ +export function choiceToast(choice: DecryptChoice, stillEncrypted: number): ChoiceToast { + if (stillEncrypted <= 0) + return { message: choice.message, sub: choice.sub ?? null, reconnect: [] }; + const n = stillEncrypted.toLocaleString(); + const reconnect = [ + ...choice.edit.add.filter((e) => !e.trim().startsWith("!")), + ...choice.edit.remove.filter((e) => e.trim().startsWith("!")), + ]; + return { + message: choice.message, + sub: `${n} open ${stillEncrypted === 1 ? "connection stays" : "connections stay"} encrypted`, + reconnect, + }; +} diff --git a/apps/yaak-proxy/lib/defaultTab.test.ts b/apps/yaak-proxy/lib/defaultTab.test.ts index b0773c294..e70bd4526 100644 --- a/apps/yaak-proxy/lib/defaultTab.test.ts +++ b/apps/yaak-proxy/lib/defaultTab.test.ts @@ -5,11 +5,11 @@ import { learnTab, REQUEST_TAB_BODY, REQUEST_TAB_HEADERS, + REQUEST_TAB_INFO, RESPONSE_TAB_BODY, + RESPONSE_TAB_COOKIES, RESPONSE_TAB_HEADERS, - RESPONSE_TAB_INFO, RESPONSE_TAB_MESSAGES, - RESPONSE_TAB_TIMELINE, type TabMemory, } from "./defaultTab"; @@ -33,13 +33,7 @@ function memory(): TabMemory & { items: Map } { }; } -const RESPONSE_TABS = [ - RESPONSE_TAB_BODY, - RESPONSE_TAB_HEADERS, - "cookies", - RESPONSE_TAB_TIMELINE, - RESPONSE_TAB_INFO, -]; +const RESPONSE_TABS = [RESPONSE_TAB_BODY, RESPONSE_TAB_HEADERS, RESPONSE_TAB_COOKIES]; describe("flowShape", () => { test("names what the flow has", () => { @@ -59,8 +53,8 @@ describe("flowShape", () => { describe("defaultTabFor", () => { test("follows the table", () => { const m = memory(); - // A response body - expect(defaultTabFor("request", ok, [], m)).toBe(REQUEST_TAB_HEADERS); + // A response body, the request's Info, as there is no request body + expect(defaultTabFor("request", ok, [], m)).toBe(REQUEST_TAB_INFO); expect(defaultTabFor("request", { ...ok, requestSize: 9 }, [], m)).toBe(REQUEST_TAB_BODY); expect(defaultTabFor("response", ok, RESPONSE_TABS, m)).toBe(RESPONSE_TAB_BODY); // A request body, no response body @@ -69,27 +63,28 @@ describe("defaultTabFor", () => { expect(defaultTabFor("response", post, RESPONSE_TABS, m)).toBe(RESPONSE_TAB_HEADERS); // Neither const empty = { ...ok, responseSize: 0 }; - expect(defaultTabFor("request", empty, [], m)).toBe(REQUEST_TAB_HEADERS); + expect(defaultTabFor("request", empty, [], m)).toBe(REQUEST_TAB_INFO); expect(defaultTabFor("response", empty, RESPONSE_TABS, m)).toBe(RESPONSE_TAB_HEADERS); // A stream const ws = { ...ok, streamKind: "websocket" as const }; expect(defaultTabFor("response", ws, [...RESPONSE_TABS, RESPONSE_TAB_MESSAGES], m)).toBe( RESPONSE_TAB_MESSAGES, ); - // No response at all + // No response at all: its timeline is in the request's Info const failed = { ...ok, status: null, error: "refused", responseSize: 0 }; - expect(defaultTabFor("response", failed, RESPONSE_TABS, m)).toBe(RESPONSE_TAB_TIMELINE); + expect(defaultTabFor("response", failed, RESPONSE_TABS, m)).toBe(RESPONSE_TAB_HEADERS); + expect(defaultTabFor("request", failed, [], m)).toBe(REQUEST_TAB_INFO); expect(defaultTabFor("request", { ...failed, requestSize: 4 }, [], m)).toBe(REQUEST_TAB_BODY); }); test("a tab chosen by hand becomes the default for the same shape and pane", () => { const m = memory(); - learnTab("response", ok, RESPONSE_TAB_INFO, m); - expect(defaultTabFor("response", ok, RESPONSE_TABS, m)).toBe(RESPONSE_TAB_INFO); + learnTab("response", ok, RESPONSE_TAB_COOKIES, m); + expect(defaultTabFor("response", ok, RESPONSE_TABS, m)).toBe(RESPONSE_TAB_COOKIES); // Not for other shapes, nor the other pane const post = { ...ok, requestSize: 30, responseSize: 0 }; expect(defaultTabFor("response", post, RESPONSE_TABS, m)).toBe(RESPONSE_TAB_HEADERS); - expect(defaultTabFor("request", ok, ["body", "headers", "info"], m)).toBe(REQUEST_TAB_HEADERS); + expect(defaultTabFor("request", ok, ["info", "body", "headers"], m)).toBe(REQUEST_TAB_INFO); }); test("a learned tab the flow doesn't have falls back to the table", () => { @@ -100,6 +95,15 @@ describe("defaultTabFor", () => { expect(defaultTabFor("response", ok, RESPONSE_TABS, m)).toBe(RESPONSE_TAB_BODY); }); + test("the request half opens on Info unless it has a body, and learns like the response", () => { + const m = memory(); + const tabs = [REQUEST_TAB_INFO, REQUEST_TAB_BODY, REQUEST_TAB_HEADERS]; + expect(defaultTabFor("request", ok, tabs, m)).toBe(REQUEST_TAB_INFO); + expect(defaultTabFor("request", { ...ok, requestSize: 5 }, tabs, m)).toBe(REQUEST_TAB_BODY); + learnTab("request", ok, REQUEST_TAB_HEADERS, m); + expect(defaultTabFor("request", ok, tabs, m)).toBe(REQUEST_TAB_HEADERS); + }); + test("works without storage", () => { expect(defaultTabFor("response", ok, RESPONSE_TABS, null)).toBe(RESPONSE_TAB_BODY); }); diff --git a/apps/yaak-proxy/lib/defaultTab.ts b/apps/yaak-proxy/lib/defaultTab.ts index 14c02d83f..0f899c82e 100644 --- a/apps/yaak-proxy/lib/defaultTab.ts +++ b/apps/yaak-proxy/lib/defaultTab.ts @@ -1,6 +1,7 @@ import type { Flow } from "@yaakapp-internal/proxy-lib"; -/** The request half's tabs, named as the shared request pane names them */ +/** The request half's tabs, named as the shared request pane names them, Info first */ +export const REQUEST_TAB_INFO = "info"; export const REQUEST_TAB_BODY = "body"; export const REQUEST_TAB_PARAMS = "params"; export const REQUEST_TAB_HEADERS = "headers"; @@ -11,8 +12,6 @@ export const REQUEST_TAB_COOKIES = "cookies"; export const RESPONSE_TAB_BODY = "body"; export const RESPONSE_TAB_HEADERS = "headers"; export const RESPONSE_TAB_COOKIES = "cookies"; -export const RESPONSE_TAB_TIMELINE = "timeline"; -export const RESPONSE_TAB_INFO = "info"; export const RESPONSE_TAB_MESSAGES = "messages"; export type FlowPane = "request" | "response"; @@ -33,10 +32,13 @@ export function flowShape( return "neither"; } -/** The tab a half opens on for a shape, before anything was learned */ +/** + * The tab a half opens on for a shape, before anything was learned: the request half on its + * body when it has one, else on Info; the response half on what it has + */ export function shapeDefaultTab(pane: FlowPane, shape: FlowShape, hasRequestBody: boolean): string { if (pane === "request") { - return hasRequestBody ? REQUEST_TAB_BODY : REQUEST_TAB_HEADERS; + return hasRequestBody ? REQUEST_TAB_BODY : REQUEST_TAB_INFO; } switch (shape) { case "response_body": @@ -44,7 +46,6 @@ export function shapeDefaultTab(pane: FlowPane, shape: FlowShape, hasRequestBody case "stream": return RESPONSE_TAB_MESSAGES; case "no_response": - return RESPONSE_TAB_TIMELINE; case "request_body": case "neither": return RESPONSE_TAB_HEADERS; diff --git a/apps/yaak-proxy/lib/encryptedCard.test.ts b/apps/yaak-proxy/lib/encryptedCard.test.ts new file mode 100644 index 000000000..6ee3db6a2 --- /dev/null +++ b/apps/yaak-proxy/lib/encryptedCard.test.ts @@ -0,0 +1,335 @@ +import type { DecryptListEntry } from "@yaakapp-internal/proxy-lib"; +import { describe, expect, test } from "vite-plus/test"; +import { + connectionOf, + type EncryptedCardInput, + encryptedCard, + stopDecryptingConnection, +} from "./encryptedCard"; + +const user = (...entries: string[]): DecryptListEntry[] => + entries.map((entry) => ({ entry, builtIn: false })); +const BUILT_INS: DecryptListEntry[] = [ + { entry: "!host:*.apple.com", builtIn: true, kind: "apple" }, + { entry: "!host:*.1password.com", builtIn: true, kind: "pinned" }, +]; + +const dia = { + clientName: "Dia", + clientBundleId: "company.thebrowser.dia", + clientPath: "/A/Dia.app", +}; +const tunnel: EncryptedCardInput["flow"] = { + url: "www.google.com:443", + ...dia, + tunnelReason: "not_listed", + tunnelExcludedBy: null, + tunnelExcludedBuiltIn: false, + tlsRejected: null, + elapsedMs: 188, + error: null, +}; + +function card(over: Partial = {}) { + return encryptedCard({ + flow: tunnel, + list: BUILT_INS, + trusted: true, + canTrust: true, + passthrough: [], + ...over, + }); +} + +const summary = (c: ReturnType) => + c.actions.map((a) => [a.label, a.hint, a.edit ?? null]); + +describe("connectionOf", () => { + test("reads a tunnel's authority and a refused handshake's URL alike", () => { + expect( + connectionOf({ url: "www.google.com:443", clientName: null, clientBundleId: null }), + ).toEqual({ + host: "www.google.com", + port: 443, + label: "www.google.com", + address: "www.google.com:443", + app: null, + }); + const c = connectionOf({ url: "https://home.ts.net:8317", ...dia }); + expect(c.label).toBe("home.ts.net:8317"); + expect(c.app).toEqual({ name: "Dia", bundleId: "company.thebrowser.dia" }); + expect( + connectionOf({ url: "[::1]:8443", clientName: null, clientBundleId: null }), + ).toMatchObject({ host: "::1", port: 8443, address: "[::1]:8443", label: "[::1]:8443" }); + }); +}); + +describe("the card of an encrypted connection", () => { + test("nothing decrypted yet: the host, the app and everything, each with what it writes", () => { + const c = card(); + expect(c.why).toBe("Nothing is decrypted yet"); + expect(summary(c)).toEqual([ + [ + "Decrypt www.google.com", + "host:www.google.com", + { add: ["host:www.google.com"], remove: [] }, + ], + ["Decrypt Dia", "app:Dia", { add: ["app:Dia"], remove: [] }], + ["Decrypt Everything", "*", { add: ["*"], remove: [] }], + ]); + expect(c.foot).toEqual(["New connections only"]); + expect(c.fact).toBeNull(); + }); + + test("asks for the password once while the certificate isn't trusted, where it can be", () => { + expect(card({ trusted: false }).foot).toEqual([ + "New connections only", + "Asks for your password once", + ]); + expect(card({ trusted: false, canTrust: false }).foot).toEqual(["New connections only"]); + }); + + test("something else listed: not on the list, with the same choices", () => { + const c = card({ list: [...user("host:api.stripe.com"), ...BUILT_INS] }); + expect(c.why).toBe("Not on the decrypt list"); + expect(c.actions.map((a) => a.label)).toEqual([ + "Decrypt www.google.com", + "Decrypt Dia", + "Decrypt Everything", + ]); + }); + + test("no app button for a connection no app is named for", () => { + const c = card({ + flow: { ...tunnel, clientName: null, clientBundleId: null, clientPath: null }, + }); + expect(c.actions.map((a) => a.label)).toEqual(["Decrypt www.google.com", "Decrypt Everything"]); + }); + + test("a port off 443 is part of the host it writes", () => { + const c = card({ flow: { ...tunnel, url: "home.ts.net:8317" } }); + expect(c.actions[0]?.hint).toBe("host:home.ts.net:8317"); + expect(c.actions[0]?.label).toBe("Decrypt home.ts.net:8317"); + }); + + test("Decrypt Everything is offered only where * would decrypt it", () => { + const flow = { ...tunnel, url: "my.1password.com:443", clientName: "1Password" }; + expect(card({ flow }).actions.map((a) => a.label)).not.toContain("Decrypt Everything"); + }); + + test("excluded by default: the host alone, dashed, and who may refuse it", () => { + const pinned = card({ + flow: { + ...tunnel, + url: "my.1password.com:443", + clientName: "1Password", + clientBundleId: "com.1password", + tunnelReason: "excluded", + tunnelExcludedBy: "!host:*.1password.com", + tunnelExcludedBuiltIn: true, + }, + }); + expect([pinned.why, pinned.whySub]).toEqual(["Excluded by default", "pins its certificate"]); + expect(summary(pinned)).toEqual([ + [ + "Decrypt my.1password.com", + "host:my.1password.com", + { add: ["host:my.1password.com"], remove: [] }, + ], + ]); + expect(pinned.actions[0]?.dashed).toBe(true); + expect(pinned.foot).toEqual(["1Password may refuse it"]); + + const apple = card({ + flow: { + ...tunnel, + url: "gateway.icloud.apple.com:443", + clientName: null, + clientBundleId: null, + tunnelReason: "excluded", + tunnelExcludedBy: "!host:*.apple.com", + tunnelExcludedBuiltIn: true, + }, + }); + expect(apple.whySub).toBe("Apple service"); + expect(apple.foot).toEqual(["macOS may refuse it"]); + }); + + test("excluded by the person: names it, and Stop Excluding when that alone decrypts it", () => { + const list = [...user("*", "!host:*.google.com"), ...BUILT_INS]; + const c = card({ + list, + flow: { ...tunnel, tunnelReason: "excluded", tunnelExcludedBy: "!host:*.google.com" }, + }); + expect([c.why, c.whyEntry]).toEqual(["Excluded by", "!host:*.google.com"]); + // Nothing added wins over the person's own exclusion, so taking it off is the way in + expect(summary(c)).toEqual([ + [ + "Stop Excluding *.google.com", + "!host:*.google.com", + { add: [], remove: ["!host:*.google.com"] }, + ], + ]); + // Their exact exclusion of the host is one Decrypt takes off itself + const exact = card({ list: [...user("*", "!host:www.google.com"), ...BUILT_INS] }); + expect(summary(exact)[0]).toEqual([ + "Decrypt www.google.com", + "host:www.google.com", + { add: [], remove: ["!host:www.google.com"] }, + ]); + + // Every way in blocked but taking the exclusions off + const both = [...user("*", "!host:*.google.com", "!app:Dia"), ...BUILT_INS]; + const blocked = card({ list: both }); + expect(summary(blocked)).toEqual([ + [ + "Stop Excluding 2 Entries", + "!host:*.google.com, !app:Dia", + { add: [], remove: ["!host:*.google.com", "!app:Dia"] }, + ], + ]); + expect(blocked.actions[0]?.removes).toBe(true); + }); + + test("once the list decrypts it: opened before, the opposite, and Reconnect while open", () => { + const list = [...user("host:www.google.com"), ...BUILT_INS]; + const closed = card({ list }); + expect(closed.why).toBe("Not on the decrypt list"); + expect(closed.fact).toBe("Opened before www.google.com was decrypted"); + expect(summary(closed)).toEqual([ + [ + "Stop Decrypting www.google.com", + "host:www.google.com", + { add: [], remove: ["host:www.google.com"] }, + ], + ]); + expect(closed.foot).toEqual([]); + + const open = card({ list, flow: { ...tunnel, elapsedMs: null } }); + expect(open.actions.map((a) => a.kind)).toEqual(["reconnect", "stop"]); + + const everything = card({ list: [...user("*"), ...BUILT_INS] }); + expect(everything.fact).toBe("Opened before everything was decrypted"); + expect(everything.actions[0]?.label).toBe("Stop Decrypting Everything"); + }); +}); + +describe("the card of a refused handshake", () => { + const refused: EncryptedCardInput["flow"] = { + ...tunnel, + url: "https://sync.acme.dev", + clientName: "Acme", + clientBundleId: "dev.acme", + clientPath: "/A/Acme.app", + tunnelReason: null, + tlsRejected: "bad_certificate", + elapsedMs: 6, + }; + const listed = [...user("*"), ...BUILT_INS]; + + test("an app: likely pinned, Stop Decrypting first, Try Again while passed through", () => { + const c = card({ flow: refused, list: listed, passthrough: ["sync.acme.dev:443"] }); + expect(c.rejected).toBe(true); + expect([c.why, c.whySub]).toEqual(["Acme rejected the certificate", "likely pinned"]); + expect(summary(c)).toEqual([ + ["Stop Decrypting Acme", "!app:Acme", { add: ["!app:Acme"], remove: [] }], + ["Try Again", "next connection", null], + ]); + expect(c.actions[0]?.primary).toBe(true); + expect(c.foot).toEqual(["Passed through until relaunch"]); + }); + + test("once no longer passed through, Try Again goes and the foot says so", () => { + const c = card({ flow: refused, list: listed, passthrough: [] }); + expect(c.actions.map((a) => a.kind)).toEqual(["stop"]); + expect(c.foot).toEqual(["No longer passed through"]); + }); + + test("a program: its own trust store, Copy Environment first", () => { + const python = { + ...refused, + url: "https://pypi.org", + clientName: "python3", + clientBundleId: null, + clientPath: "/opt/homebrew/bin/python3", + }; + const c = card({ flow: python, list: listed, passthrough: ["pypi.org:443"] }); + expect([c.why, c.whySub]).toEqual([ + "python3 rejected the certificate", + "uses its own trust store", + ]); + expect(c.actions.map((a) => [a.kind, a.label])).toEqual([ + ["copy_environment", "Copy Environment"], + ["stop", "Stop Decrypting python3"], + ["try_again", "Try Again"], + ]); + expect(c.foot).toEqual(["Then rerun python3"]); + }); + + test("an unknown client: the host", () => { + const c = card({ + flow: { ...refused, clientName: null, clientBundleId: null, clientPath: null }, + list: listed, + }); + expect(c.why).toBe("A client rejected the certificate"); + expect(summary(c)[0]).toEqual([ + "Stop Decrypting sync.acme.dev", + "!host:sync.acme.dev", + { add: ["!host:sync.acme.dev"], remove: [] }, + ]); + }); + + test("an untrusted certificate explains it: Trust Certificate first", () => { + const c = card({ + flow: refused, + list: listed, + trusted: false, + passthrough: ["sync.acme.dev:443"], + }); + expect(c.why).toBe("Certificate not trusted"); + expect(c.actions.map((a) => a.kind)).toEqual(["trust", "stop"]); + expect(c.foot).toEqual(["Passed through until trusted"]); + }); + + test("a tunnel after a refusal names the client that refused", () => { + const after = { + ...tunnel, + url: "sync.acme.dev:443", + tunnelReason: "rejected" as const, + }; + const c = card({ + flow: after, + list: listed, + rejectedBy: { clientName: "Acme", clientBundleId: "dev.acme", clientPath: null }, + }); + expect(c.why).toBe("Acme rejected the certificate for this host"); + // Stop Decrypting acts on this connection, so on Dia's own + expect(c.actions[0]?.hint).toBe("!app:Dia"); + }); +}); + +describe("Stop Decrypting a refused connection", () => { + const c = connectionOf({ url: "sync.acme.dev:443", clientName: "Acme", clientBundleId: null }); + + test("takes off the entry naming exactly this host when that alone stops it", () => { + const action = stopDecryptingConnection(user("host:sync.acme.dev"), c); + expect(action?.edit).toEqual({ add: [], remove: ["host:sync.acme.dev"] }); + expect(action?.label).toBe("Stop Decrypting sync.acme.dev"); + }); + + test("else excludes the app, or the host without one, which always wins", () => { + expect(stopDecryptingConnection(user("*"), c)?.edit).toEqual({ + add: ["!app:Acme"], + remove: [], + }); + const anonymous = { ...c, app: null }; + expect(stopDecryptingConnection(user("*", "host:sync.acme.dev"), anonymous)?.edit).toEqual({ + add: ["!host:sync.acme.dev"], + remove: [], + }); + }); + + test("nothing when the list does not decrypt it", () => { + expect(stopDecryptingConnection(BUILT_INS, c)).toBeNull(); + }); +}); diff --git a/apps/yaak-proxy/lib/encryptedCard.ts b/apps/yaak-proxy/lib/encryptedCard.ts new file mode 100644 index 000000000..ff8aaae70 --- /dev/null +++ b/apps/yaak-proxy/lib/encryptedCard.ts @@ -0,0 +1,404 @@ +import type { DecryptListEntry, Flow } from "@yaakapp-internal/proxy-lib"; +import { + type DecryptQuery, + type DecryptSubject, + decrypts, + decryptsAnything, + detailActions, + EVERYTHING, + entryMatches, + entrySubject, + listEdit, + setDecrypted, + subjectEntry, +} from "./decrypt"; +import { hostnameOf } from "./search"; + +/** Where an encrypted connection went, and the app that opened it, as the list judges it */ +export interface Connection { + /** The host alone, an IPv6 address without brackets */ + host: string; + port: number; + /** The host as a button names it: with its port when that is not 443 */ + label: string; + /** `host:port`, an IPv6 host in brackets, as the engine's pass-through set lists it */ + address: string; + app: { name: string; bundleId: string | null } | null; +} + +type ConnectionFlow = Pick; + +/** The connection a tunnel or a refused handshake records: `https://host:port` or `host:port` */ +export function connectionOf(flow: ConnectionFlow): Connection { + const scheme = flow.url.indexOf("://"); + const rest = scheme === -1 ? flow.url : flow.url.slice(scheme + 3); + const authority = (rest.split(/[/?#]/)[0] ?? "").toLowerCase(); + const name = hostnameOf(authority); + const portText = authority.slice(name.length + 1); + const port = /^\d+$/.test(portText) ? Number(portText) : 443; + const host = name.replace(/^\[|\]$/g, ""); + return { + host, + port, + label: port === 443 ? name : `${name}:${port}`, + address: `${name}:${port}`, + app: flow.clientName == null ? null : { name: flow.clientName, bundleId: flow.clientBundleId }, + }; +} + +function queryOf(c: Connection): DecryptQuery { + return { host: c.host, port: c.port, app: c.app }; +} + +/** What a button on the card does */ +export type CardActionKind = + | "decrypt" + | "stop" + | "reconnect" + | "trust" + | "try_again" + | "copy_environment"; + +export interface CardAction { + kind: CardActionKind; + label: string; + /** What it writes, shown beside it: an entry, or a hint like `next connection` */ + hint: string | null; + /** The hint is an entry it takes off the list, so it is struck through */ + removes?: boolean; + /** The list change, for `decrypt` and `stop` */ + edit?: { add: string[]; remove: string[] }; + /** What the toast says was chosen */ + toast?: { message: string; sub?: string }; + /** The suggested action */ + primary?: boolean; + /** The narrowest override of an exclusion that ships for a reason */ + dashed?: boolean; + /** What the icon shows: a host, the app, everything, or the action's own */ + icon: "host" | "app" | "everything" | "lock" | "lock_open" | "refresh" | "shield" | "terminal"; +} + +export interface EncryptedCardModel { + rejected: boolean; + /** Why the connection is encrypted, in one line */ + why: string; + /** Quieter, after it */ + whySub: string | null; + /** The exclusion that kept it out, when the person's own, shown as code after "Excluded by" */ + whyEntry: string | null; + /** A fact about the connection since the list changed: when it opened */ + fact: string | null; + actions: CardAction[]; + /** Small lines under the buttons */ + foot: string[]; +} + +export interface EncryptedCardInput { + flow: Pick< + Flow, + | "url" + | "clientName" + | "clientBundleId" + | "clientPath" + | "tunnelReason" + | "tunnelExcludedBy" + | "tunnelExcludedBuiltIn" + | "tlsRejected" + | "elapsedMs" + | "error" + >; + list: DecryptListEntry[]; + /** The certificate is trusted, null until read */ + trusted: boolean | null; + /** The app can trust the certificate itself, as on macOS */ + canTrust: boolean; + /** The addresses passed through after a rejection, null until read */ + passthrough: string[] | null; + /** For a tunnel passed through after a rejection: the client that refused, when known */ + rejectedBy?: Pick | null; +} + +/** A refused handshake, or a tunnel passed through because of one */ +export function isRejection(flow: Pick): boolean { + return flow.tlsRejected != null || flow.tunnelReason === "rejected"; +} + +/** A tunnel still open: written as it opens, done once it closes */ +export function isOpen(flow: Pick): boolean { + return flow.elapsedMs == null && flow.error == null; +} + +/** + * The card for an HTTPS connection the proxy did not decrypt: why, from what the engine recorded + * as it opened, and the changes to the list that would decrypt the next one like it, judged + * against the list as it is now. Once the list decrypts it, the card says it opened before and + * offers the opposite, and Reconnect while it is open. Nothing on it narrates what happens next. + */ +export function encryptedCard(input: EncryptedCardInput): EncryptedCardModel { + return isRejection(input.flow) ? rejectedCard(input) : tunnelCard(input); +} + +function tunnelCard({ flow, list, trusted, canTrust }: EncryptedCardInput): EncryptedCardModel { + const c = connectionOf(flow); + const q = queryOf(c); + const { why, whySub, whyEntry } = whyLine(flow, list); + const foot = ["New connections only"]; + + if (decrypts(list, q)) { + // Decrypted from the next connection on: the entries that do it, each to take back. + const decrypting = list.filter( + (e) => !e.builtIn && !e.entry.startsWith("!") && entryMatches(e.entry, q), + ); + const actions: CardAction[] = decrypting.map((e) => stopEntry(e.entry)); + if (isOpen(flow)) { + actions.unshift({ kind: "reconnect", label: "Reconnect", hint: null, icon: "refresh" }); + } + const first = decrypting[0]?.entry; + const what = + first == null ? c.label : first === EVERYTHING ? "everything" : entrySubject(first); + return { + rejected: false, + why, + whySub, + whyEntry, + fact: `Opened before ${what} was decrypted`, + actions, + foot: [], + }; + } + + const actions: CardAction[] = []; + const builtInBlock = list.find( + (e) => e.builtIn && e.entry.startsWith("!") && entryMatches(e.entry, q), + ); + const hostSubject: DecryptSubject = { kind: "host", host: c.label }; + const judged = detailActions(list, c.label, c.app); + if (builtInBlock != null) { + // Kept out by default for a reason: only the host itself, the narrowest override. + if (judged.host) { + actions.push({ ...decryptSubject(list, hostSubject, c.label, "host"), dashed: true }); + foot.length = 0; + foot.push( + builtInBlock.kind === "apple" || c.app == null + ? "macOS may refuse it" + : `${c.app.name} may refuse it`, + ); + } + } else { + if (judged.host) actions.push(decryptSubject(list, hostSubject, c.label, "host")); + if (judged.app && c.app != null) { + actions.push(decryptSubject(list, { kind: "app", ...c.app }, c.app.name, "app")); + } + const everything = list.some((e) => e.entry === EVERYTHING); + if (!everything && decrypts([{ entry: EVERYTHING, builtIn: false }, ...list], q)) { + actions.push({ + kind: "decrypt", + label: "Decrypt Everything", + hint: EVERYTHING, + edit: { add: [EVERYTHING], remove: [] }, + toast: { message: "Decrypting everything", sub: "Except Apple services and apps that pin" }, + icon: "everything", + }); + } + if (actions.length === 0) { + // The person's own exclusions keep it out: taking them off, when that alone decrypts it. + const own = list.filter( + (e) => !e.builtIn && e.entry.startsWith("!") && entryMatches(e.entry, q), + ); + const without = list.filter((e) => !own.includes(e)); + if (own.length > 0 && decrypts(without, q)) { + const [only] = own; + const label = + own.length === 1 && only != null + ? `Stop Excluding ${entrySubject(only.entry.slice(1))}` + : `Stop Excluding ${own.length} Entries`; + actions.push({ + kind: "decrypt", + label, + hint: own.map((e) => e.entry).join(", "), + removes: true, + edit: { add: [], remove: own.map((e) => e.entry) }, + toast: { message: label.replace("Stop Excluding", "Stopped excluding") }, + icon: "lock_open", + }); + } + } + } + if (actions.length > 0 && canTrust && trusted === false) foot.push("Asks for your password once"); + return { + rejected: false, + why, + whySub, + whyEntry, + fact: null, + actions, + foot: actions.length === 0 ? [] : foot, + }; +} + +/** The why line, from the reason the engine recorded as the tunnel opened */ +function whyLine( + flow: EncryptedCardInput["flow"], + list: DecryptListEntry[], +): { why: string; whySub: string | null; whyEntry: string | null } { + if (flow.tunnelReason === "excluded" && flow.tunnelExcludedBy != null) { + if (flow.tunnelExcludedBuiltIn) { + const kind = list.find((e) => e.entry === flow.tunnelExcludedBy)?.kind; + const whySub = + kind === "apple" ? "Apple service" : kind === "pinned" ? "pins its certificate" : null; + return { why: "Excluded by default", whySub, whyEntry: null }; + } + return { why: "Excluded by", whySub: null, whyEntry: flow.tunnelExcludedBy }; + } + return { + why: decryptsAnything(list) ? "Not on the decrypt list" : "Nothing is decrypted yet", + whySub: null, + whyEntry: null, + }; +} + +function decryptSubject( + list: DecryptListEntry[], + subject: DecryptSubject, + name: string, + icon: CardAction["icon"], +): CardAction { + return { + kind: "decrypt", + label: `Decrypt ${name}`, + hint: subjectEntry(subject), + edit: listEdit(list, setDecrypted(list, [subject], true)), + toast: { message: `Decrypting ${name}` }, + icon, + }; +} + +function stopEntry(entry: string): CardAction { + const name = entrySubject(entry); + return { + kind: "stop", + label: `Stop Decrypting ${name}`, + hint: entry, + removes: true, + edit: { add: [], remove: [entry] }, + toast: { message: `Stopped decrypting ${entry === EVERYTHING ? "everything" : name}` }, + icon: "lock", + }; +} + +/** + * Stop Decrypting on a refused connection, judged with its host, port and app together: takes + * off the person's entry naming exactly this host when that alone stops it, else excludes the + * app when it is known, else the host, which the list always honours. Null when the list does + * not decrypt it. + */ +export function stopDecryptingConnection( + list: DecryptListEntry[], + connection: Connection, +): CardAction | null { + const q = queryOf(connection); + if (!decrypts(list, q)) return null; + const hostEntry = subjectEntry({ kind: "host", host: connection.label }); + const own = list.find((e) => !e.builtIn && e.entry === hostEntry); + if ( + own != null && + !decrypts( + list.filter((e) => e !== own), + q, + ) + ) { + return { ...stopEntry(own.entry), label: `Stop Decrypting ${connection.label}` }; + } + const name = connection.app?.name ?? connection.label; + const add = connection.app == null ? `!${hostEntry}` : `!app:${connection.app.name}`; + return { + kind: "stop", + label: `Stop Decrypting ${name}`, + hint: add, + edit: { add: [add], remove: [] }, + toast: { message: `Stopped decrypting ${name}` }, + icon: "lock", + }; +} + +/** Who refused: an app with a bundle, a program without one, or a client the app couldn't name */ +type Refuser = + | { kind: "app"; name: string } + | { kind: "program"; name: string } + | { kind: "unknown" }; + +function refuserOf( + client: Pick | null | undefined, +): Refuser { + if (client?.clientName == null) return { kind: "unknown" }; + if (client.clientBundleId != null) return { kind: "app", name: client.clientName }; + return { kind: "program", name: client.clientName }; +} + +function rejectedCard(input: EncryptedCardInput): EncryptedCardModel { + const { flow, list, trusted, canTrust, passthrough } = input; + const c = connectionOf(flow); + // The refused handshake names its own client; a tunnel after it, the one that refused. + const own = flow.tlsRejected != null; + const refuser = refuserOf(own ? flow : input.rejectedBy); + const stop = stopDecryptingConnection(list, c); + const passedThrough = passthrough == null ? null : passthrough.includes(c.address); + const tryAgain: CardAction | null = + passedThrough === false + ? null + : { kind: "try_again", label: "Try Again", hint: "next connection", icon: "refresh" }; + const until = passedThrough === false ? "No longer passed through" : null; + + if (canTrust && trusted === false && decryptsAnything(list)) { + return { + rejected: true, + why: "Certificate not trusted", + whySub: null, + whyEntry: null, + fact: null, + actions: [ + { kind: "trust", label: "Trust Certificate", hint: null, primary: true, icon: "shield" }, + ...(stop == null ? [] : [stop]), + ], + foot: [until ?? "Passed through until trusted"], + }; + } + + const forHost = own ? "" : " for this host"; + if (refuser.kind === "program") { + return { + rejected: true, + why: `${refuser.name} rejected the certificate${forHost}`, + whySub: "uses its own trust store", + whyEntry: null, + fact: null, + actions: [ + { + kind: "copy_environment", + label: "Copy Environment", + hint: "SSL_CERT_FILE, …", + primary: true, + icon: "terminal", + }, + ...(stop == null ? [] : [stop]), + ...(tryAgain == null ? [] : [tryAgain]), + ], + foot: until == null ? [`Then rerun ${refuser.name}`] : [until], + }; + } + return { + rejected: true, + why: + refuser.kind === "app" + ? `${refuser.name} rejected the certificate${forHost}` + : "A client rejected the certificate", + whySub: refuser.kind === "app" ? "likely pinned" : null, + whyEntry: null, + fact: null, + actions: [ + ...(stop == null ? [] : [{ ...stop, primary: true }]), + ...(tryAgain == null ? [] : [tryAgain]), + ], + foot: [until ?? "Passed through until relaunch"], + }; +} diff --git a/apps/yaak-proxy/lib/firstRun.test.ts b/apps/yaak-proxy/lib/firstRun.test.ts index 259cff349..8a28f7d70 100644 --- a/apps/yaak-proxy/lib/firstRun.test.ts +++ b/apps/yaak-proxy/lib/firstRun.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vite-plus/test"; -import { decryptStep, setupIncomplete } from "./firstRun"; +import { setupIncomplete } from "./firstRun"; describe("setupIncomplete", () => { const done = { systemProxySupported: true, systemProxyEverOn: true }; @@ -21,21 +21,3 @@ describe("setupIncomplete", () => { ); }); }); - -describe("decryptStep", () => { - const builtIns = [{ entry: "!host:*.apple.com", builtIn: true }]; - - test("picks hosts as they appear by default, after the certificate", () => { - expect(decryptStep(builtIns, false)).toEqual({ choice: "pick", state: "todo" }); - expect(decryptStep(builtIns, true)).toEqual({ choice: "pick", state: "next" }); - }); - - test("is done once everything, or a host, is on the list", () => { - const everything = [{ entry: "*", builtIn: false }, ...builtIns]; - expect(decryptStep(everything, true)).toEqual({ choice: "everything", state: "done" }); - const host = [{ entry: "host:api.example.com", builtIn: false }]; - expect(decryptStep(host, true)).toEqual({ choice: "pick", state: "done" }); - // An exclusion alone decrypts nothing. - expect(decryptStep([{ entry: "!host:a.com", builtIn: false }], true).state).toBe("next"); - }); -}); diff --git a/apps/yaak-proxy/lib/firstRun.ts b/apps/yaak-proxy/lib/firstRun.ts index 811b06f36..b3df9ab95 100644 --- a/apps/yaak-proxy/lib/firstRun.ts +++ b/apps/yaak-proxy/lib/firstRun.ts @@ -1,6 +1,3 @@ -import type { DecryptListEntry } from "@yaakapp-internal/proxy-lib"; -import { decryptsEverything } from "./decrypt"; - /** What decides whether setup is done, as the app last read it */ export interface SetupState { /** The system proxy can be set on this platform, or null until read */ @@ -20,21 +17,3 @@ export function setupIncomplete(s: SetupState): boolean { if (s.systemProxySupported == null) return false; return s.systemProxySupported && !s.systemProxyEverOn; } - -/** The first-run card's "Choose what to decrypt" step, as the decrypt list has it */ -export interface DecryptStep { - /** Everything is decrypted, `*` on the list; else hosts are picked as they appear */ - choice: "everything" | "pick"; - state: "todo" | "next" | "done"; -} - -/** - * Picking hosts as they appear is the default and needs nothing done, so the step waits on the - * certificate like the others and is done once a choice is on the list: everything, or any host - * or app picked already. - */ -export function decryptStep(list: DecryptListEntry[], trusted: boolean): DecryptStep { - const choice = decryptsEverything(list) ? "everything" : "pick"; - const picked = list.some((e) => !e.builtIn && !e.entry.startsWith("!")); - return { choice, state: picked ? "done" : trusted ? "next" : "todo" }; -} diff --git a/apps/yaak-proxy/lib/hideSystem.test.ts b/apps/yaak-proxy/lib/hideSystem.test.ts index 1763ce552..8ed6be9c2 100644 --- a/apps/yaak-proxy/lib/hideSystem.test.ts +++ b/apps/yaak-proxy/lib/hideSystem.test.ts @@ -8,8 +8,14 @@ vi.mock("@yaakapp-internal/ui", async () => { return { selectedIdsFamily: () => atom([]) }; }); -const { activeQueryAtom, allTrafficTotalAtom, flowTotalAtom, hideSystemAtom, nonSystemTotalAtom } = - await import("./store"); +const { + activeQueryAtom, + allTrafficTotalAtom, + flowTotalAtom, + hideEncryptedAtom, + hideSystemAtom, + allTrafficShownAtom, +} = await import("./store"); describe("Show in All Traffic, in System's menu", () => { test("shows System's traffic in All traffic by default", () => { @@ -20,13 +26,45 @@ describe("Show in All Traffic, in System's menu", () => { test("leaves System out of All traffic and brings it back at once", () => { const store = createStore(); - store.set(nonSystemTotalAtom, 3); + store.set(allTrafficShownAtom, 3); expect(store.get(allTrafficTotalAtom)).toBe(store.get(flowTotalAtom)); store.set(hideSystemAtom, true); - expect(store.get(activeQueryAtom)).toEqual({ query: "", hideSystem: true }); + expect(store.get(activeQueryAtom)).toEqual({ + query: "", + hideSystem: true, + hideEncrypted: false, + }); expect(store.get(allTrafficTotalAtom)).toBe(3); store.set(hideSystemAtom, false); expect(store.get(activeQueryAtom)).toBeNull(); expect(store.get(allTrafficTotalAtom)).toBe(store.get(flowTotalAtom)); }); }); + +describe("Show in All Traffic, in Encrypted's menu", () => { + test("shows encrypted connections in All traffic by default", () => { + expect(createStore().get(hideEncryptedAtom)).toBe(false); + }); + + test("leaves encrypted connections out of All traffic, with System or alone", () => { + const store = createStore(); + store.set(allTrafficShownAtom, 5); + store.set(hideEncryptedAtom, true); + expect(store.get(activeQueryAtom)).toEqual({ + query: "", + hideSystem: false, + hideEncrypted: true, + }); + expect(store.get(allTrafficTotalAtom)).toBe(5); + store.set(hideSystemAtom, true); + expect(store.get(activeQueryAtom)).toEqual({ + query: "", + hideSystem: true, + hideEncrypted: true, + }); + store.set(hideEncryptedAtom, false); + store.set(hideSystemAtom, false); + expect(store.get(activeQueryAtom)).toBeNull(); + expect(store.get(allTrafficTotalAtom)).toBe(store.get(flowTotalAtom)); + }); +}); diff --git a/apps/yaak-proxy/lib/httpsStrip.test.ts b/apps/yaak-proxy/lib/httpsStrip.test.ts new file mode 100644 index 000000000..102d02ea5 --- /dev/null +++ b/apps/yaak-proxy/lib/httpsStrip.test.ts @@ -0,0 +1,83 @@ +import type { DecryptListEntry, Flow } from "@yaakapp-internal/proxy-lib"; +import { describe, expect, test } from "vite-plus/test"; +import { chooseOptions, connectionCount, type StripInput, stripState } from "./httpsStrip"; + +const BUILT_INS: DecryptListEntry[] = [ + { entry: "!host:*.apple.com", builtIn: true, kind: "apple" }, + { entry: "!host:*.1password.com", builtIn: true, kind: "pinned" }, +]; +const base: StripInput = { + list: BUILT_INS, + encrypted: 22, + dismissed: false, + trusted: false, + canTrust: true, +}; + +describe("the strip", () => { + test("shows while nothing is decrypted and something is encrypted", () => { + expect(stripState(base)).toBe("offer"); + expect(stripState({ ...base, trusted: true })).toBe("offer"); + }); + + test("goes once something is listed, with nothing encrypted, or dismissed", () => { + const listed = [{ entry: "app:Dia", builtIn: false }, ...BUILT_INS]; + expect(stripState({ ...base, list: listed, trusted: true })).toBe("hidden"); + expect(stripState({ ...base, encrypted: 0 })).toBe("hidden"); + expect(stripState({ ...base, dismissed: true })).toBe("hidden"); + // Exclusions alone decrypt nothing + const excluded = [{ entry: "!app:Dia", builtIn: false }, ...BUILT_INS]; + expect(stripState({ ...base, list: excluded })).toBe("offer"); + }); + + test("comes back broken, however dismissed, while something listed can't be decrypted", () => { + const listed = [{ entry: "*", builtIn: false }, ...BUILT_INS]; + expect(stripState({ ...base, list: listed, dismissed: true })).toBe("broken"); + // Not where the app can't trust it itself, nor before the status is read + expect(stripState({ ...base, list: listed, canTrust: false })).toBe("hidden"); + expect(stripState({ ...base, list: listed, trusted: null })).toBe("hidden"); + }); + + test("counts connections", () => { + expect(connectionCount(1)).toBe("1 connection"); + expect(connectionCount(1200)).toBe("1,200 connections"); + }); +}); + +describe("Choose…", () => { + const flow = (url: string, app: Partial, tunnelled = true) => ({ + url, + tunnelled, + clientName: null, + clientBundleId: null, + clientPath: null, + ...app, + }); + const dia = { clientName: "Dia", clientBundleId: "company.dia", clientPath: "/A/Dia.app" }; + const slack = { clientName: "Slack", clientBundleId: "com.slack", clientPath: "/A/Slack.app" }; + const flows = [ + flow("www.google.com:443", dia), + flow("fonts.gstatic.com:443", dia), + flow("clients2.google.com:443", dia), + flow("edgeapi.slack.com:443", slack), + // Decrypted already, so not one of the encrypted + flow("https://slack.com/api", slack, false), + // A daemon, and what a built-in keeps out, are deliberate choices elsewhere + flow("gateway.icloud.com:443", { clientName: "cloudd", clientPath: "/usr/libexec/cloudd" }), + flow("my.1password.com:443", { clientName: "1Password", clientBundleId: "com.1password" }), + ]; + + test("lists apps and sites seen, most connections first, checked while decrypted", () => { + const list = [{ entry: "host:*.slack.com", builtIn: false }, ...BUILT_INS]; + const { apps, sites } = chooseOptions(flows, list); + expect(apps.map((a) => [a.name, a.count, a.checked])).toEqual([ + ["Dia", 3, false], + ["Slack", 1, false], + ]); + expect(sites.map((s) => [s.site, s.count, s.checked])).toEqual([ + ["google.com", 2, false], + ["gstatic.com", 1, false], + ["slack.com", 1, true], + ]); + }); +}); diff --git a/apps/yaak-proxy/lib/httpsStrip.ts b/apps/yaak-proxy/lib/httpsStrip.ts new file mode 100644 index 000000000..6141ff23b --- /dev/null +++ b/apps/yaak-proxy/lib/httpsStrip.ts @@ -0,0 +1,107 @@ +import type { DecryptListEntry, Flow } from "@yaakapp-internal/proxy-lib"; +import { decryptsAnything, entryMatches, isDecrypted } from "./decrypt"; +import { connectionOf } from "./encryptedCard"; +import { isSystemFlow, siteOf } from "./search"; + +/** What the strip above the list shows */ +export type StripState = + /** Not shown */ + | "hidden" + /** HTTPS is encrypted, with Decrypt Everything and Choose… */ + | "offer" + /** The list decrypts something and the certificate isn't trusted: Trust, however dismissed */ + | "broken"; + +export interface StripInput { + list: DecryptListEntry[]; + /** Encrypted connections in the capture */ + encrypted: number; + /** Its × was clicked, remembered */ + dismissed: boolean; + /** The certificate is trusted, null until read */ + trusted: boolean | null; + /** The app can trust the certificate itself, as on macOS */ + canTrust: boolean; +} + +/** + * Shows while nothing is decrypted and some HTTPS is encrypted, until dismissed; goes once + * anything is on the list, from anywhere. A list that decrypts something while the certificate + * isn't trusted is broken, and says so whatever else holds. + */ +export function stripState({ + list, + encrypted, + dismissed, + trusted, + canTrust, +}: StripInput): StripState { + const listed = decryptsAnything(list); + if (listed && canTrust && trusted === false) return "broken"; + if (listed || encrypted === 0 || dismissed) return "hidden"; + return "offer"; +} + +/** "22 connections" */ +export function connectionCount(n: number): string { + return `${n.toLocaleString()} ${n === 1 ? "connection" : "connections"}`; +} + +export interface ChooseApp { + name: string; + bundleId: string | null; + path: string | null; + count: number; + checked: boolean; +} + +export interface ChooseSite { + site: string; + count: number; + checked: boolean; +} + +/** At most this many apps, and sites, in Choose… */ +const CHOOSE_MAX = 8; + +/** + * Choose…'s rows: the apps and sites of the encrypted connections seen, most connections first, + * each checked while the list decrypts it. System's daemons and what a built-in exclusion keeps + * out are left out, as decrypting those is deliberate, from the card or Settings. + */ +export function chooseOptions( + flows: Pick[], + list: DecryptListEntry[], +): { apps: ChooseApp[]; sites: ChooseSite[] } { + const builtIns = list.filter((e) => e.builtIn && e.entry.startsWith("!")); + const apps = new Map(); + const sites = new Map(); + for (const flow of flows) { + if (!flow.tunnelled || isSystemFlow(flow)) continue; + const c = connectionOf(flow); + const q = { host: c.host, port: c.port, app: c.app }; + if (builtIns.some((e) => entryMatches(e.entry, q))) continue; + if (c.app != null) { + const app = apps.get(c.app.name) ?? { + name: c.app.name, + bundleId: c.app.bundleId, + path: flow.clientPath, + count: 0, + checked: isDecrypted(list, { kind: "app", ...c.app }), + }; + app.count++; + apps.set(app.name, app); + } + const name = siteOf(c.label); + const site = sites.get(name) ?? { + site: name, + count: 0, + checked: isDecrypted(list, { kind: "site", site: name }), + }; + site.count++; + sites.set(name, site); + } + const top = (rows: Iterable) => + [...rows].sort((a, b) => b.count - a.count).slice(0, CHOOSE_MAX); + return { apps: top(apps.values()), sites: top(sites.values()) }; +} diff --git a/apps/yaak-proxy/lib/query.test.ts b/apps/yaak-proxy/lib/query.test.ts index ed66497b6..94b619a99 100644 --- a/apps/yaak-proxy/lib/query.test.ts +++ b/apps/yaak-proxy/lib/query.test.ts @@ -55,8 +55,17 @@ describe("terms", () => { describe("activeQuery", () => { test("is null for all traffic, and carries hiding System", () => { expect(activeQuery(" ", false)).toBeNull(); - expect(activeQuery("", true)).toEqual({ query: "", hideSystem: true }); - expect(activeQuery(" stripe ", false)).toEqual({ query: "stripe", hideSystem: false }); + expect(activeQuery("", true)).toEqual({ query: "", hideSystem: true, hideEncrypted: false }); + expect(activeQuery(" stripe ", false)).toEqual({ + query: "stripe", + hideSystem: false, + hideEncrypted: false, + }); + expect(activeQuery("", false, true)).toEqual({ + query: "", + hideSystem: false, + hideEncrypted: true, + }); }); }); diff --git a/apps/yaak-proxy/lib/query.ts b/apps/yaak-proxy/lib/query.ts index 9064edfd9..f61d13d1b 100644 --- a/apps/yaak-proxy/lib/query.ts +++ b/apps/yaak-proxy/lib/query.ts @@ -3,17 +3,23 @@ import { formatField, parseQuery } from "@yaakapp-internal/query"; /** * A search over every flow in the database, as `list_flows` takes it: the search box's text, - * which the backend parses and runs, and whether System's flows are hidden. + * which the backend parses and runs, and whether System's flows and encrypted connections are + * hidden. */ export interface FlowQuery { query: string; hideSystem: boolean; + hideEncrypted: boolean; } /** The search the list shows, or null for all traffic */ -export function activeQuery(text: string, hideSystem: boolean): FlowQuery | null { - if (text.trim() === "" && !hideSystem) return null; - return { query: text.trim(), hideSystem }; +export function activeQuery( + text: string, + hideSystem: boolean, + hideEncrypted = false, +): FlowQuery | null { + if (text.trim() === "" && !hideSystem && !hideEncrypted) return null; + return { query: text.trim(), hideSystem, hideEncrypted }; } /** `@field:value`, or `-@field:value`, the value quoted when it needs to be */ diff --git a/apps/yaak-proxy/lib/sidebarTree.test.ts b/apps/yaak-proxy/lib/sidebarTree.test.ts index 2c07425c6..080dc18fd 100644 --- a/apps/yaak-proxy/lib/sidebarTree.test.ts +++ b/apps/yaak-proxy/lib/sidebarTree.test.ts @@ -215,13 +215,20 @@ describe("decrypting from the sidebar", () => { const root = buildSidebarTree([...flows, tunnel], 5); const items = itemsById(root); - test("Not decrypted follows All traffic while there are tunnelled flows", () => { - expect((root.children ?? []).slice(0, 2).map((c) => shape(c)[0])).toEqual([ - "All traffic 5", - "Not decrypted 1", - ]); - expect(items.get("tunnelled")?.query).toBe("@is:tunnelled"); - expect(itemsById(buildSidebarTree(flows, 4)).has("tunnelled")).toBe(false); + test("Encrypted comes last, after System, while there are encrypted connections", () => { + expect((root.children ?? []).map((c) => shape(c)[0]).at(-1)).toBe("Encrypted 1"); + expect(items.get("encrypted")?.query).toBe("@is:encrypted"); + expect(itemsById(buildSidebarTree(flows, 4)).has("encrypted")).toBe(false); + }); + + test("marks what is all encrypted, once something is decrypted", () => { + expect(items.get("host:bank.example.com")?.encryptedOnly).toBe(true); + expect(items.get("site:example.com")?.encryptedOnly).toBe(true); + // Slack has decrypted flows too + expect(items.get("app:Slack")?.encryptedOnly).toBeUndefined(); + // In the first minute, when every row would be marked, none is + const onlyTunnels = itemsById(buildSidebarTree([tunnel], 1)); + expect(onlyTunnels.get("host:bank.example.com")?.encryptedOnly).toBeUndefined(); }); test("hosts, sites and apps name what their menu's Decrypt covers", () => { diff --git a/apps/yaak-proxy/lib/sidebarTree.ts b/apps/yaak-proxy/lib/sidebarTree.ts index 5ac8bd22d..0a93d9d4d 100644 --- a/apps/yaak-proxy/lib/sidebarTree.ts +++ b/apps/yaak-proxy/lib/sidebarTree.ts @@ -14,7 +14,7 @@ import { appKeyOf, folderOf, isSystemFlow, runtimeFolderOf, sidebarPath, siteOf export type SidebarKind = | "all" - | "tunnelled" + | "encrypted" | "saved" | "group" | "app" @@ -48,6 +48,11 @@ export interface SidebarItem { value?: string; /** What Decrypt in the row's menu decrypts: its host, site or app */ decrypt?: DecryptSubject; + /** + * Every flow under the row is encrypted, marked once something in the capture is decrypted, + * as in the first minute every row would be + */ + encryptedOnly?: boolean; /** The saved search a Saved row is, for renaming and deleting it */ savedId?: string; /** @@ -71,7 +76,7 @@ export const SAVED_ID = "saved"; export const APPS_ID = "apps"; export const DOMAINS_ID = "domains"; export const SYSTEM_ID = "system"; -export const TUNNELLED_ID = "tunnelled"; +export const ENCRYPTED_ID = "encrypted"; /** A row a flow sits under, with what makes it, so a rebuild only looks it up */ interface PlaceRow { @@ -196,12 +201,14 @@ function programPath(flow: Flow): string | undefined { interface Trie { item: SidebarItem; children: Map; + /** Encrypted flows under it */ + encrypted?: number; } const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }); /** Counts a flow under `parent`'s child `row`, making the child the first time */ -function child(parent: Trie, row: PlaceRow): Trie { +function child(parent: Trie, row: PlaceRow, encrypted: boolean): Trie { let node = parent.children.get(row.id); if (node == null) { node = { @@ -224,13 +231,25 @@ function child(parent: Trie, row: PlaceRow): Trie { delete node.item.path; } node.item.count++; + if (encrypted) node.encrypted = (node.encrypted ?? 0) + 1; return node; } /** Adds a flow under `parent` by where it went: its site, then its host, then its path */ -function addPlace(parent: Trie, rows: PlaceRow[]) { +function addPlace(parent: Trie, rows: PlaceRow[], encrypted: boolean) { let node = parent; - for (const row of rows) node = child(node, row); + for (const row of rows) node = child(node, row, encrypted); +} + +/** Marks the apps, sites and hosts whose every flow is encrypted */ +function markEncrypted(trie: Trie) { + for (const c of trie.children.values()) { + const kind = c.item.kind; + if ((kind === "app" || kind === "site" || kind === "host") && c.encrypted === c.item.count) { + c.item.encryptedOnly = true; + } + markEncrypted(c); + } } function toNodes( @@ -264,9 +283,8 @@ function group(id: string, label: string): Trie { /** * The sidebar: All traffic, then Apps, each app opening to its own sites, hosts and paths, then * Domains by site, host and path, then System, macOS's daemons, apart from the apps. A group with - * nothing in it is left out. Not decrypted, after All traffic, gathers the HTTPS passed through - * undecrypted, when there is any, and Saved lists the saved searches, each with its count from - * the database. `total` is All traffic's count: every flow it covers in the database. + * nothing in it is left out. Encrypted, last, gathers the HTTPS passed through undecrypted, + * when there is any, and Saved lists the saved searches, each with its count from the database. `total` is All traffic's count: every flow it covers in the database. */ export function buildSidebarTree( flows: Flow[], @@ -284,12 +302,12 @@ export function buildSidebarTree( const domains = group(DOMAINS_ID, "Domains"); const system = { ...group(SYSTEM_ID, "System") }; system.item.query = fieldTerm("is", "system"); - let tunnelled = 0; + let encrypted = 0; for (const flow of flows) { const place = placeOf(flow); - if (flow.tunnelled) tunnelled++; - addPlace(domains, place.rows); + if (flow.tunnelled) encrypted++; + addPlace(domains, place.rows, flow.tunnelled); if (place.app == null) continue; const parent = place.system ? system : apps; const id = `app:${place.app}`; @@ -318,8 +336,12 @@ export function buildSidebarTree( delete app.item.path; } app.item.count++; + if (flow.tunnelled) app.encrypted = (app.encrypted ?? 0) + 1; parent.item.count++; - addPlace(app, place.appRows); + addPlace(app, place.appRows, flow.tunnelled); + } + if (encrypted > 0 && encrypted < flows.length) { + for (const g of [apps, domains, system]) markEncrypted(g); } for (const site of domains.children.values()) domains.item.count += site.children.size; @@ -329,20 +351,6 @@ export function buildSidebarTree( depth: 1, draggable: false, }); - if (tunnelled > 0) { - root.children?.push({ - item: { - id: TUNNELLED_ID, - kind: "tunnelled", - label: "Not decrypted", - count: tunnelled, - query: fieldTerm("is", "tunnelled"), - }, - parent: root, - depth: 1, - draggable: false, - }); - } if (saved.length > 0) { const node: TreeNode = { item: { id: SAVED_ID, kind: "group", label: "Saved", count: 0, query: "" }, @@ -378,6 +386,20 @@ export function buildSidebarTree( node.children = toNodes(g.children.values(), node); root.children?.push(node); } + if (encrypted > 0) { + root.children?.push({ + item: { + id: ENCRYPTED_ID, + kind: "encrypted", + label: "Encrypted", + count: encrypted, + query: fieldTerm("is", "encrypted"), + }, + parent: root, + depth: 1, + draggable: false, + }); + } return root; } diff --git a/apps/yaak-proxy/lib/store.ts b/apps/yaak-proxy/lib/store.ts index fe7b12864..831bc413f 100644 --- a/apps/yaak-proxy/lib/store.ts +++ b/apps/yaak-proxy/lib/store.ts @@ -81,9 +81,14 @@ export const hideSystemAtom = atomWithStorage("sidebar_hide_system", false, unde getOnInit: true, }); +/** Encrypted connections are left out of All traffic, by Show in All Traffic in Encrypted's menu */ +export const hideEncryptedAtom = atomWithStorage("sidebar_hide_encrypted", false, undefined, { + getOnInit: true, +}); + /** The search the list shows, from the filter applied, or null for all traffic */ export const activeQueryAtom = atom((get) => - activeQuery(get(appliedFilterAtom), get(hideSystemAtom)), + activeQuery(get(appliedFilterAtom), get(hideSystemAtom), get(hideEncryptedAtom)), ); /** The saved searches, in their order */ @@ -103,13 +108,16 @@ const savedRowsAtom = atom((get) => { })); }); -/** Flows All traffic covers in the database: every one, or all but System's while hidden */ +/** + * Flows All traffic covers in the database: every one, or all but System's and the encrypted + * connections while either is hidden + */ export const allTrafficTotalAtom = atom((get) => - get(hideSystemAtom) ? get(nonSystemTotalAtom) : get(flowTotalAtom), + get(hideSystemAtom) || get(hideEncryptedAtom) ? get(allTrafficShownAtom) : get(flowTotalAtom), ); -/** Flows in the database not from System, counted while System is hidden */ -export const nonSystemTotalAtom = atom(0); +/** Flows in the database All traffic shows, counted while System or Encrypted is hidden */ +export const allTrafficShownAtom = atom(0); /** * The flows the sidebar counts: the window's, taken at most once a second, so a burst of traffic @@ -450,9 +458,11 @@ export function syncFlows(store: Store) { win.total === res.total ? win : { ...win, total: res.total }, ); }); - if (store.get(hideSystemAtom)) { - void rpc("list_flows", { hideSystem: true, limit: 0 }).then((res) => { - if (thisLoad === loads) store.set(nonSystemTotalAtom, res.matching ?? res.total); + const hideSystem = store.get(hideSystemAtom); + const hideEncrypted = store.get(hideEncryptedAtom); + if (hideSystem || hideEncrypted) { + void rpc("list_flows", { hideSystem, hideEncrypted, limit: 0 }).then((res) => { + if (thisLoad === loads) store.set(allTrafficShownAtom, res.matching ?? res.total); }); } countSaved(); @@ -485,7 +495,10 @@ export function syncFlows(store: Store) { return; } counting = true; - rpc("count_saved_queries", { hideSystem: store.get(hideSystemAtom) }) + rpc("count_saved_queries", { + hideSystem: store.get(hideSystemAtom), + hideEncrypted: store.get(hideEncryptedAtom), + }) .then((counts) => store.set(savedCountsAtom, Object.fromEntries(counts.map((c) => [c.id, c.count]))), ) @@ -643,9 +656,12 @@ export function syncFlows(store: Store) { // Read again, it keeps showing its rows until the new ones are in. const win = (keepRows ? store.get(searchWindowAtom)?.win : null) ?? emptyWindow; store.set(searchWindowAtom, { key, query, win, loading: true, buffered: [] }); - void rpc("watch_query", { key, query: query.query, hideSystem: query.hideSystem }).catch( - (err) => console.warn("Failed to watch the search", err), - ); + void rpc("watch_query", { + key, + query: query.query, + hideSystem: query.hideSystem, + hideEncrypted: query.hideEncrypted, + }).catch((err) => console.warn("Failed to watch the search", err)); const settle = (page: FlowWindow | null) => store.set(searchWindowAtom, (s) => { if (s?.key !== key) return s; @@ -693,6 +709,9 @@ export function syncFlows(store: Store) { store.sub(hideSystemAtom, () => { if (loaded) recount(); }); + store.sub(hideEncryptedAtom, () => { + if (loaded) recount(); + }); // A search counting back from now, `@time:<5m`, is read again while it is shown and the list // follows new traffic, so flows leave it and join it as they age. Saved ones are counted @@ -772,7 +791,7 @@ let reloadFlows: (() => void) | null = null; /** Empties the list, the search's too, and their counts, as a clear leaves them */ const emptyFlowsAtom = atom(null, (_get, set) => { set(flowWindowAtom, emptyWindow); - set(nonSystemTotalAtom, 0); + set(allTrafficShownAtom, 0); set(searchWindowAtom, (s) => (s == null ? s : { ...s, win: emptyWindow, buffered: [] })); set(sortedWindowAtom, (s) => s == null ? s : { ...s, flows: [], boundary: null, cursor: null, buffered: [] }, diff --git a/packages/ui/src/components/CountBadge.tsx b/packages/ui/src/components/CountBadge.tsx index a64d968bc..105ca4293 100644 --- a/packages/ui/src/components/CountBadge.tsx +++ b/packages/ui/src/components/CountBadge.tsx @@ -2,7 +2,8 @@ import type { Color } from "@yaakapp-internal/plugins"; import classNames from "classnames"; interface Props { - count: number | true; + /** A number, a dot for `true`, or a short word such as a body's type */ + count: number | true | string; count2?: number | true; className?: string; color?: Color; diff --git a/packages/ui/src/components/Dropdown.tsx b/packages/ui/src/components/Dropdown.tsx index f0d7a76cc..7f5dd2611 100644 --- a/packages/ui/src/components/Dropdown.tsx +++ b/packages/ui/src/components/Dropdown.tsx @@ -98,6 +98,8 @@ export interface DropdownProps { fullWidth?: boolean; hotKeyAction?: HotkeyAction; onOpen?: () => void; + /** The menu closed, however it was closed */ + onClose?: () => void; } export interface DropdownRef { @@ -117,7 +119,7 @@ const openAtom = atom(null); const openStore = createStore(); export const Dropdown = forwardRef(function Dropdown( - { children, items, hotKeyAction, fullWidth, onOpen }: DropdownProps, + { children, items, hotKeyAction, fullWidth, onOpen, onClose }: DropdownProps, ref, ) { const id = useRef(generateId()); @@ -129,9 +131,10 @@ export const Dropdown = forwardRef(function Dropdown const newIsOpen = globalOpenId === id.current; if (newIsOpen !== isOpen) { setIsOpen(newIsOpen); + if (!newIsOpen) onClose?.(); } }); - }, [isOpen]); + }, [isOpen, onClose]); // const [isOpen, _setIsOpen] = useState(false); const [defaultSelectedIndex, setDefaultSelectedIndex] = useState(null); diff --git a/packages/ui/src/components/http/HttpRequestPaneView.tsx b/packages/ui/src/components/http/HttpRequestPaneView.tsx index fb263f12e..8d08f03c8 100644 --- a/packages/ui/src/components/http/HttpRequestPaneView.tsx +++ b/packages/ui/src/components/http/HttpRequestPaneView.tsx @@ -106,6 +106,8 @@ export interface HttpRequestPaneViewProps { /** Tabs only the app has, after the shared ones */ extraTabs?: ExtraPaneTab[]; + /** Tabs only the app has, before the shared ones, like the proxy's Info */ + leadingTabs?: ExtraPaneTab[]; /** Replaces the URL bar, as the proxy's card shows a captured request's method and URL */ urlLine?: ReactNode; @@ -165,6 +167,7 @@ export function HttpRequestPaneView({ authTabs, authContent, extraTabs, + leadingTabs, urlLine, defaultTab, activeTab, @@ -187,6 +190,7 @@ export function HttpRequestPaneView({ const headerCount = request.headers.filter((h) => h.name && h.enabled !== false).length; const tabs = useMemo( () => [ + ...(leadingTabs?.map((t) => t.tab) ?? []), bodyTab ?? { value: REQUEST_TAB_BODY, label: bodyTypeLabel(request.bodyType, request.contentType), @@ -219,6 +223,7 @@ export function HttpRequestPaneView({ authTabs, bodyTab, extraTabs, + leadingTabs, headerCount, headersTabs, numParams, @@ -319,7 +324,7 @@ export function HttpRequestPaneView({ {renderBody ? renderBody(sharedBody) : sharedBody} - {extraTabs?.map(({ tab, content }) => ( + {[...(leadingTabs ?? []), ...(extraTabs ?? [])].map(({ tab, content }) => ( {content} diff --git a/packages/ui/src/components/http/HttpResponsePaneView.tsx b/packages/ui/src/components/http/HttpResponsePaneView.tsx index f1509e78a..3df89fd57 100644 --- a/packages/ui/src/components/http/HttpResponsePaneView.tsx +++ b/packages/ui/src/components/http/HttpResponsePaneView.tsx @@ -66,6 +66,8 @@ export interface HttpResponsePaneViewProps { cookieCounts?: { sent: number; received: number }; /** Tabs only the app has, after the shared ones */ extraTabs?: ExtraPaneTab[]; + /** The body tab as the app names it, where it has no menu of its own */ + bodyTab?: TabItem; textViewer?: ResponseTextViewerExtras; /** Viewers only this app has for a response body, like the client's event stream viewer */ renderBodyViewer?: EditorHost["renderBodyViewer"]; @@ -111,6 +113,7 @@ export function HttpResponsePaneView({ onCancel, cookieCounts: cookieCountsOverride, extraTabs, + bodyTab, textViewer, renderBodyViewer, infoRows, @@ -166,7 +169,7 @@ export function HttpResponsePaneView({ const headerCount = response?.headers.length ?? 0; return [ onViewModeChange == null && itemsAfter.length === 0 - ? { value: RESPONSE_TAB_BODY, label: "Response" } + ? (bodyTab ?? { value: RESPONSE_TAB_BODY, label: "Response" }) : { value: RESPONSE_TAB_BODY, options: { @@ -212,6 +215,7 @@ export function HttpResponsePaneView({ ...(extraTabs?.map((t) => t.tab) ?? []), ]; }, [ + bodyTab, cookieCounts, extraTabs, mimeType,