Encrypt Yaak Proxy's databases at rest with SQLCipher (#823)

This commit is contained in:
Gregory Schier authored and GitHub committed 2026-11-06 21:07:38 -07:00
1 parent cc382b9226
commit 91696c2d1f
42 files changed
+3366 -286

No files matched your search

@@ -1,14 +1,16 @@
import { Dropdown, type DropdownItem, IconButton } from "@yaakapp-internal/ui";
import { useAtomValue, useSetAtom } from "jotai";
import { useState } from "react";
import { clearFlowsAtom, flowTotalAtom } from "../lib/store";
import { clearFlowsAtom, flowTotalAtom, lockedAtom } from "../lib/store";
/**
* Clears every flow, once confirmed in its menu, which its hotkey opens too. The list empties at
* once; the flows are deleted in the background.
* once; the flows are deleted in the background. Disabled with none to clear, or while captured
* traffic is locked.
*/
export function ClearFlowsButton() {
const total = useAtomValue(flowTotalAtom);
const locked = useAtomValue(lockedAtom);
const clear = useSetAtom(clearFlowsAtom);
const [error, setError] = useState<string | null>(null);
@@ -45,6 +47,20 @@ export function ClearFlowsButton() {
},
];
// Nothing to clear, or nothing that can be read while traffic is locked: no menu, nor hotkey.
if (locked != null || total === 0) {
return (
<IconButton
className="pointer-events-auto"
icon="trash"
title={locked != null ? "Clear flows (traffic is locked)" : "Clear flows (none yet)"}
size="sm"
iconColor="secondary"
disabled
/>
);
}
return (
<Dropdown items={items} hotKeyAction="flows_clear">
<IconButton
+18 -3
View File
@@ -3,6 +3,7 @@ import classNames from "classnames";
import { useAtomValue } from "jotai";
import type { ReactNode } from "react";
import { useRpcMutation } from "../hooks/useRpcMutation";
import { encryptingPercent } from "../lib/encrypting";
import { proxyStateAtom } from "../lib/store";
import { useCopyEnvironment, useProxySetup, useSystemProxySwitch } from "./ProxyStatus";
@@ -47,7 +48,9 @@ export function FirstRun() {
? "next"
: "todo";
const ready = trusted && (systemOn || !systemSupported);
const trafficState: StepState = !running && !otherOwner ? "fail" : ready ? "wait" : "todo";
const encrypting = !running && !otherOwner ? encryptingPercent(proxyState) : null;
const trafficState: StepState =
encrypting != null ? "wait" : !running && !otherOwner ? "fail" : ready ? "wait" : "todo";
return (
<div className="grid place-items-center h-full px-10 pb-16 pt-6">
@@ -113,9 +116,17 @@ export function FirstRun() {
<Step
n={systemSupported ? 3 : 2}
state={trafficState}
title={trafficState === "wait" ? "Waiting for traffic" : "See traffic"}
title={
encrypting != null
? `Encrypting stored traffic, ${encrypting}`
: trafficState === "wait"
? "Waiting for traffic"
: "See traffic"
}
description={
trafficState === "fail"
encrypting != null
? "Traffic captured before this version is being encrypted on disk. Recording starts when it's done."
: trafficState === "fail"
? (retry.error?.message ?? proxyState?.startError ?? "The proxy has not started.")
: !recording
? "Recording is paused, so traffic passes through without being stored."
@@ -161,6 +172,10 @@ export function FirstRun() {
Working with an agent? <InlineCode>yaak mcp</InlineCode> gives it the proxy's flows and
actions.
</div>
<div className="mt-2 text-sm text-text-subtle">
Captured traffic is encrypted on disk with a key kept in your keychain, so macOS may ask
to let Yaak Proxy use it.
</div>
</div>
</div>
);
@@ -0,0 +1,53 @@
import type { ProxyLock } from "@yaakapp-internal/proxy-lib";
import { Button, Icon, LoadingIcon } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai";
import { useRpcMutation } from "../hooks/useRpcMutation";
import { encryptingPercent } from "../lib/encrypting";
import { lockedLine } from "../lib/locked";
import { proxyStateAtom } from "../lib/store";
import { getOsType } from "../lib/tauri";
/**
* Over the list and the detail pane while captured traffic can't be read: its key is
* unavailable, or traffic from before encryption is being encrypted. Retry reads the key again
* and opens it.
*/
export function LockedState({ lock }: { lock: ProxyLock }) {
const proxyState = useAtomValue(proxyStateAtom);
const retry = useRpcMutation("take_over");
const encrypting = encryptingPercent(proxyState);
return (
<div className="grid place-items-center h-full px-10 pb-16">
<div className="flex flex-col items-center text-center gap-2 max-w-[28rem]">
{encrypting != null ? (
<LoadingIcon size="lg" className="text-text-subtle mb-1" />
) : (
<Icon icon="lock" size="lg" className="text-text-subtle mb-1" />
)}
<div className="text-lg font-semibold">
{encrypting != null ? `Encrypting stored traffic, ${encrypting}` : "Traffic is locked"}
</div>
<div className="text-sm text-text-subtle">
{encrypting != null
? "Traffic captured before this version is being encrypted on disk. It shows here when it's done."
: lockedLine(lock, getOsType())}
</div>
{encrypting == null && (
<Button
size="xs"
variant="border"
className="mt-2"
isLoading={retry.isPending}
onClick={() => retry.mutate({})}
>
Retry
</Button>
)}
{encrypting == null && retry.error != null && (
<div className="text-xs text-danger">{retry.error.message}</div>
)}
</div>
</div>
);
}
+7 -3
View File
@@ -12,12 +12,13 @@ import { useState } from "react";
import { useLocalStorage } from "react-use";
import { flowLayoutAtom, settingsRequestedAtom } from "../lib/atoms";
import { setupIncomplete } from "../lib/firstRun";
import { noFlowsAtom, systemProxyEverOnAtom } from "../lib/store";
import { lockedAtom, noFlowsAtom, systemProxyEverOnAtom } from "../lib/store";
import { getOsType } from "../lib/tauri";
import { ClearFlowsButton } from "./ClearFlowsButton";
import { FirstRun } from "./FirstRun";
import { FlowDetail } from "./FlowDetail";
import { FlowList } from "./FlowList";
import { LockedState } from "./LockedState";
import { ProxyStatus, useProxySetup } from "./ProxyStatus";
import { HelperDialog } from "./HelperDialog";
import { SaveDialog } from "./SaveDialog";
@@ -157,10 +158,12 @@ export function ProxyLayout() {
/**
* The flow list beside or above the detail pane, or, while setup is incomplete and there are no
* flows, the first-run card across the whole area. Once setup is done an empty list says so
* itself, after a Clear too.
* itself, after a Clear too. While captured traffic is locked, the locked state takes the whole
* area, or the first-run card does, saying why, while setup is incomplete too.
*/
function FlowsOrFirstRun({ flowLayout }: { flowLayout: SplitLayoutLayout }) {
const noFlows = useAtomValue(noFlowsAtom);
const locked = useAtomValue(lockedAtom);
const noFlows = useAtomValue(noFlowsAtom) || locked != null;
const { caStatus, systemProxy } = useProxySetup();
const systemProxyEverOn = useAtomValue(systemProxyEverOnAtom);
const incomplete = setupIncomplete({
@@ -169,6 +172,7 @@ function FlowsOrFirstRun({ flowLayout }: { flowLayout: SplitLayoutLayout }) {
systemProxyEverOn,
});
if (noFlows && incomplete) return <FirstRun />;
if (locked != null) return <LockedState lock={locked} />;
return (
<SplitLayout
storageKey="proxy_detail"
+25 -3
View File
@@ -7,6 +7,7 @@ import {
type DropdownItem,
type DropdownRef,
Icon,
LoadingIcon,
} from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useAtom, useAtomValue, useSetAtom, useStore } from "jotai";
@@ -16,6 +17,7 @@ import { useRpcEvent } from "../hooks/useRpcEvent";
import { useRpcMutation } from "../hooks/useRpcMutation";
import { useRpcQuery } from "../hooks/useRpcQuery";
import { helperDialogOpenAtom, helperRegisteredAtAtom } from "../lib/atoms";
import { encryptingPercent } from "../lib/encrypting";
import { environmentExports } from "../lib/env";
import { proxyStateAtom, refreshProxyState, systemProxyEverOnAtom } from "../lib/store";
import {
@@ -51,9 +53,17 @@ export function ProxyStatus() {
const other = proxyState?.otherOwner ?? null;
const port = proxyState?.port ?? other?.proxyPort ?? null;
const untrusted = caStatus.data != null && !caStatus.data.trusted;
// Traffic stored before encryption is being encrypted; the engine starts once it is.
const encrypting = !running && other == null ? encryptingPercent(proxyState) : null;
const stateRow =
other != null ? (
encrypting != null ? (
<MenuRow
icon={<LoadingIcon size="sm" className="text-text-subtle" />}
title={`Encrypting stored traffic, ${encrypting}`}
description="Traffic captured before this version is being encrypted on disk. Recording starts when it's done."
/>
) : other != null ? (
<MenuRow
icon={<StateDot state="cli" />}
title="Running from the CLI"
@@ -252,8 +262,20 @@ export function ProxyStatus() {
return (
<>
<Dropdown ref={dropdownRef} items={items}>
<Pill color={!running && other == null && proxyState != null ? "warning" : undefined}>
{other != null ? (
<Pill
color={
!running && other == null && encrypting == null && proxyState != null
? "warning"
: undefined
}
>
{encrypting != null ? (
<>
<LoadingIcon size="xs" />
<span className="text-text">Encrypting</span>
<span className="font-mono text-text-subtle">{encrypting}</span>
</>
) : other != null ? (
<>
<StateDot state="cli" />
<span className="text-text">CLI</span>
+5 -1
View File
@@ -3,6 +3,7 @@ import { useAtom, useAtomValue, useSetAtom, useStore } from "jotai";
import { memo, type RefObject, useEffect, useRef } from "react";
import {
allTrafficTotalAtom,
lockedAtom,
searchTermsAtom,
searchTextAtom,
searchTextVersionAtom,
@@ -24,6 +25,8 @@ export const SearchBar = memo(function SearchBar() {
const setTerms = useSetAtom(searchTermsAtom);
const setSearchText = useSetAtom(setSearchTextAtom);
const version = useAtomValue(searchTextVersionAtom);
// Nothing to search while captured traffic is locked.
const locked = useAtomValue(lockedAtom) != null;
// Read, not subscribed: `PlaceholderCount` keeps it current without re-rendering the field.
const total = useStore().get(allTrafficTotalAtom);
// PlainInput hands back its <input> as the ref
@@ -59,7 +62,8 @@ export const SearchBar = memo(function SearchBar() {
hideLabel
size="sm"
className="font-mono text-xs"
placeholder={placeholderFor(total)}
placeholder={locked ? "Traffic is locked" : placeholderFor(total)}
disabled={locked}
defaultValue={text}
forceUpdateKey={String(version)}
onChange={setText}
+90
View File
@@ -0,0 +1,90 @@
import { HotkeyProvider } from "@yaakapp-internal/ui";
import type { GetProxyStateResponse } from "@yaakapp-internal/proxy-lib";
import { useStore } from "jotai";
import { type ReactNode, useEffect, useState } from "react";
import { ProxyLayout } from "../components/ProxyLayout";
import { useHotkeyContextValue } from "../lib/hotkeys";
import { syncFlows, syncProxyState, syncSidebarSelection } from "../lib/store";
const LOCKED =
"Traffic is locked: Yaak Proxy stores traffic encrypted and couldn't read its key from the " +
"keychain: Encryption error: User canceled the operation. Allow it access, then retry.";
const STATE: GetProxyStateResponse = {
state: "stopped",
port: null,
configuredPort: 9090,
recording: true,
systemProxyAtLaunch: true,
httpVersion: "auto",
startError: LOCKED.slice("Traffic is locked: ".length),
encrypting: null,
locked: { message: LOCKED, keychain: true },
otherOwner: null,
};
/** What the backend answers while the keychain refuses: the state, and every read refused */
function answer(cmd: string): unknown {
switch (cmd) {
case "get_proxy_state":
return STATE;
case "ca_status":
return { trusted: true, installed: true, certPath: "/tmp/yaak-proxy-ca.pem" };
case "system_proxy_status":
return {
enabled: false,
port: 9090,
services: [],
backend: "helper",
helper: "enabled",
supported: true,
};
case "list_actions":
return { actions: [] };
default:
throw new Error(LOCKED);
}
}
/** Stands in for Tauri's IPC, answering as a locked proxy does */
export function installLockedBackend() {
const internals = (window as unknown as { __TAURI_INTERNALS__: Record<string, unknown> })
.__TAURI_INTERNALS__;
internals.invoke = async (cmd: string, args?: { cmd?: string }) => {
if (cmd === "rpc") return answer(args?.cmd ?? "");
if (cmd.startsWith("plugin:event|")) return 0;
throw new Error(`Not in the harness: ${cmd}`);
};
(window as unknown as { __TAURI_OS_PLUGIN_INTERNALS__: Record<string, string> })
.__TAURI_OS_PLUGIN_INTERNALS__ = {
os_type: "macos",
platform: "macos",
family: "unix",
version: "15.0",
eol: "\n",
};
// Setup was done before, as for anyone whose traffic is locked after an upgrade.
localStorage.setItem("system_proxy_ever_on", "true");
}
/** The whole window, as the app renders it, while captured traffic is locked */
export function LockedHarness() {
const store = useStore();
const [synced, setSynced] = useState(false);
useEffect(() => {
syncProxyState(store);
syncFlows(store);
syncSidebarSelection(store);
setSynced(true);
}, [store]);
if (!synced) return null;
return (
<Hotkeys>
<ProxyLayout />
</Hotkeys>
);
}
function Hotkeys({ children }: { children: ReactNode }) {
return <HotkeyProvider value={useHotkeyContextValue()}>{children}</HotkeyProvider>;
}
+9 -1
View File
@@ -4,18 +4,26 @@ import { createStore, Provider } from "jotai";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { FlowPanesHarness } from "./FlowPanesHarness";
import { installLockedBackend, LockedHarness } from "./LockedHarness";
import { MessagesHarness } from "./MessagesHarness";
import "../main.css";
const params = new URLSearchParams(location.search);
const dark = params.get("theme") !== "light";
applyThemeToDocument(dark ? defaultDarkTheme : defaultLightTheme);
if (params.has("locked")) installLockedBackend();
createRoot(document.getElementById("root") as HTMLElement).render(
<StrictMode>
<QueryClientProvider client={new QueryClient()}>
<Provider store={createStore()}>
{params.has("stream") ? <MessagesHarness /> : <FlowPanesHarness />}
{params.has("locked") ? (
<LockedHarness />
) : params.has("stream") ? (
<MessagesHarness />
) : (
<FlowPanesHarness />
)}
</Provider>
</QueryClientProvider>
</StrictMode>,
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, test } from "vite-plus/test";
import { encryptingPercent } from "./encrypting";
describe("encryptingPercent", () => {
const state = (encrypting: number | null) =>
({ encrypting }) as Parameters<typeof encryptingPercent>[0];
test("is null when nothing is being encrypted", () => {
expect(encryptingPercent(null)).toBeNull();
expect(encryptingPercent(state(null))).toBeNull();
});
test("rounds down and stays within 0 and 100", () => {
expect(encryptingPercent(state(0))).toBe("0%");
expect(encryptingPercent(state(0.426))).toBe("42%");
expect(encryptingPercent(state(0.999))).toBe("99%");
expect(encryptingPercent(state(1))).toBe("100%");
expect(encryptingPercent(state(1.2))).toBe("100%");
});
});
+11
View File
@@ -0,0 +1,11 @@
import type { GetProxyStateResponse } from "@yaakapp-internal/proxy-lib";
/**
* How far encrypting traffic stored before encryption has got, as "42%", while it runs; null
* otherwise. Rounded down, so it reads 100% only once it is done.
*/
export function encryptingPercent(state: GetProxyStateResponse | null | undefined): string | null {
const fraction = state?.encrypting;
if (fraction == null) return null;
return `${Math.floor(Math.min(Math.max(fraction, 0), 1) * 100)}%`;
}
+5 -2
View File
@@ -7,7 +7,7 @@ import { useCallback } from "react";
import { base64ToBytes, bodyFileName } from "./body";
import { curlBodyText, toCurl } from "./curl";
import { rpc } from "./rpc";
import { activeQueryAtom } from "./store";
import { activeQueryAtom, lockedAtom } from "./store";
import { useShowToast } from "./toast";
/** The flow the save dialog is open for */
@@ -68,10 +68,13 @@ export async function exportHar(
export function useExportShownHar(): () => DropdownItem {
const showToast = useShowToast();
const query = useAtomValue(activeQueryAtom);
// Nothing can be read to export while captured traffic is locked.
const locked = useAtomValue(lockedAtom) != null;
return useCallback(
() => ({
label: query == null ? "Export All Flows as HAR" : "Export Matching Flows as HAR",
icon: "download",
disabled: locked,
onSelect: async () => {
try {
const written = await exportHar(query == null ? {} : { filter: query }, "flows.har");
@@ -86,7 +89,7 @@ export function useExportShownHar(): () => DropdownItem {
}
},
}),
[query, showToast],
[query, locked, showToast],
);
}
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, test } from "vite-plus/test";
import { lockedLine } from "./locked";
describe("lockedLine", () => {
test("points at the platform's key store when it refused", () => {
const lock = { keychain: true, message: "Traffic is locked: anything" };
expect(lockedLine(lock, "macos")).toBe(
"Allow Yaak Proxy to use its key in Keychain Access, then retry.",
);
expect(lockedLine(lock, "windows")).toContain("Credential Manager");
expect(lockedLine(lock, "linux")).toContain("your keyring");
});
test("gives any other reason without the title's words", () => {
const lock = {
keychain: false,
message: "Traffic is locked: the proxy is already running in the yaak CLI (pid 4).",
};
expect(lockedLine(lock, "macos")).toBe("The proxy is already running in the yaak CLI (pid 4).");
});
});
+30
View File
@@ -0,0 +1,30 @@
import type { ProxyLock } from "@yaakapp-internal/proxy-lib";
/** What every refused read starts with, before its reason */
const LOCKED_PREFIX = "Traffic is locked: ";
/** Where each platform keeps the key, by the name its users know it by */
function keyStoreName(os: string): string {
switch (os) {
case "macos":
return "Keychain Access";
case "windows":
return "Credential Manager";
default:
return "your keyring";
}
}
/**
* The locked state's one line: how to let Yaak Proxy read its key when the key store is what
* refused, or else the reason itself, without the "Traffic is locked" its title already says.
*/
export function lockedLine(lock: ProxyLock, os: string): string {
if (lock.keychain) {
return `Allow Yaak Proxy to use its key in ${keyStoreName(os)}, then retry.`;
}
const reason = lock.message.startsWith(LOCKED_PREFIX)
? lock.message.slice(LOCKED_PREFIX.length)
: lock.message;
return reason.charAt(0).toUpperCase() + reason.slice(1);
}
+3
View File
@@ -185,6 +185,9 @@ export const followAtom = atom(
/** The engine's state, kept current from its events, and polled while another process has it */
export const proxyStateAtom = atom<GetProxyStateResponse | null>(null);
/** Why captured traffic can't be read, while it can't: nothing that reads it is offered then */
export const lockedAtom = atom((get) => get(proxyStateAtom)?.locked ?? null);
/** When recording was seen to pause, in this session; null while recording or not known */
export const pausedAtAtom = atom<number | null>(null);