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

Generated
+6
View File
@@ -4156,6 +4156,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95b4103cffefa72eb8428cb6b47d6627161e51c2739fc5e3b734584157bc642a"
dependencies = [
"cc",
"openssl-sys",
"pkg-config",
"vcpkg",
]
@@ -11612,9 +11613,11 @@ dependencies = [
"base32",
"base64 0.23.1",
"chacha20poly1305",
"dbus-secret-service",
"keyring",
"log 0.4.34",
"serde",
"tempfile",
"thiserror 2.0.21",
"yaak-models",
]
@@ -11628,6 +11631,7 @@ dependencies = [
"include_dir",
"log 0.4.34",
"nanoid",
"openssl-sys",
"r2d2",
"r2d2_sqlite",
"rusqlite",
@@ -11635,6 +11639,7 @@ dependencies = [
"sea-query-rusqlite",
"serde",
"serde_json",
"tempfile",
"thiserror 2.0.21",
"ts-rs",
"uuid",
@@ -11951,6 +11956,7 @@ dependencies = [
"ts-rs",
"url",
"yaak-common",
"yaak-crypto",
"yaak-database",
"yaak-filter",
"yaak-grpc",
@@ -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);
+5 -2
View File
@@ -26,12 +26,14 @@ tokio = { workspace = true, features = ["io-util", "macros", "net", "process", "
ts-rs = { workspace = true, features = ["chrono-impl"] }
url = "2"
yaak-common = { workspace = true }
yaak-database = { workspace = true }
yaak-crypto = { workspace = true }
# Its databases are encrypted whole with SQLCipher.
yaak-database = { workspace = true, features = ["sqlcipher"] }
yaak-filter = { workspace = true }
yaak-grpc = { workspace = true }
yaak-http = { workspace = true }
yaak-lifecycle = { workspace = true }
yaak-models = { workspace = true }
yaak-models = { workspace = true, features = ["sqlcipher"] }
yaak-proxy = { workspace = true }
yaak-proxy-helper = { workspace = true, features = ["ts"] }
yaak-rpc = { workspace = true }
@@ -82,4 +84,5 @@ tonic-reflection = "0.14.6"
reqwest = { workspace = true }
tempfile = "3"
tokio = { workspace = true, features = ["macros", "net", "rt-multi-thread", "time"] }
yaak-crypto = { workspace = true, features = ["test-support"] }
yaak-proxy-helper = { workspace = true, features = ["test-support"] }
+23
View File
@@ -250,6 +250,15 @@ httpVersion: HttpVersion,
* Why the engine last failed to start, until it starts.
*/
startError: string | null,
/**
* How far encrypting traffic stored before encryption has got, from 0 to 1, while it
* runs. The engine starts once it is done.
*/
encrypting: number | null,
/**
* Captured traffic cannot be read: its key is unavailable, or it is being encrypted.
*/
locked: ProxyLock | null,
/**
* Another process that owns the engine, such as the CLI daemon. While one does, this app
* cannot start its own, and new flows reach it only through the database.
@@ -336,6 +345,20 @@ export type Owner = "app" | "cli";
export type PreviewSaveRequest = { flowId: string, };
/**
* Why captured traffic cannot be read, for the window's locked state.
*/
export type ProxyLock = {
/**
* What every refused read says.
*/
message: string,
/**
* The keychain would not give up the key, or has not been asked yet, so allowing Yaak
* Proxy in Keychain Access is the way out.
*/
keychain: boolean, };
export type ProxySetting = { enabled: boolean, server: string, port: number, };
export type ProxyState = "running" | "stopped";
+2 -1
View File
@@ -322,7 +322,8 @@ mod tests {
fn proxy_db() -> (tempfile::TempDir, ProxyQueryManager) {
let dir = tempfile::tempdir().unwrap();
let db = ProxyQueryManager::new(&dir.path().join("proxy.db")).unwrap();
let db =
ProxyQueryManager::new(&dir.path().join("proxy.db"), &crate::db::test_key()).unwrap();
(dir, db)
}
+12 -9
View File
@@ -9,6 +9,7 @@ use crate::control::{
SystemProxyRequest, TRUNCATED_HEADER,
};
use crate::db::ProxyQueryManager;
use crate::db::Stores;
use crate::flows::{self, FlowDetail, FlowFilter};
use crate::har::{self, Har};
use crate::macos::SystemProxyStatus;
@@ -18,6 +19,7 @@ use crate::settings::ProxySettings;
use serde::Serialize;
use serde::de::DeserializeOwned;
use std::path::Path;
use yaak_crypto::MasterKeySource;
use yaak_models::blob_manager::BlobManager;
/// Where captured traffic is read from.
@@ -29,11 +31,13 @@ pub enum ProxyConnection {
}
impl ProxyConnection {
/// The live owner in `data_dir`, or its databases when there is none.
pub fn connect(data_dir: &Path) -> Result<Self, String> {
/// The live owner in `data_dir`, or its databases when there is none, read with the data
/// key the master key in `keys` unwraps. The app's keychain item, so on macOS a CLI asking
/// for it is prompted to be allowed.
pub fn connect(data_dir: &Path, keys: &MasterKeySource) -> Result<Self, String> {
match control::live_owner(data_dir) {
Some(info) => Ok(Self::Live(ControlClient::new(info))),
None => Ok(Self::Offline(OfflineStore::open(data_dir)?)),
None => Ok(Self::Offline(OfflineStore::open(data_dir, keys)?)),
}
}
@@ -200,18 +204,17 @@ pub struct OfflineStore {
}
impl OfflineStore {
pub fn open(data_dir: &Path) -> Result<Self, String> {
let db_path = data_dir.join("proxy.db");
/// Opens the databases in `data_dir`, decrypting with the data key the master key in `keys`
/// unwraps. Ones from before encryption, not yet converted, are read as they are.
pub fn open(data_dir: &Path, keys: &MasterKeySource) -> Result<Self, String> {
let db_path = data_dir.join(crate::db::PROXY_DB);
if !db_path.exists() {
return Err(format!(
"No captured traffic yet: {} does not exist. Start Yaak Proxy to capture some.",
db_path.display()
));
}
let db = ProxyQueryManager::open_read_only(&db_path)
.map_err(|e| format!("Failed to open {}: {e}", db_path.display()))?;
let blobs = crate::db::open_blobs_read_only(&data_dir.join("blobs.sqlite"))
.map_err(|e| format!("Failed to open the proxy's blob store: {e}"))?;
let Stores { db, blobs, .. } = Stores::open_read_only(data_dir, keys)?;
let settings = ProxySettings::load(&data_dir.join("settings.json"))?;
Ok(Self { db, blobs, capture_body_limit: settings.capture_body_limit })
}
+51 -23
View File
@@ -33,7 +33,6 @@ use std::sync::mpsc as std_mpsc;
use std::time::Duration;
use tokio::net::TcpListener;
use ts_rs::TS;
use yaak_proxy::CaStore;
pub const CONTROL_FILE: &str = "control.json";
const LOCK_FILE: &str = "control.lock";
@@ -212,10 +211,14 @@ pub struct ControlStatus {
pub configured_port: u16,
/// Flows are stored. Paused, traffic still passes through, unrecorded.
pub recording: bool,
/// Flows stored; zero while they are locked.
pub flow_count: usize,
pub ca: CaStatus,
/// `None` when it could not be read.
pub system_proxy: Option<SystemProxyStatus>,
/// Why captured traffic cannot be read, while it cannot.
#[serde(default)]
pub locked: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
@@ -458,10 +461,21 @@ impl ApiError {
impl From<String> for ApiError {
fn from(message: String) -> Self {
// Locked, said through a call that returns its reason as text.
if message.starts_with(crate::LOCKED) {
return Self::new(StatusCode::SERVICE_UNAVAILABLE, message);
}
Self::new(StatusCode::INTERNAL_SERVER_ERROR, message)
}
}
/// Captured traffic is locked for now: unavailable, not missing or broken.
impl From<crate::Locked> for ApiError {
fn from(locked: crate::Locked) -> Self {
Self::new(StatusCode::SERVICE_UNAVAILABLE, locked.to_string())
}
}
async fn handle(state: &ServerState, req: Request<Incoming>) -> ControlResponse {
if !authorized(&req, &state.token) {
return error_response(ApiError::new(
@@ -515,7 +529,7 @@ async fn route(state: &ServerState, req: Request<Incoming>) -> Result<ControlRes
}
(&Method::GET, ["flows"]) => {
let filter = FlowFilter::from_query(&query).map_err(bad_request)?;
json(&flows::list(&ctx.db, &filter)?)
json(&flows::list(&ctx.db()?, &filter)?)
}
(&Method::POST, ["flows", "clear"]) => {
let ctx = ctx.clone();
@@ -524,7 +538,7 @@ async fn route(state: &ServerState, req: Request<Incoming>) -> Result<ControlRes
(&Method::GET, ["flows", id]) => {
found(ctx, id)?;
let limit = body_limit(&query)?;
json(&flows::detail(&ctx.db, &ctx.blobs, id, limit).await?)
json(&flows::detail(&ctx.db()?, &ctx.blobs()?, id, limit).await?)
}
(&Method::GET, ["flows", id, "request-body"]) => raw_body(ctx, id, FlowBody::Request).await,
(&Method::GET, ["flows", id, "response-body"]) => {
@@ -533,20 +547,20 @@ async fn route(state: &ServerState, req: Request<Incoming>) -> Result<ControlRes
(&Method::GET, ["flows", id, "messages"]) => {
found(ctx, id)?;
let (after_seq, limit) = message_page(&query)?;
let messages = messages::list(&ctx.db, id, after_seq, limit)?;
let total = messages::count(&ctx.db, id)?;
let messages = messages::list(&ctx.db()?, id, after_seq, limit)?;
let total = messages::count(&ctx.db()?, id)?;
json(&ListMessagesResponse { messages, total })
}
(&Method::GET, ["flows", id, "messages", seq]) => {
let flow = flows::find(&ctx.db, id)
let flow = flows::find(&ctx.db()?, id)
.ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, format!("No flow {id}")))?;
let seq: i64 = seq.parse().map_err(|_| bad_request(format!("Invalid seq {seq:?}")))?;
let message = messages::find(&ctx.db, id, seq)?.ok_or_else(|| {
let message = messages::find(&ctx.db()?, id, seq)?.ok_or_else(|| {
ApiError::new(StatusCode::NOT_FOUND, format!("No message {seq} in flow {id}"))
})?;
let payload = messages::payload(&ctx.db, &ctx.blobs, &message)?;
let payload = messages::payload(&ctx.db()?, &ctx.blobs()?, &message)?;
let (decoded, decode_error) = if message.kind == MessageKind::GrpcMessage {
match grpc::decode(&ctx.db, &ctx.blobs, &ctx.grpc, &flow, seq, false).await {
match grpc::decode(&ctx.db()?, &ctx.blobs()?, &ctx.grpc, &flow, seq, false).await {
Ok(decoded) => (Some(decoded), None),
Err(e) => (None, Some(e)),
}
@@ -556,20 +570,22 @@ async fn route(state: &ServerState, req: Request<Incoming>) -> Result<ControlRes
let payload = BodyPreview::new(&payload, message.truncated);
json(&MessageDetail { message, payload, decoded, decode_error })
}
(&Method::GET, ["grpc-schemas"]) => json(&grpc::list(&ctx.db)?),
(&Method::GET, ["grpc-schemas"]) => json(&grpc::list(&ctx.db()?)?),
(&Method::POST, ["grpc-schemas"]) => {
let body: AddGrpcSchemaRequest = read_json(req).await?;
let added = grpc::add(&ctx.db, &ctx.grpc, &body.host_pattern, &body.proto_paths);
let db = ctx.db()?;
let added = grpc::add(&db, &ctx.grpc, &body.host_pattern, &body.proto_paths);
json(&added.await.map_err(bad_request)?)
}
(&Method::POST, ["grpc-schemas", "reflect"]) => {
let body: ReflectGrpcHostRequest = read_json(req).await?;
let (host, plaintext) = grpc::reflection_host(&body.host);
let reflected = grpc::reflect(&ctx.db, &ctx.grpc, &host, plaintext).await;
let db = ctx.db()?;
let reflected = grpc::reflect(&db, &ctx.grpc, &host, plaintext).await;
json(&reflected.map_err(bad_request)?)
}
(&Method::DELETE, ["grpc-schemas", id]) => {
json(&RemovedResponse { removed: grpc::remove(&ctx.db, &ctx.grpc, id)? })
json(&RemovedResponse { removed: grpc::remove(&ctx.db()?, &ctx.grpc, id)? })
}
(&Method::POST, ["flows", id, "save"]) => {
found(ctx, id)?;
@@ -579,17 +595,23 @@ async fn route(state: &ServerState, req: Request<Incoming>) -> Result<ControlRes
}
(&Method::GET, ["har"]) => {
let filter = FlowFilter::from_query(&query).map_err(bad_request)?;
let flows = flows::list(&ctx.db, &filter)?;
json(&har::export(&ctx.db, &ctx.blobs, &flows, ctx.capture_body_limit()?).await?)
let flows = flows::list(&ctx.db()?, &filter)?;
json(&har::export(&ctx.db()?, &ctx.blobs()?, &flows, ctx.capture_body_limit()?).await?)
}
(&Method::GET, ["ca"]) => {
let ca = CaStore::load_or_create(&ctx.ca_dir())
.map_err(|e| format!("Failed to load proxy CA: {e}"))?;
// Off the runtime's few workers: it may wait for the keychain or a conversion.
let ca = {
let ctx = ctx.clone();
blocking(move || ctx.load_ca()).await?
};
Ok(response(StatusCode::OK, "application/x-pem-file", ca.pem().to_string()))
}
(&Method::POST, ["ca", "trust"]) => {
let ca = CaStore::load_or_create(&ctx.ca_dir())
.map_err(|e| format!("Failed to load proxy CA: {e}"))?;
// Off the runtime's few workers: it may wait for the keychain or a conversion.
let ca = {
let ctx = ctx.clone();
blocking(move || ctx.load_ca()).await?
};
ca::trust(ca.cert_path()).await.map_err(bad_request)?;
ctx.forget_passthrough();
json(&ca::status(ca.cert_path()).await?)
@@ -625,24 +647,30 @@ async fn status(ctx: &ProxyCtx) -> Result<ControlStatus, ApiError> {
proxy_port,
configured_port: ctx.settings()?.port,
recording: ctx.recording(),
flow_count: flows::count(&ctx.db)?,
// Answered while locked too, saying so.
flow_count: match ctx.db() {
Ok(db) => flows::count(&db)?,
Err(_) => 0,
},
ca,
system_proxy,
locked: ctx.locked().map(|lock| lock.message),
})
}
fn found(ctx: &ProxyCtx, id: &str) -> Result<(), ApiError> {
match flows::find(&ctx.db, id) {
match flows::find(&ctx.db()?, id) {
Some(_) => Ok(()),
None => Err(ApiError::new(StatusCode::NOT_FOUND, format!("No flow {id}"))),
}
}
async fn raw_body(ctx: &ProxyCtx, id: &str, which: FlowBody) -> Result<ControlResponse, ApiError> {
let flow = flows::find(&ctx.db, id)
let flow = flows::find(&ctx.db()?, id)
.ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, format!("No flow {id}")))?;
let body =
bodies::read_body(&ctx.db, &ctx.blobs, &flow, which, ctx.capture_body_limit()?).await?;
bodies::read_body(&ctx.db()?, &ctx.blobs()?, &flow, which, ctx.capture_body_limit()?)
.await?;
let content_type =
find_header(flow.headers(which), "content-type").unwrap_or("application/octet-stream");
let mut resp = response(StatusCode::OK, content_type, body.data);
+214 -24
View File
@@ -1,15 +1,30 @@
//! The one place the proxy's databases are opened. `proxy.db` and `blobs.sqlite` are encrypted
//! whole with SQLCipher, keyed from the data key [`Stores::open`] is given: every connection is
//! keyed as it opens, and a plain database left by a build from before is converted before
//! anything writes to it. Only reading, an unconverted one is read as it is.
use include_dir::{Dir, include_dir};
use r2d2::Pool;
use r2d2_sqlite::SqliteConnectionManager;
use rusqlite::functions::FunctionFlags;
use rusqlite::{OpenFlags, Transaction, TransactionBehavior};
use std::path::Path;
use std::path::{Path, PathBuf};
use std::time::Duration;
use yaak_crypto::MasterKeySource;
use yaak_crypto::data_key::DataKey;
use yaak_database::encryption::{self, DatabaseKey, FileState};
use yaak_database::{ConnectionOrTx, DbContext, Error, Result, SqlitePool, run_migrations};
use yaak_models::blob_manager::BlobManager;
static MIGRATIONS: Dir<'static> = include_dir!("$CARGO_MANIFEST_DIR/migrations");
/// Flows, their headers and messages.
pub const PROXY_DB: &str = "proxy.db";
/// Request and response bodies, and message payloads.
pub const BLOBS_DB: &str = "blobs.sqlite";
/// The key both are encrypted with, wrapped by the app's master key in the keychain.
pub const DATA_KEY: &str = "data.key";
/// Reads come from a pool; writes go through one connection, as in yaak-models'
/// `QueryManager`. WAL lets the CLI read `proxy.db` while the app writes it.
#[derive(Clone)]
@@ -19,19 +34,23 @@ pub struct ProxyQueryManager {
}
impl ProxyQueryManager {
pub fn new(db_path: &Path) -> Result<Self> {
/// Opens `proxy.db` encrypted with `key`, made so when it is new and converted when a build
/// from before left it plain, and brings its schema up to date.
pub fn new(db_path: &Path, key: &DatabaseKey) -> Result<Self> {
if let Some(parent) = db_path.parent() {
std::fs::create_dir_all(parent)?;
}
let writer = file_pool(db_path, 1)?;
encryption::encrypt_in_place(db_path, key, &|_| {})?;
let writer = file_pool(db_path, 1, key)?;
run_migrations(&writer, &MIGRATIONS)?;
Ok(Self { readers: file_pool(db_path, 5)?, writer })
Ok(Self { readers: file_pool(db_path, 5, key)?, writer })
}
/// `proxy.db` for reading while another process may be writing it, or none is. Writes
/// fail. No migrations run, so an older database reads as it is.
pub fn open_read_only(db_path: &Path) -> Result<Self> {
let readers = read_only_pool(db_path)?;
/// fail. No migrations run, so an older database reads as it is. An encrypted one needs
/// `key`; a plain one, not yet converted, is read without it.
pub fn open_read_only(db_path: &Path, key: Option<&DatabaseKey>) -> Result<Self> {
let readers = read_only_pool(db_path, key)?;
Ok(Self { writer: readers.clone(), readers })
}
@@ -71,12 +90,150 @@ impl ProxyQueryManager {
}
}
/// `blobs.sqlite` encrypted with `key`, converted first when it is plain, migrated.
pub fn open_blobs(path: &Path, key: &DatabaseKey) -> Result<BlobManager> {
encryption::encrypt_in_place(path, key, &|_| {})?;
yaak_models::init_encrypted_blob_store(path, key).map_err(|e| Error::Database(e.to_string()))
}
/// `blobs.sqlite` for reading, alongside [`ProxyQueryManager::open_read_only`].
pub fn open_blobs_read_only(path: &Path) -> Result<BlobManager> {
let readers = read_only_pool(path)?;
pub fn open_blobs_read_only(path: &Path, key: Option<&DatabaseKey>) -> Result<BlobManager> {
let readers = read_only_pool(path, key)?;
Ok(BlobManager::new(readers.clone(), readers))
}
/// The SQLCipher key for the databases, from the data key.
pub fn database_key(data_key: &DataKey) -> DatabaseKey {
DatabaseKey::from_bytes(data_key.raw_bytes())
}
/// Where the data key is kept in `data_dir`.
pub fn data_key_path(data_dir: &Path) -> PathBuf {
data_dir.join(DATA_KEY)
}
/// The data key for the databases in `data_dir`, unwrapped with the master key in `keys`, and
/// made the first time. Not made again once a database is encrypted, should its file go: a new
/// one could not open it, and would replace the CA.
pub fn load_or_create_data_key(
keys: &MasterKeySource,
data_dir: &Path,
) -> std::result::Result<DataKey, String> {
let path = data_key_path(data_dir);
if any_encrypted(data_dir) {
DataKey::load_file(keys, &path)
.map_err(|e| key_unavailable(&e.to_string()))?
.ok_or_else(|| missing_data_key(data_dir))
} else {
DataKey::load_or_create_file(keys, &path).map_err(|e| key_unavailable(&e.to_string()))
}
}
fn any_encrypted(data_dir: &Path) -> bool {
[PROXY_DB, BLOBS_DB]
.iter()
.any(|name| encryption::file_state(&data_dir.join(name)).ok() == Some(FileState::Encrypted))
}
fn missing_data_key(data_dir: &Path) -> String {
format!(
"The proxy's captured traffic is encrypted, but its key is missing from {}",
data_dir.display()
)
}
/// Both databases, opened together.
#[derive(Clone)]
pub struct Stores {
pub db: ProxyQueryManager,
pub blobs: BlobManager,
}
impl Stores {
/// The databases in `data_dir`, encrypted with `key`. Ones a build from before left plain
/// are converted first, without progress; [`encrypt_existing`] shows it.
pub fn open(data_dir: &Path, key: &DatabaseKey) -> std::result::Result<Self, String> {
let db = ProxyQueryManager::new(&data_dir.join(PROXY_DB), key)
.map_err(|e| format!("Failed to open proxy database: {e}"))?;
let blobs = open_blobs(&data_dir.join(BLOBS_DB), key)
.map_err(|e| format!("Failed to open proxy blob store: {e}"))?;
Ok(Self { db, blobs })
}
/// The databases in `data_dir` for reading, with the data key the master key in `keys`
/// unwraps. Asks the keychain only when one is encrypted, and never makes a key.
pub fn open_read_only(
data_dir: &Path,
keys: &MasterKeySource,
) -> std::result::Result<Self, String> {
let db_path = data_dir.join(PROXY_DB);
let blobs_path = data_dir.join(BLOBS_DB);
let key = if any_encrypted(data_dir) {
let data_key = DataKey::load_file(keys, &data_key_path(data_dir))
.map_err(|e| key_unavailable(&e.to_string()))?
.ok_or_else(|| missing_data_key(data_dir))?;
Some(database_key(&data_key))
} else {
None
};
let db = ProxyQueryManager::open_read_only(&db_path, key.as_ref())
.map_err(|e| format!("Failed to open {}: {e}", db_path.display()))?;
let blobs = open_blobs_read_only(&blobs_path, key.as_ref())
.map_err(|e| format!("Failed to open the proxy's blob store: {e}"))?;
Ok(Self { db, blobs })
}
}
/// Either database in `data_dir` is plain, left by a build from before encryption.
pub fn needs_encrypting(data_dir: &Path) -> bool {
[PROXY_DB, BLOBS_DB]
.iter()
.any(|name| encryption::file_state(&data_dir.join(name)).ok() == Some(FileState::Plain))
}
/// Converts the plain databases in `data_dir` to ones encrypted with `key`, telling `progress`
/// how far it has got, from 0 to 1. Returns whether there were any. Nothing may have them
/// open meanwhile.
pub fn encrypt_existing(
data_dir: &Path,
key: &DatabaseKey,
progress: &(dyn Fn(f64) + Sync),
) -> std::result::Result<bool, String> {
let paths: Vec<PathBuf> = [PROXY_DB, BLOBS_DB]
.iter()
.map(|name| data_dir.join(name))
.filter(|p| encryption::file_state(p).ok() == Some(FileState::Plain))
.collect();
let total = paths.iter().map(|p| encryption::plain_size(p)).sum::<u64>().max(1) as f64;
let mut before = 0u64;
let mut converted = false;
for path in &paths {
let size = encryption::plain_size(path);
let done = before;
let file_progress = move |written: u64| {
progress(((done + written.min(size)) as f64 / total).min(1.0));
};
converted |= encryption::encrypt_in_place(path, key, &file_progress)
.map_err(|e| format!("Failed to encrypt {}: {e}", path.display()))?;
before += size;
}
progress(1.0);
Ok(converted)
}
const KEY_UNAVAILABLE: &str =
"Yaak Proxy stores traffic encrypted and couldn't read its key from the keychain";
/// Why the databases cannot be opened, or the CA made, without the data key.
pub fn key_unavailable(reason: &str) -> String {
format!("{KEY_UNAVAILABLE}: {reason}. Allow it access, then retry.")
}
/// `reason` is [`key_unavailable`]'s: the keychain would not give up the key.
pub fn is_key_unavailable(reason: &str) -> bool {
reason.starts_with(KEY_UNAVAILABLE)
}
/// The functions filters use: `sidebar_path(url)`, the app sidebar's place for a URL, and
/// `under_site(url, site)`, for filtering by its nodes; `folder_name(dir)`, for naming a program
/// by its folder; and `is_system(bundle_id, path)`, for macOS's own daemons.
@@ -99,10 +256,24 @@ fn add_functions(conn: &mut rusqlite::Connection) -> rusqlite::Result<()> {
})
}
fn read_only_pool(path: &Path) -> Result<SqlitePool> {
fn read_only_pool(path: &Path, key: Option<&DatabaseKey>) -> Result<SqlitePool> {
encryption::init_crypto();
let key = match (encryption::file_state(path)?, key) {
(FileState::Plain, _) => None,
(_, Some(key)) => Some(key.clone()),
(_, None) => {
return Err(Error::Database(format!(
"{} is encrypted, with no key to read it",
path.display()
)));
}
};
let manager = SqliteConnectionManager::file(path)
.with_flags(OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX)
.with_init(|conn| {
.with_init(move |conn| {
if let Some(key) = &key {
encryption::apply_key(conn, key)?;
}
conn.busy_timeout(Duration::from_millis(5000))?;
add_functions(conn)
});
@@ -113,8 +284,11 @@ fn read_only_pool(path: &Path) -> Result<SqlitePool> {
.map_err(|e| Error::Database(e.to_string()))
}
fn file_pool(path: &Path, max_size: u32) -> Result<SqlitePool> {
let manager = SqliteConnectionManager::file(path).with_init(|conn| {
fn file_pool(path: &Path, max_size: u32, key: &DatabaseKey) -> Result<SqlitePool> {
encryption::init_crypto();
let key = key.clone();
let manager = SqliteConnectionManager::file(path).with_init(move |conn| {
encryption::apply_key(conn, &key)?;
conn.pragma_update(None, "journal_mode", "WAL")?;
conn.pragma_update(None, "synchronous", "NORMAL")?;
conn.busy_timeout(Duration::from_millis(5000))?;
@@ -127,6 +301,21 @@ fn file_pool(path: &Path, max_size: u32) -> Result<SqlitePool> {
.map_err(|e| Error::Database(e.to_string()))
}
/// The key unit tests encrypt their databases with.
#[cfg(test)]
pub(crate) fn test_key() -> DatabaseKey {
DatabaseKey::from_bytes([7; 32])
}
/// A connection to a database encrypted with [`test_key`], for altering it as another build
/// would have.
#[cfg(test)]
pub(crate) fn test_conn(path: &Path) -> rusqlite::Connection {
let conn = rusqlite::Connection::open(path).unwrap();
encryption::apply_key(&conn, &test_key()).unwrap();
conn
}
#[cfg(test)]
mod tests {
use super::*;
@@ -142,10 +331,10 @@ mod tests {
use crate::models::Flow;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("proxy.db");
drop(ProxyQueryManager::new(&path).unwrap());
drop(ProxyQueryManager::new(&path, &crate::db::test_key()).unwrap());
// As a build from before the saved request and client columns left it.
let conn = Connection::open(&path).unwrap();
let conn = test_conn(&path);
conn.execute_batch(
"ALTER TABLE flows DROP COLUMN saved_request_id;
ALTER TABLE flows DROP COLUMN client_pid;
@@ -164,7 +353,7 @@ mod tests {
.unwrap();
drop(conn);
let db = ProxyQueryManager::open_read_only(&path).unwrap();
let db = ProxyQueryManager::open_read_only(&path, Some(&test_key())).unwrap();
let flows = db.with_conn(|c| c.find_all::<Flow>()).unwrap();
assert_eq!(flows.len(), 1);
assert_eq!((flows[0].id.as_str(), flows[0].saved_request_id.as_deref()), ("fl_old", None));
@@ -178,7 +367,7 @@ mod tests {
// Filters by app work without the folder column, and count without the clear mark.
drop(db);
let conn = Connection::open(&path).unwrap();
let conn = test_conn(&path);
conn.execute_batch(
"ALTER TABLE flows ADD COLUMN client_name TEXT;
ALTER TABLE flows ADD COLUMN client_bundle_id TEXT;
@@ -189,7 +378,7 @@ mod tests {
)
.unwrap();
drop(conn);
let db = ProxyQueryManager::open_read_only(&path).unwrap();
let db = ProxyQueryManager::open_read_only(&path, Some(&test_key())).unwrap();
let filter =
crate::flows::FlowFilter { app: Some(vec!["curl".into()]), ..Default::default() };
let (page, total, matching) = crate::flows::page(&db, &filter).unwrap();
@@ -219,10 +408,10 @@ mod tests {
.unwrap();
drop(conn);
let db = ProxyQueryManager::new(&path).unwrap();
let db = ProxyQueryManager::new(&path, &crate::db::test_key()).unwrap();
drop(db);
let conn = Connection::open(&path).unwrap();
let conn = test_conn(&path);
let tables: Vec<String> = conn
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name")
.unwrap()
@@ -312,9 +501,9 @@ mod tests {
.unwrap();
drop(conn);
drop(ProxyQueryManager::new(&path).unwrap());
drop(ProxyQueryManager::new(&path, &crate::db::test_key()).unwrap());
let conn = Connection::open(&path).unwrap();
let conn = test_conn(&path);
assert!(columns(&conn, "flows").contains(&"client_cwd".to_string()));
let applied: Vec<String> = conn
.prepare("SELECT version FROM _sqlx_migrations ORDER BY version")
@@ -329,8 +518,9 @@ mod tests {
drop(conn);
// The stream and its messages come through, and clearing then deleting takes both.
let db = ProxyQueryManager::new(&path).unwrap();
let blobs = yaak_models::init_blob_store(dir.path().join("blobs.sqlite")).unwrap();
let db = ProxyQueryManager::new(&path, &crate::db::test_key()).unwrap();
let blobs = crate::db::open_blobs(&dir.path().join("blobs.sqlite"), &crate::db::test_key())
.unwrap();
let listed = crate::flows::list(&db, &Default::default()).unwrap();
assert_eq!(listed.len(), 1);
assert_eq!((listed[0].id.as_str(), listed[0].message_count), ("fl_ws", 2));
+23 -10
View File
@@ -753,7 +753,8 @@ mod tests {
fn db_of(flows: Vec<Flow>) -> (tempfile::TempDir, ProxyQueryManager) {
use yaak_database::UpdateSource;
let dir = tempfile::tempdir().unwrap();
let db = ProxyQueryManager::new(&dir.path().join("proxy.db")).unwrap();
let db =
ProxyQueryManager::new(&dir.path().join("proxy.db"), &crate::db::test_key()).unwrap();
for (i, flow) in flows.into_iter().enumerate() {
let started_at = chrono::DateTime::from_timestamp(i as i64, 0).unwrap().naive_utc();
let flow = Flow { id: format!("fl_{i:03}"), started_at, ..flow };
@@ -964,7 +965,8 @@ mod tests {
fn after_follows_flows_as_they_are_recorded() {
use yaak_database::UpdateSource;
let dir = tempfile::tempdir().unwrap();
let db = ProxyQueryManager::new(&dir.path().join("proxy.db")).unwrap();
let db =
ProxyQueryManager::new(&dir.path().join("proxy.db"), &crate::db::test_key()).unwrap();
let at = |s| chrono::DateTime::from_timestamp(s, 0).unwrap().naive_utc();
// A slow flow that started first but ended, and so was written, after a quick one.
for (id, started) in [("fl_quick", 20), ("fl_slow", 10), ("fl_next", 30)] {
@@ -986,7 +988,8 @@ mod tests {
fn before_pages_back_through_older_flows() {
use yaak_database::UpdateSource;
let dir = tempfile::tempdir().unwrap();
let db = ProxyQueryManager::new(&dir.path().join("proxy.db")).unwrap();
let db =
ProxyQueryManager::new(&dir.path().join("proxy.db"), &crate::db::test_key()).unwrap();
let at = |s| chrono::DateTime::from_timestamp(s, 0).unwrap().naive_utc();
// Two started together, which their ids order.
for (id, started) in [
@@ -1033,8 +1036,10 @@ mod tests {
use yaak_database::UpdateSource;
use yaak_models::blob_manager::BodyChunkWriter;
let dir = tempfile::tempdir().unwrap();
let db = ProxyQueryManager::new(&dir.path().join("proxy.db")).unwrap();
let blobs = yaak_models::init_blob_store(dir.path().join("blobs.sqlite")).unwrap();
let db =
ProxyQueryManager::new(&dir.path().join("proxy.db"), &crate::db::test_key()).unwrap();
let blobs = crate::db::open_blobs(&dir.path().join("blobs.sqlite"), &crate::db::test_key())
.unwrap();
let write_body = |id: &str, which| {
let mut writer = BodyChunkWriter::new(blobs.clone(), Flow::body_id(id, which));
writer.write(b"body").unwrap();
@@ -1079,7 +1084,11 @@ mod tests {
assert_eq!(page(&db, &narrowed).unwrap().1, 1);
// Bodies that fail to delete keep their flows, to delete again.
let read_only = crate::db::open_blobs_read_only(&dir.path().join("blobs.sqlite")).unwrap();
let read_only = crate::db::open_blobs_read_only(
&dir.path().join("blobs.sqlite"),
Some(&crate::db::test_key()),
)
.unwrap();
assert!(delete_cleared(&db, &read_only, 2, "cln_a").is_err());
assert_eq!(rows(), 4);
@@ -1117,8 +1126,10 @@ mod tests {
fn one_cleaner_at_a_time_and_never_a_reused_rowid() {
use yaak_database::UpdateSource;
let dir = tempfile::tempdir().unwrap();
let db = ProxyQueryManager::new(&dir.path().join("proxy.db")).unwrap();
let blobs = yaak_models::init_blob_store(dir.path().join("blobs.sqlite")).unwrap();
let db =
ProxyQueryManager::new(&dir.path().join("proxy.db"), &crate::db::test_key()).unwrap();
let blobs = crate::db::open_blobs(&dir.path().join("blobs.sqlite"), &crate::db::test_key())
.unwrap();
let write_flow = |id: &str| {
let flow = Flow { id: id.into(), ..Default::default() };
db.with_tx(|tx| tx.upsert(&flow, &UpdateSource::Background)).unwrap().0
@@ -1169,7 +1180,8 @@ mod tests {
fn numbers_flows_by_rowid_and_keeps_it_on_update() {
use yaak_database::UpdateSource;
let dir = tempfile::tempdir().unwrap();
let db = ProxyQueryManager::new(&dir.path().join("proxy.db")).unwrap();
let db =
ProxyQueryManager::new(&dir.path().join("proxy.db"), &crate::db::test_key()).unwrap();
let write = |flow: &Flow| db.with_tx(|tx| tx.upsert(flow, &UpdateSource::Background));
let (a, _) = write(&Flow { id: "fl_a".into(), ..Default::default() }).unwrap();
let (b, _) = write(&Flow { id: "fl_b".into(), ..Default::default() }).unwrap();
@@ -1186,7 +1198,8 @@ mod tests {
fn filters_by_app_name_folder_site_and_system() {
use yaak_database::UpdateSource;
let dir = tempfile::tempdir().unwrap();
let db = ProxyQueryManager::new(&dir.path().join("proxy.db")).unwrap();
let db =
ProxyQueryManager::new(&dir.path().join("proxy.db"), &crate::db::test_key()).unwrap();
let flows = [
Flow {
id: "fl_slack".into(),
+2 -1
View File
@@ -469,7 +469,8 @@ mod tests {
};
let dir = tempfile::tempdir().unwrap();
let db = ProxyQueryManager::new(&dir.path().join("proxy.db")).unwrap();
let db =
ProxyQueryManager::new(&dir.path().join("proxy.db"), &crate::db::test_key()).unwrap();
let har = export(&db, &blobs, &[flow], 1024 * 1024).await.unwrap();
let entry = &har.log.entries[0];
let text_kept = entry.response.content.text.as_deref().unwrap();
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,531 @@
//! Captured traffic encrypted at rest: `proxy.db` and `blobs.sqlite` with SQLCipher, keyed from
//! a data key the master key in the keychain wraps, and `ca.key` with the data key.
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use yaak_crypto::data_key::DataKey;
use yaak_database::UpdateSource;
use yaak_database::encryption::{FileState, apply_key, file_state};
use yaak_models::blob_manager::BodyChunkWriter;
use yaak_proxy::CaStore;
use yaak_proxy_lib::bodies::read_raw_body;
use yaak_proxy_lib::client::ProxyConnection;
use yaak_proxy_lib::db::{BLOBS_DB, DATA_KEY, PROXY_DB, database_key};
use yaak_proxy_lib::flows::{self, FlowFilter};
use yaak_proxy_lib::models::{Flow, FlowBody, ProxyHeader};
use yaak_proxy_lib::settings::ProxySettings;
use yaak_proxy_lib::{MasterKeySource, ProxyCtx};
use yaak_rpc::RpcEventEmitter;
const HEADER_SECRET: &str = "sk_live_HEADERSECRET123";
const QUERY_SECRET: &str = "QUERYSECRET456";
const BODY_SECRET: &str = "BODYSECRET789";
const FLOWS: usize = 3_000;
fn open_ctx(dir: &Path, keys: MasterKeySource) -> ProxyCtx {
let settings = ProxySettings { port: 0, system_proxy_at_launch: false, ..Default::default() };
settings.save(&dir.join("settings.json")).unwrap();
let (events, _rx) = RpcEventEmitter::new();
ProxyCtx::new(dir, events, keys).unwrap()
}
/// `n` flows, each with a bearer token in a header, an API key in its query and a token in a
/// 1 KB response body.
fn write_flows(ctx: &ProxyCtx, n: usize) {
let flows: Vec<Flow> = (0..n)
.map(|i| Flow {
id: format!("fl_{i:05}"),
method: if i % 3 == 0 { "POST" } else { "GET" }.into(),
url: format!("https://api.example.com/v1/items/{i}?api_key={QUERY_SECRET}"),
status: Some(if i % 10 == 0 { 404 } else { 200 }),
content_type: Some("application/json".into()),
request_headers: vec![ProxyHeader {
name: "authorization".into(),
value: format!("Bearer {HEADER_SECRET}"),
}],
started_at: chrono::DateTime::from_timestamp(1_700_000_000 + i as i64, 0)
.unwrap()
.naive_utc(),
elapsed_ms: Some(5),
..Default::default()
})
.collect();
ctx.db()
.unwrap()
.with_tx(|tx| {
flows.iter().try_for_each(|f| tx.upsert(f, &UpdateSource::Background).map(|_| ()))
})
.unwrap();
for flow in &flows {
let mut writer =
BodyChunkWriter::new(ctx.blobs().unwrap(), Flow::body_id(&flow.id, FlowBody::Response));
let body = format!("{{\"token\":\"{BODY_SECRET}\",\"pad\":\"{}\"}}", "x".repeat(1000));
writer.write(body.as_bytes()).unwrap();
writer.finish().unwrap();
}
}
/// Every file under `dir`, recursively.
fn files(dir: &Path) -> Vec<PathBuf> {
let mut found = vec![];
for entry in std::fs::read_dir(dir).unwrap() {
let path = entry.unwrap().path();
if path.is_dir() {
found.extend(files(&path));
} else {
found.push(path);
}
}
found
}
/// The files under `dir` that hold `needle` as it is. One gone by the time it is read, a WAL
/// its last connection closed meanwhile, holds nothing.
fn holding(dir: &Path, needle: &str) -> Vec<PathBuf> {
files(dir)
.into_iter()
.filter(|p| {
std::fs::read(p)
.map(|b| b.windows(needle.len()).any(|w| w == needle.as_bytes()))
.unwrap_or(false)
})
.collect()
}
fn assert_no_plaintext(dir: &Path) {
for secret in [HEADER_SECRET, QUERY_SECRET, BODY_SECRET, "PRIVATE KEY"] {
assert_eq!(holding(dir, secret), Vec::<PathBuf>::new(), "{secret} in plaintext");
}
}
/// `dir`'s databases as a build from before encryption left them: the same tables and rows,
/// plain, with a plaintext CA key.
fn make_plain(dir: &Path, keys: &MasterKeySource) {
let data_key = DataKey::load_file(keys, &dir.join(DATA_KEY)).unwrap().unwrap();
let key = database_key(&data_key);
for name in [PROXY_DB, BLOBS_DB] {
let path = dir.join(name);
let plain = dir.join(format!("{name}.plain-export"));
let conn = rusqlite::Connection::open(&path).unwrap();
apply_key(&conn, &key).unwrap();
conn.execute("ATTACH DATABASE ?1 AS plain KEY ''", [plain.to_string_lossy()]).unwrap();
conn.query_row("SELECT sqlcipher_export('plain')", [], |_| Ok(())).unwrap();
conn.execute_batch("DETACH DATABASE plain").unwrap();
drop(conn);
for suffix in ["", "-wal", "-shm"] {
let _ = std::fs::remove_file(dir.join(format!("{name}{suffix}")));
}
std::fs::rename(&plain, &path).unwrap();
assert_eq!(file_state(&path).unwrap(), FileState::Plain);
}
std::fs::remove_file(dir.join(DATA_KEY)).unwrap();
// The CA as it was written before: its key in plaintext PEM.
let ca_dir = dir.join("ca");
std::fs::remove_dir_all(&ca_dir).ok();
CaStore::load_or_create(&ca_dir).unwrap();
}
/// A data directory with `FLOWS` flows in plain databases, as before encryption.
fn plain_capture() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
let keys = MasterKeySource::memory();
let ctx = open_ctx(dir.path(), keys.clone());
write_flows(&ctx, FLOWS);
drop(ctx);
make_plain(dir.path(), &keys);
assert!(!holding(dir.path(), BODY_SECRET).is_empty());
assert!(!holding(dir.path(), "PRIVATE KEY").is_empty());
dir
}
#[test]
fn stores_nothing_in_plaintext_and_reads_back_with_the_key() {
let dir = tempfile::tempdir().unwrap();
let keys = MasterKeySource::memory();
let ctx = open_ctx(dir.path(), keys.clone());
ctx.load_ca().unwrap();
write_flows(&ctx, 20);
// Through the WAL and checkpointed alike.
assert_no_plaintext(dir.path());
for name in [PROXY_DB, BLOBS_DB] {
assert_eq!(file_state(&dir.path().join(name)).unwrap(), FileState::Encrypted);
}
drop(ctx);
assert_no_plaintext(dir.path());
// Opened again with the key, everything is there.
let ctx = open_ctx(dir.path(), keys.clone());
assert_eq!(flows::count(&ctx.db().unwrap()).unwrap(), 20);
let body = read_raw_body(&ctx.blobs().unwrap(), "fl_00007", FlowBody::Response).unwrap();
assert!(String::from_utf8(body).unwrap().contains(BODY_SECRET));
drop(ctx);
// Read offline, as the CLI does, with the same keychain.
let offline = ProxyConnection::Offline(
yaak_proxy_lib::client::OfflineStore::open(dir.path(), &keys).unwrap(),
);
let rt = tokio::runtime::Runtime::new().unwrap();
let listed = rt.block_on(offline.flows(&FlowFilter::default())).unwrap();
assert_eq!(listed.len(), 20);
// Without the master key that wraps it, nothing opens.
let err = yaak_proxy_lib::client::OfflineStore::open(dir.path(), &MasterKeySource::memory())
.err()
.unwrap();
assert!(err.contains("The encryption key is missing from the keychain"), "{err}");
let err = yaak_proxy_lib::client::OfflineStore::open(
dir.path(),
&MasterKeySource::refusing("User canceled the operation"),
)
.err()
.unwrap();
assert!(err.contains("couldn't read its key from the keychain"), "{err}");
// Nor without any key at all.
let raw = rusqlite::Connection::open(dir.path().join(PROXY_DB)).unwrap();
let err = raw.query_row("SELECT count(*) FROM flows", [], |r| r.get::<_, i64>(0)).unwrap_err();
assert!(err.to_string().contains("file is not a database"), "{err}");
}
/// A capture from before encryption, 3,000 flows with bodies, is converted on first launch:
/// the engine starts once it is done, the app hears how far it has got, nothing is lost and no
/// secret is left readable in any file.
#[test]
fn converts_a_plain_capture_on_first_launch() {
let dir = plain_capture();
let pem_before = std::fs::read_to_string(dir.path().join("ca/ca.pem")).unwrap();
let keys = MasterKeySource::memory();
// Flows deleted since, as a clear leaves gaps in their numbers.
let plain = rusqlite::Connection::open(dir.path().join(PROXY_DB)).unwrap();
let deleted = plain.execute("DELETE FROM flows WHERE id < 'fl_00010'", []).unwrap();
let number: i64 =
plain.query_row("SELECT rowid FROM flows WHERE id = 'fl_01234'", [], |r| r.get(0)).unwrap();
drop(plain);
let ctx = open_ctx(dir.path(), keys.clone());
let seen = Arc::new(Mutex::new(Vec::<f64>::new()));
let watching = Arc::new(AtomicBool::new(true));
let watcher = std::thread::spawn({
let (ctx, seen, watching) = (ctx.clone(), seen.clone(), watching.clone());
move || {
while watching.load(Ordering::Relaxed) {
if let Some(fraction) = ctx.encrypting() {
seen.lock().unwrap().push(fraction);
}
std::thread::sleep(Duration::from_micros(200));
}
}
});
// Waits for the conversion, then starts.
ctx.start().unwrap();
watching.store(false, Ordering::Relaxed);
watcher.join().unwrap();
assert!(ctx.port().is_some());
assert_eq!(ctx.encrypting(), None);
let seen = seen.lock().unwrap();
assert!(!seen.is_empty(), "no progress shown");
assert!(seen.iter().all(|f| (0.0..=1.0).contains(f)), "{seen:?}");
for name in [PROXY_DB, BLOBS_DB] {
assert_eq!(file_state(&dir.path().join(name)).unwrap(), FileState::Encrypted);
}
assert!(dir.path().join(DATA_KEY).exists());
let leftovers: Vec<_> = files(dir.path())
.into_iter()
.filter(|p| {
let name = p.file_name().unwrap().to_string_lossy();
name.ends_with("-plain") || name.ends_with("-encrypting")
})
.collect();
assert!(leftovers.is_empty(), "{leftovers:?}");
// Every flow and body came across, each flow under its own number.
assert_eq!(flows::count(&ctx.db().unwrap()).unwrap(), FLOWS - deleted);
let body = read_raw_body(&ctx.blobs().unwrap(), "fl_02999", FlowBody::Response).unwrap();
assert!(String::from_utf8(body).unwrap().contains(BODY_SECRET));
let flow = flows::get(&ctx.db().unwrap(), "fl_01234").unwrap();
assert_eq!(flow.request_headers[0].value, format!("Bearer {HEADER_SECRET}"));
assert_eq!(flow.number, number);
// The same CA, so it stays trusted, its key now encrypted.
let ca = ctx.load_ca().unwrap();
assert_eq!(ca.pem(), pem_before);
ctx.stop().unwrap();
drop(ctx);
assert_no_plaintext(dir.path());
}
/// Search, the `q` filter, paging and the sidebar's filters read the encrypted database as
/// they read a plain one, and a full-text table works inside it.
#[test]
fn search_reaches_inside_the_encrypted_database() {
let dir = plain_capture();
let ctx = open_ctx(dir.path(), MasterKeySource::memory());
ctx.load_ca().unwrap();
let db = ctx.db().unwrap();
// Query strings are searchable, being inside the encrypted file.
let q = |q: &str| FlowFilter { q: Some(q.into()), limit: Some(50), ..Default::default() };
let (page, total, matching) = flows::page(&db, &q(QUERY_SECRET)).unwrap();
assert_eq!((page.len(), total, matching), (50, FLOWS, Some(FLOWS)));
let (page, _, matching) = flows::page(&db, &q("items/2999")).unwrap();
assert_eq!((page[0].id.as_str(), matching), ("fl_02999", Some(1)));
let posts_not_found = FlowFilter {
method: Some("post".into()),
status: Some("4xx".into()),
limit: Some(1),
..Default::default()
};
let (_, _, matching) = flows::page(&db, &posts_not_found).unwrap();
assert_eq!(matching, Some((0..FLOWS).filter(|i| i % 3 == 0 && i % 10 == 0).count()));
// Paging back from the newest, as the list does.
let mut before = None;
let mut seen = 0;
loop {
let filter = FlowFilter { limit: Some(500), before: before.clone(), ..Default::default() };
let (page, _, _) = flows::page(&db, &filter).unwrap();
if page.is_empty() {
break;
}
seen += page.len();
before = page.last().map(|f| f.id.clone());
}
assert_eq!(seen, FLOWS);
// The sidebar's site and path filters, which run the database's own functions.
let under = |u: &str| FlowFilter { under: Some(vec![u.into()]), ..Default::default() };
assert_eq!(flows::list(&db, &under("api.example.com")).unwrap().len(), FLOWS);
assert_eq!(flows::list(&db, &under("*.example.com")).unwrap().len(), FLOWS);
assert_eq!(flows::list(&db, &under("api.example.com/v1/items/42")).unwrap().len(), 1);
// A full-text table, as body search will want, made and searched in the encrypted file.
let hits: i64 = db
.with_tx(|tx| {
let conn = tx.conn().resolve();
conn.execute_batch(&format!(
"CREATE VIRTUAL TABLE fts_probe USING fts5(body);
INSERT INTO fts_probe VALUES ('{{\"token\":\"{BODY_SECRET}\"}}'), ('nothing');"
))?;
let hits = conn.query_row(
&format!(
"SELECT count(*) FROM fts_probe WHERE fts_probe MATCH '\"{BODY_SECRET}\"'"
),
[],
|r| r.get(0),
)?;
conn.execute_batch("DROP TABLE fts_probe")?;
Ok::<_, yaak_database::Error>(hits)
})
.unwrap();
assert_eq!(hits, 1);
drop((db, ctx));
assert_no_plaintext(dir.path());
}
/// Two first launches at once, as the app and the CLI daemon might: one data key, one
/// conversion, one CA, and both read everything.
#[test]
fn concurrent_first_unlocks_agree() {
let dir = plain_capture();
let keys = MasterKeySource::memory();
let barrier = std::sync::Barrier::new(2);
let pems: Vec<String> = std::thread::scope(|s| {
let handles: Vec<_> = (0..2)
.map(|_| {
s.spawn(|| {
barrier.wait();
let ctx = open_ctx(dir.path(), keys.clone());
let pem = ctx.load_ca().unwrap().pem().to_string();
assert_eq!(flows::count(&ctx.db().unwrap()).unwrap(), FLOWS);
pem
})
})
.collect();
handles.into_iter().map(|h| h.join().unwrap()).collect()
});
assert_eq!(pems[0], pems[1]);
let ctx = open_ctx(dir.path(), keys);
assert_eq!(flows::count(&ctx.db().unwrap()).unwrap(), FLOWS);
drop(ctx);
assert_no_plaintext(dir.path());
}
/// A keychain that refuses leaves capture refused, saying why, and touches nothing: no key or
/// database is made, and traffic from before stays as it was until the key can be read.
#[test]
fn a_refused_key_refuses_capture() {
let refusing = MasterKeySource::refusing("User canceled the operation");
let empty = tempfile::tempdir().unwrap();
let ctx = open_ctx(empty.path(), refusing.clone());
let err = ctx.start().unwrap_err();
assert_eq!(
err,
"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."
);
assert_eq!(ctx.start_error().as_deref(), Some(err.as_str()));
assert_eq!(ctx.port(), None);
assert!(ctx.load_ca().is_err());
// Nothing is open: what reads or writes captured traffic is refused, saying why.
let locked = ctx.db().err().unwrap();
assert_eq!(locked.to_string(), format!("Traffic is locked: {err}"));
assert!(ctx.blobs().is_err());
let lock = ctx.locked().unwrap();
assert!(lock.keychain);
assert_eq!(lock.message, locked.to_string());
let made: Vec<_> = files(empty.path())
.into_iter()
.filter(|p| p.extension().is_none_or(|e| e != "lock") && !p.ends_with("settings.json"))
.collect();
assert!(made.is_empty(), "{made:?}");
let dir = plain_capture();
let ctx = open_ctx(dir.path(), refusing);
let refused = ctx.start().unwrap_err();
assert_eq!(ctx.encrypting(), None);
for name in [PROXY_DB, BLOBS_DB] {
assert_eq!(file_state(&dir.path().join(name)).unwrap(), FileState::Plain);
}
// Clear is refused too, so the captured flows are not left to come back.
assert_eq!(ctx.clear_flows().unwrap_err(), format!("Traffic is locked: {refused}"));
// Retrying with access allowed converts it then, every flow still there.
drop(ctx);
let ctx = open_ctx(dir.path(), MasterKeySource::memory());
ctx.start().unwrap();
assert_eq!(flows::count(&ctx.db().unwrap()).unwrap(), FLOWS);
// And clears them now.
assert_eq!(ctx.clear_flows().unwrap(), FLOWS);
assert_eq!(flows::count(&ctx.db().unwrap()).unwrap(), 0);
}
/// A build from before encryption that owns the engine, a CLI daemon, keeps its plain databases
/// until it lets go: converting them under it would pull them away from its connections.
#[cfg(unix)]
#[test]
fn waits_for_an_older_owner_before_converting() {
use yaak_proxy_lib::control::{ControlInfo, Owner, control_path};
let dir = plain_capture();
let ca_key = std::fs::read(dir.path().join("ca/ca.key")).unwrap();
// Another live process owns it: this one's parent, answering on a port of its own.
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let owner = ControlInfo {
pid: std::os::unix::process::parent_id(),
control_port: listener.local_addr().unwrap().port(),
proxy_port: None,
token: "tok".into(),
owner: Owner::Cli,
started_at: chrono::Utc::now(),
};
let control = control_path(dir.path());
std::fs::write(&control, serde_json::to_string(&owner).unwrap()).unwrap();
let ctx = open_ctx(dir.path(), MasterKeySource::memory());
assert!(ctx.load_ca().err().unwrap().contains("can't be encrypted yet"));
assert!(ctx.start().unwrap_err().starts_with("The proxy is already running in the yaak CLI"));
for name in [PROXY_DB, BLOBS_DB] {
assert_eq!(file_state(&dir.path().join(name)).unwrap(), FileState::Plain);
}
assert_eq!(std::fs::read(dir.path().join("ca/ca.key")).unwrap(), ca_key, "CA left as it was");
// Once it has let go, starting converts them.
drop(listener);
std::fs::remove_file(&control).unwrap();
ctx.start().unwrap();
assert_eq!(flows::count(&ctx.db().unwrap()).unwrap(), FLOWS);
for name in [PROXY_DB, BLOBS_DB] {
assert_eq!(file_state(&dir.path().join(name)).unwrap(), FileState::Encrypted);
}
}
/// The data key's file gone from beside encrypted databases is refused too, rather than a new
/// key being made that could open nothing and would replace the trusted CA.
#[test]
fn a_lost_data_key_is_not_replaced() {
let dir = tempfile::tempdir().unwrap();
let keys = MasterKeySource::memory();
let ctx = open_ctx(dir.path(), keys.clone());
let pem = ctx.load_ca().unwrap().pem().to_string();
write_flows(&ctx, 3);
drop(ctx);
std::fs::remove_file(dir.path().join(DATA_KEY)).unwrap();
let ctx = open_ctx(dir.path(), keys);
let err = ctx.start().unwrap_err();
assert!(err.starts_with("The proxy's captured traffic is encrypted, but its key is missing"));
assert!(!dir.path().join(DATA_KEY).exists());
assert_eq!(std::fs::read_to_string(dir.path().join("ca/ca.pem")).unwrap(), pem);
assert!(ctx.db().is_err(), "locked");
assert!(!ctx.locked().unwrap().keychain, "not the keychain's doing");
}
/// While locked, every RPC that reads or writes captured traffic is refused with why, and so is
/// the control API, with 503; the proxy's state says so, and starting, once the key can be
/// read, opens the databases.
#[tokio::test(flavor = "multi_thread")]
async fn locked_rpcs_and_control_api_refuse() {
let dir = plain_capture();
let ctx = open_ctx(dir.path(), MasterKeySource::refusing("User canceled the operation"));
let router = yaak_proxy_lib::build_router();
let rpc = |cmd: &'static str, payload: serde_json::Value| {
let (router, ctx) = (&router, ctx.clone());
async move { router.dispatch(cmd, payload, &ctx).await.map_err(|e| e.message) }
};
let flow_id = "fl_00001";
for (cmd, payload) in [
("list_flows", serde_json::json!({})),
("clear_flows", serde_json::json!({})),
("list_messages", serde_json::json!({ "flowId": flow_id })),
("get_flow_body", serde_json::json!({ "flowId": flow_id, "which": "response" })),
("preview_save", serde_json::json!({ "flowId": flow_id })),
("list_grpc_schemas", serde_json::json!({})),
("remove_grpc_schema", serde_json::json!({ "id": "gs_1" })),
("execute_action", serde_json::json!({ "scope": "global", "action": "flows_clear" })),
] {
let err = rpc(cmd, payload).await.unwrap_err();
assert!(err.starts_with("Traffic is locked: "), "{cmd}: {err}");
}
let state = rpc("get_proxy_state", serde_json::json!({})).await.unwrap();
assert_eq!(state["locked"]["keychain"], true);
assert!(state["locked"]["message"].as_str().unwrap().starts_with("Traffic is locked: "));
ctx.serve_control_api().unwrap();
let info = ctx.control_info().unwrap();
let http = reqwest::Client::builder().no_proxy().build().unwrap();
for path in ["/flows", &format!("/flows/{flow_id}"), "/har"] {
let resp = http
.get(format!("{}{path}", info.base_url()))
.bearer_auth(&info.token)
.send()
.await
.unwrap();
assert_eq!(resp.status(), 503, "{path}");
assert!(resp.text().await.unwrap().contains("Traffic is locked: "), "{path}");
}
let cleared = http
.post(format!("{}/flows/clear", info.base_url()))
.bearer_auth(&info.token)
.send()
.await
.unwrap();
assert_eq!(cleared.status(), 503);
// The status still answers, saying why.
let status: serde_json::Value = http
.get(format!("{}/status", info.base_url()))
.bearer_auth(&info.token)
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(status["flowCount"], 0);
assert!(status["locked"].as_str().unwrap().starts_with("Traffic is locked: "));
ctx.stop_control_api();
// Nothing was cleared or converted meanwhile.
for name in [PROXY_DB, BLOBS_DB] {
assert_eq!(file_state(&dir.path().join(name)).unwrap(), FileState::Plain);
}
}
+23 -12
View File
@@ -20,7 +20,7 @@ use yaak_proxy_lib::flows::{BodyEncoding, FlowFilter};
use yaak_proxy_lib::models::{Flow, FlowBody};
use yaak_proxy_lib::save::SaveFlowRequest;
use yaak_proxy_lib::settings::ProxySettings;
use yaak_proxy_lib::{ProxyCtx, ProxyState};
use yaak_proxy_lib::{MasterKeySource, ProxyCtx, ProxyState};
use yaak_rpc::RpcEventEmitter;
async fn route(_req: Request<Incoming>) -> Result<Response<Full<Bytes>>, Infallible> {
@@ -44,12 +44,18 @@ async fn spawn_upstream() -> SocketAddr {
addr
}
/// The keychain every context and reader here shares, as the app and the CLI share one.
fn keys() -> MasterKeySource {
static KEYS: std::sync::OnceLock<MasterKeySource> = std::sync::OnceLock::new();
KEYS.get_or_init(MasterKeySource::memory).clone()
}
/// A context over `dir` whose engine picks a free port, saving into Yaak's data in `client`.
fn ctx(dir: &Path, client: &Path) -> ProxyCtx {
let settings = ProxySettings { port: 0, system_proxy_at_launch: false, ..Default::default() };
settings.save(&dir.join("settings.json")).unwrap();
let (events, _rx) = RpcEventEmitter::new();
ProxyCtx::new(dir, events).unwrap().with_client_data_dir(client)
ProxyCtx::new(dir, events, keys()).unwrap().with_client_data_dir(client)
}
/// Sends one POST through the engine and waits for its flow.
@@ -72,7 +78,7 @@ async fn capture_flow(ctx: &ProxyCtx) -> Flow {
let deadline = Instant::now() + Duration::from_secs(5);
loop {
let flows = ctx.db.with_conn(|db| db.find_all::<Flow>().unwrap());
let flows = ctx.db().unwrap().with_conn(|db| db.find_all::<Flow>().unwrap());
if let Some(flow) = flows.into_iter().find(|f| f.url == url) {
return flow;
}
@@ -117,7 +123,8 @@ async fn round_trip() {
let wrong = http.get(&url).bearer_auth("not-the-token").send().await.unwrap();
assert_eq!(wrong.status(), 401);
let ProxyConnection::Live(client) = ProxyConnection::connect(dir.path()).unwrap() else {
let ProxyConnection::Live(client) = ProxyConnection::connect(dir.path(), &keys()).unwrap()
else {
panic!("expected the live owner");
};
let status = client.status().await.unwrap();
@@ -268,7 +275,7 @@ async fn offline_reads_proxy_db() {
flow
};
let connection = ProxyConnection::connect(dir.path()).unwrap();
let connection = ProxyConnection::connect(dir.path(), &keys()).unwrap();
assert!(matches!(connection, ProxyConnection::Offline(_)));
let flows = connection.flows(&FlowFilter::default()).await.unwrap();
assert_eq!(flows.iter().map(|f| &f.id).collect::<Vec<_>>(), vec![&flow.id]);
@@ -280,7 +287,7 @@ async fn offline_reads_proxy_db() {
assert_eq!(connection.har(&posts).await.unwrap().log.entries.len(), 1);
let empty = tempfile::tempdir().unwrap();
let err = ProxyConnection::connect(empty.path()).err().unwrap();
let err = ProxyConnection::connect(empty.path(), &keys()).err().unwrap();
assert!(err.starts_with("No captured traffic yet"), "{err}");
}
@@ -365,7 +372,7 @@ async fn save_writes_the_request_into_yaak() {
assert!(source.contains("sync"), "{source}");
// The flow remembers what it was saved as.
let flow = yaak_proxy_lib::flows::get(&ctx.db, &flow.id).unwrap();
let flow = yaak_proxy_lib::flows::get(&ctx.db().unwrap(), &flow.id).unwrap();
assert_eq!(flow.saved_request_id, Some(saved.request.id));
// Without a name, through the control API as the CLI saves, it stays unnamed, as Yaak
@@ -564,7 +571,8 @@ async fn the_save_dialog_reads_workspaces_previews_and_saves() {
(workspace.id.as_str(), Some(folder.id.as_str()))
);
let stored = ctx
.db
.db()
.unwrap()
.with_conn(|db| db.find_one::<Flow>(yaak_proxy_lib::models::FlowIden::Id, &flow.id))
.unwrap();
assert_eq!(stored.saved_request_id.as_deref(), Some(request_id));
@@ -636,7 +644,7 @@ async fn flows_name_the_app_that_sent_them() {
let deadline = Instant::now() + Duration::from_secs(5);
let flow = loop {
let flows = ctx.db.with_conn(|db| db.find_all::<Flow>().unwrap());
let flows = ctx.db().unwrap().with_conn(|db| db.find_all::<Flow>().unwrap());
if let Some(flow) = flows.into_iter().find(|f| f.url == url) {
break flow;
}
@@ -650,7 +658,7 @@ async fn flows_name_the_app_that_sent_them() {
// The search and the sidebar's Apps group find it by its path.
let filter = FlowFilter { app: Some(vec!["/usr/bin/curl".into()]), ..Default::default() };
assert_eq!(yaak_proxy_lib::flows::list(&ctx.db, &filter).unwrap().len(), 1);
assert_eq!(yaak_proxy_lib::flows::list(&ctx.db().unwrap(), &filter).unwrap().len(), 1);
}
/// Sends one GET through the engine, without waiting for a flow.
@@ -682,9 +690,12 @@ async fn pausing_stores_no_flows_and_resuming_stores_them_again() {
assert_eq!((state["state"].as_str(), &state["recording"]), (Some("running"), &json!(false)));
let paused = send_through(&ctx, "/while-paused").await;
tokio::time::sleep(Duration::from_millis(300)).await;
let flows = ctx.db.with_conn(|db| db.find_all::<Flow>().unwrap());
let flows = ctx.db().unwrap().with_conn(|db| db.find_all::<Flow>().unwrap());
assert!(flows.iter().all(|f| f.url != paused), "a flow was stored while paused");
assert!(ctx.blobs.connect().list_body_ids().unwrap().is_empty(), "a body was stored");
assert!(
ctx.blobs().unwrap().connect().list_body_ids().unwrap().is_empty(),
"a body was stored"
);
// Resumed through the control API, as the CLI would.
let status = client.set_recording(true).await.unwrap();
+105 -14
View File
@@ -16,10 +16,10 @@ use hyper::{Request, Response};
use hyper_util::rt::TokioIo;
use serde_json::{Value, json};
use tokio::net::TcpListener;
use yaak_proxy_lib::ProxyCtx;
use yaak_proxy_lib::bodies::{DecodedBody, read_body, read_raw_body};
use yaak_proxy_lib::models::{Flow, FlowBody};
use yaak_proxy_lib::settings::ProxySettings;
use yaak_proxy_lib::{MasterKeySource, ProxyCtx};
use yaak_rpc::RpcEventEmitter;
const CAPTURE_LIMIT: usize = 1024;
@@ -95,7 +95,7 @@ async fn spawn_upstream() -> SocketAddr {
async fn wait_for_flow(ctx: &ProxyCtx, url: &str) -> Flow {
let deadline = Instant::now() + Duration::from_secs(5);
loop {
let flows = ctx.db.with_conn(|db| db.find_all::<Flow>().unwrap());
let flows = ctx.db().unwrap().with_conn(|db| db.find_all::<Flow>().unwrap());
if let Some(flow) = flows.into_iter().find(|f| f.url == url) {
return flow;
}
@@ -116,7 +116,7 @@ async fn flow_is_stored_with_its_bodies() {
settings.save(&dir.path().join("settings.json")).unwrap();
let (events, _event_rx) = RpcEventEmitter::new();
let ctx = ProxyCtx::new(dir.path(), events).unwrap();
let ctx = ProxyCtx::new(dir.path(), events, MasterKeySource::memory()).unwrap();
ctx.start().unwrap();
let port = ctx.port().unwrap();
assert!(dir.path().join("ca/ca.pem").exists());
@@ -149,10 +149,23 @@ async fn flow_is_stored_with_its_bodies() {
assert!(flow.request_headers.iter().any(|h| h.name == "content-length"));
assert_eq!(flow.error, None);
assert_eq!(read_raw_body(&ctx.blobs, &flow.id, FlowBody::Request).unwrap(), b"hello proxy");
assert_eq!(read_raw_body(&ctx.blobs, &flow.id, FlowBody::Response).unwrap(), compressed);
let decoded =
read_body(&ctx.db, &ctx.blobs, &flow, FlowBody::Response, CAPTURE_LIMIT).await.unwrap();
assert_eq!(
read_raw_body(&ctx.blobs().unwrap(), &flow.id, FlowBody::Request).unwrap(),
b"hello proxy"
);
assert_eq!(
read_raw_body(&ctx.blobs().unwrap(), &flow.id, FlowBody::Response).unwrap(),
compressed
);
let decoded = read_body(
&ctx.db().unwrap(),
&ctx.blobs().unwrap(),
&flow,
FlowBody::Response,
CAPTURE_LIMIT,
)
.await
.unwrap();
let expected = DecodedBody {
data: b"hello proxy".to_vec(),
stored_size: compressed.len(),
@@ -170,9 +183,15 @@ async fn flow_is_stored_with_its_bodies() {
assert_eq!(flow.request_size, 0);
assert_eq!(flow.response_size, BIG_BODY as i64);
assert!(flow.response_truncated);
let stored = read_raw_body(&ctx.blobs, &flow.id, FlowBody::Response).unwrap();
let stored = read_raw_body(&ctx.blobs().unwrap(), &flow.id, FlowBody::Response).unwrap();
assert_eq!(stored, vec![b'x'; CAPTURE_LIMIT]);
assert!(!ctx.blobs.connect().body_exists(&Flow::body_id(&flow.id, FlowBody::Request)).unwrap());
assert!(
!ctx.blobs()
.unwrap()
.connect()
.body_exists(&Flow::body_id(&flow.id, FlowBody::Request))
.unwrap()
);
ctx.stop().unwrap();
assert_eq!(ctx.port(), None);
@@ -190,7 +209,7 @@ async fn early_response_keeps_request_totals() {
settings.save(&dir.path().join("settings.json")).unwrap();
let (events, _event_rx) = RpcEventEmitter::new();
let ctx = ProxyCtx::new(dir.path(), events).unwrap();
let ctx = ProxyCtx::new(dir.path(), events, MasterKeySource::memory()).unwrap();
ctx.start().unwrap();
let port = ctx.port().unwrap();
@@ -205,7 +224,8 @@ async fn early_response_keeps_request_totals() {
let flow = wait_for_flow(&ctx, &url).await;
assert_eq!(flow.status, Some(200));
let stored = read_raw_body(&ctx.blobs, &flow.id, FlowBody::Request).unwrap().len() as i64;
let stored =
read_raw_body(&ctx.blobs().unwrap(), &flow.id, FlowBody::Request).unwrap().len() as i64;
assert!(stored > 0);
assert!(flow.request_size >= stored, "{} < {stored}", flow.request_size);
assert_eq!(flow.request_truncated, flow.request_size > stored);
@@ -223,7 +243,7 @@ async fn cancelled_flow_is_not_an_error() {
settings.save(&dir.path().join("settings.json")).unwrap();
let (events, _event_rx) = RpcEventEmitter::new();
let ctx = ProxyCtx::new(dir.path(), events).unwrap();
let ctx = ProxyCtx::new(dir.path(), events, MasterKeySource::memory()).unwrap();
ctx.start().unwrap();
let port = ctx.port().unwrap();
@@ -251,7 +271,7 @@ async fn http_version_setting_applies_to_new_connections() {
settings.save(&dir.path().join("settings.json")).unwrap();
let (events, _event_rx) = RpcEventEmitter::new();
let ctx = ProxyCtx::new(dir.path(), events).unwrap();
let ctx = ProxyCtx::new(dir.path(), events, MasterKeySource::memory()).unwrap();
ctx.start().unwrap();
let port = ctx.port().unwrap();
let ca = std::fs::read(dir.path().join("ca/ca.pem")).unwrap();
@@ -296,7 +316,7 @@ async fn body_rpcs() {
settings.save(&dir.path().join("settings.json")).unwrap();
let (events, _event_rx) = RpcEventEmitter::new();
let ctx = ProxyCtx::new(dir.path(), events).unwrap();
let ctx = ProxyCtx::new(dir.path(), events, MasterKeySource::memory()).unwrap();
ctx.start().unwrap();
let port = ctx.port().unwrap();
@@ -311,6 +331,8 @@ async fn body_rpcs() {
"systemProxyAtLaunch": false,
"httpVersion": "auto",
"startError": null,
"encrypting": null,
"locked": null,
"otherOwner": null,
})
);
@@ -393,3 +415,72 @@ async fn body_rpcs() {
let formatted = rpc(&ctx, "format_body", json!({"text": r#"{"a":[1,2]}"#})).await.unwrap();
assert_eq!(formatted, json!("{\n \"a\": [\n 1,\n 2\n ]\n}"));
}
/// Capture throughput through the engine, end to end: 3,000 requests, 16 at a time over
/// kept-alive connections, each with a 4 KB response, timed until every flow is stored, and how
/// long opening one then takes. Run in release: `cargo test --release -p yaak-proxy-lib --test
/// flow times_capture -- --ignored --nocapture`.
#[tokio::test(flavor = "multi_thread")]
#[ignore]
async fn times_capture_through_the_engine() {
const FLOWS: usize = 3_000;
const AT_ONCE: usize = 16;
let upstream = spawn_upstream().await;
let dir = tempfile::tempdir().unwrap();
let settings = ProxySettings { port: 0, system_proxy_at_launch: false, ..Default::default() };
settings.save(&dir.path().join("settings.json")).unwrap();
let (events, _rx) = RpcEventEmitter::new();
let ctx = ProxyCtx::new(dir.path(), events, MasterKeySource::memory()).unwrap();
ctx.start().unwrap();
let proxy = format!("http://127.0.0.1:{}", ctx.port().unwrap());
let client = reqwest::Client::builder()
.proxy(reqwest::Proxy::http(&proxy).unwrap())
.pool_max_idle_per_host(AT_ONCE)
.build()
.unwrap();
let started = Instant::now();
let next = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let workers: Vec<_> = (0..AT_ONCE)
.map(|_| {
let (client, next) = (client.clone(), next.clone());
tokio::spawn(async move {
loop {
let i = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
if i >= FLOWS {
return;
}
let url = format!("http://{upstream}/v1/items/{i}?api_key=sk_test_123");
let resp = client.get(&url).header("authorization", "Bearer abc").send();
assert_eq!(resp.await.unwrap().bytes().await.unwrap().len(), BIG_BODY);
}
})
})
.collect();
for worker in workers {
worker.await.unwrap();
}
let db = ctx.db().unwrap();
while yaak_proxy_lib::flows::count(&db).unwrap() < FLOWS {
tokio::time::sleep(Duration::from_millis(1)).await;
}
let capture = started.elapsed();
let ids: Vec<String> = yaak_proxy_lib::flows::list(&db, &Default::default())
.unwrap()
.into_iter()
.map(|f| f.id)
.collect();
let blobs = ctx.blobs().unwrap();
let started = Instant::now();
for id in &ids {
let flow = yaak_proxy_lib::flows::get(&db, id).unwrap();
read_raw_body(&blobs, &flow.id, FlowBody::Response).unwrap();
}
let open = started.elapsed() / ids.len() as u32;
ctx.stop().unwrap();
eprintln!(
"{FLOWS} flows through the engine in {capture:?}: {:.0} flows/s; opening one: {open:?}",
FLOWS as f64 / capture.as_secs_f64()
);
}
+15 -8
View File
@@ -22,7 +22,7 @@ use tokio::net::TcpListener;
use yaak_proxy_lib::bodies::read_raw_body;
use yaak_proxy_lib::models::{Flow, FlowBody};
use yaak_proxy_lib::settings::ProxySettings;
use yaak_proxy_lib::{GrpcConfig, ProxyCtx};
use yaak_proxy_lib::{GrpcConfig, MasterKeySource, ProxyCtx};
use yaak_rpc::RpcEventEmitter;
type BoxError = Box<dyn std::error::Error + Send + Sync>;
@@ -210,7 +210,7 @@ fn start(dir: &Path) -> (ProxyCtx, std::sync::mpsc::Receiver<yaak_rpc::RpcEvent>
};
settings.save(&dir.join("settings.json")).unwrap();
let (events, event_rx) = RpcEventEmitter::new();
let ctx = ProxyCtx::new(dir, events).unwrap().with_protoc(protoc());
let ctx = ProxyCtx::new(dir, events, MasterKeySource::memory()).unwrap().with_protoc(protoc());
ctx.start().unwrap();
(ctx, event_rx)
}
@@ -228,7 +228,7 @@ async fn rpc(ctx: &ProxyCtx, cmd: &str, payload: Value) -> Result<Value, String>
async fn wait_for(ctx: &ProxyCtx, url: &str, check: impl Fn(&Flow) -> bool) -> Flow {
let deadline = Instant::now() + Duration::from_secs(10);
loop {
let flows = ctx.db.with_conn(|db| db.find_all::<Flow>().unwrap());
let flows = ctx.db().unwrap().with_conn(|db| db.find_all::<Flow>().unwrap());
if let Some(flow) = flows.into_iter().find(|f| f.url == url && check(f)) {
return flow;
}
@@ -280,7 +280,9 @@ async fn server_sent_events_are_stored_while_the_stream_is_open() {
// The events are the body's record: nothing of it went to the blob store, and it reads
// back as the client read it.
assert!(read_raw_body(&ctx.blobs, &ended.id, FlowBody::Response).unwrap().is_empty());
assert!(
read_raw_body(&ctx.blobs().unwrap(), &ended.id, FlowBody::Response).unwrap().is_empty()
);
let body = rpc(&ctx, "get_flow_body", json!({ "flowId": ended.id, "which": "response" }))
.await
.unwrap();
@@ -342,7 +344,10 @@ async fn grpc_web_calls_are_stored_and_decoded() {
(FlowBody::Request, "request"),
(FlowBody::Response, "response"),
] {
assert!(read_raw_body(&ctx.blobs, &flow.id, which).unwrap().is_empty(), "{side}");
assert!(
read_raw_body(&ctx.blobs().unwrap(), &flow.id, which).unwrap().is_empty(),
"{side}"
);
}
let read =
|which: &str| rpc(&ctx, "get_flow_body", json!({ "flowId": flow.id, "which": which }));
@@ -381,7 +386,7 @@ async fn grpc_web_calls_are_stored_and_decoded() {
.unwrap();
let down = wait_for(&ctx, &gateway, |f| f.elapsed_ms.is_some()).await;
assert_eq!(
read_raw_body(&ctx.blobs, &down.id, FlowBody::Response).unwrap(),
read_raw_body(&ctx.blobs().unwrap(), &down.id, FlowBody::Response).unwrap(),
b"upstream down"
);
let page = rpc(&ctx, "get_flow_body", json!({ "flowId": down.id, "which": "response" }))
@@ -515,11 +520,13 @@ async fn grpc_web_calls_are_stored_and_decoded() {
assert!(rpc(&ctx, "list_messages", json!({ "flowId": flow.id })).await.is_err());
// The rows go with the background delete the clear starts.
let deadline = Instant::now() + Duration::from_secs(10);
while ctx.db.with_conn(|c| yaak_proxy_lib::flows::clear_mark(c.conn().resolve())) > 0 {
while ctx.db().unwrap().with_conn(|c| yaak_proxy_lib::flows::clear_mark(c.conn().resolve()))
> 0
{
assert!(Instant::now() < deadline, "cleared flows were not deleted");
tokio::time::sleep(Duration::from_millis(10)).await;
}
let left: i64 = ctx.db.with_conn(|c| {
let left: i64 = ctx.db().unwrap().with_conn(|c| {
c.conn()
.resolve()
.query_row("SELECT COUNT(*) FROM flow_messages", [], |r| r.get(0))
+9 -2
View File
@@ -2,7 +2,7 @@ use log::{error, info, warn};
use tauri::Runtime;
use tauri::path::BaseDirectory;
use tauri::{Emitter, Manager, RunEvent, State, WebviewWindow};
use yaak_proxy_lib::ProxyCtx;
use yaak_proxy_lib::{MasterKeySource, ProxyCtx};
use yaak_rpc::{RpcEventEmitter, RpcRouter};
use yaak_window::window::CreateWindowConfig;
@@ -88,7 +88,14 @@ pub fn run() {
std::fs::create_dir_all(&data_dir).expect("failed to create app data dir");
let (emitter, event_rx) = RpcEventEmitter::new();
let mut ctx = ProxyCtx::new(&data_dir, emitter)?;
// Captured traffic is encrypted with a key wrapped by a master key in the keychain,
// under this app's identifier, so a development build keeps its own. Linux without
// a Secret Service keeps it in a file only the user can read, with a warning.
let keys = MasterKeySource::keychain_or_file(
&app.config().identifier,
data_dir.join("encryption.key"),
);
let mut ctx = ProxyCtx::new(&data_dir, emitter, keys)?;
// Compiles protos attached for gRPC decoding when the bundle has protoc, as Yaak's
// does; otherwise one on PATH is used.
if let Some(config) = bundled_protoc(app.handle()) {
+9
View File
@@ -17,7 +17,16 @@ serde_json = { workspace = true }
thiserror = { workspace = true }
ts-rs = { workspace = true }
[features]
# Whole-database encryption: links SQLCipher in place of SQLite, with OpenSSL built in.
sqlcipher = ["rusqlite/bundled-sqlcipher-vendored-openssl", "dep:openssl-sys"]
[dev-dependencies]
tempfile = "3"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
# The OpenSSL SQLCipher links, initialized without its exit-time cleanup.
openssl-sys = { version = "0.9", optional = true }
r2d2 = "0.8.10"
r2d2_sqlite = { version = "0.32" }
@@ -0,0 +1,490 @@
//! Whole-database encryption with SQLCipher: the key a connection is opened with, and the
//! one-time conversion of a plain database into an encrypted one.
//!
//! An encrypted file is SQLite inside, so every query, index and full-text table works as on a
//! plain one; pages are encrypted as they are written and decrypted as they are read. Built only
//! with the `sqlcipher` feature, which links SQLCipher in place of SQLite: a build without it
//! cannot open an encrypted file, and one with it opens plain files without a key as before.
use crate::error::{Error, Result};
use rusqlite::Connection;
use std::fmt;
use std::fs;
use std::io::{self, Read, Write};
use std::path::{Path, PathBuf};
use std::time::Duration;
/// A database's 256-bit key, given to SQLCipher raw: no passphrase, so no key derivation runs
/// as each connection opens. The file's salt stays in its first 16 bytes, as SQLCipher keeps it.
#[derive(Clone)]
pub struct DatabaseKey([u8; 32]);
impl DatabaseKey {
pub fn from_bytes(bytes: [u8; 32]) -> Self {
Self(bytes)
}
/// The key as `PRAGMA key` and `ATTACH … KEY` take it raw: `"x'…'"`.
fn sql_literal(&self) -> String {
let hex: String = self.0.iter().map(|b| format!("{b:02X}")).collect();
format!("\"x'{hex}'\"")
}
}
/// Never prints the key.
impl fmt::Debug for DatabaseKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("DatabaseKey(..)")
}
}
/// What is at a database's path.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileState {
/// Nothing, or an empty file: one opened with a key starts out encrypted.
Missing,
/// A plain SQLite database, which [`encrypt_in_place`] converts.
Plain,
/// Anything else, which only the right key opens.
Encrypted,
}
const PLAIN_HEADER: &[u8; 16] = b"SQLite format 3\0";
/// Whether `path` holds a plain database, an encrypted one, or none. Reads the first 16 bytes:
/// a plain file starts with SQLite's header, an encrypted one with its random salt.
pub fn file_state(path: &Path) -> io::Result<FileState> {
let mut file = match fs::File::open(path) {
Ok(file) => file,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(FileState::Missing),
Err(e) => return Err(e),
};
let mut header = [0u8; 16];
let mut read = 0;
while read < header.len() {
match file.read(&mut header[read..])? {
0 => break,
n => read += n,
}
}
Ok(match read {
0 => FileState::Missing,
_ if &header == PLAIN_HEADER => FileState::Plain,
_ => FileState::Encrypted,
})
}
/// Keys `conn`, which must have just been opened and not yet read: every pool's connection
/// init calls this first. Reads the schema at once, so a wrong key, or a plain file, fails here
/// ("file is not a database") rather than at the first query.
pub fn apply_key(conn: &Connection, key: &DatabaseKey) -> rusqlite::Result<()> {
init_crypto();
conn.execute_batch(&format!("PRAGMA key = {};", key.sql_literal()))?;
conn.query_row("SELECT count(*) FROM sqlite_master", [], |_| Ok(()))
}
/// Initializes OpenSSL without the cleanup it otherwise registers to run at exit, which frees
/// its state while threads still reading or writing an encrypted database use it, so the process
/// crashes as it quits. Must run before the process opens its first SQLite connection: SQLCipher
/// uses OpenSSL as SQLite initializes, and a cleanup registered then cannot be taken back. Every
/// opener here calls it; one that opens a connection itself first calls it too.
pub fn init_crypto() {
openssl_sys::init();
}
/// The bytes a plain database at `path` takes, with its WAL: what [`encrypt_in_place`] has to
/// write, for showing its progress.
pub fn plain_size(path: &Path) -> u64 {
[path.to_path_buf(), sidecar(path, "-wal")]
.iter()
.filter_map(|p| fs::metadata(p).ok())
.map(|m| m.len())
.sum()
}
/// Converts a plain database at `path` into one encrypted with `key`, WAL and all, and returns
/// whether there was one to convert. Exports into a new file beside it, checks the new file
/// opens with `key` and holds every table, swaps it in, then overwrites the plain file, its WAL
/// and shared memory with zeros before deleting them. `progress` hears the bytes written so
/// far, a few times a second.
///
/// Nothing may have the database open meanwhile. A lock beside it serializes callers across
/// processes; the second finds it converted. An interrupted conversion is finished, or undone
/// and redone, by the next call.
pub fn encrypt_in_place(
path: &Path,
key: &DatabaseKey,
progress: &(dyn Fn(u64) + Sync),
) -> Result<bool> {
init_crypto();
let exported = sidecar(path, "-encrypting");
let replaced = sidecar(path, "-plain");
// Nothing to convert and nothing left over, the usual case, takes no lock.
if file_state(path)? != FileState::Plain && !exported.exists() && !replaced.exists() {
return Ok(false);
}
let lock = fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(sidecar(path, "-encrypt.lock"))?;
lock.lock()?;
recover(path, &exported, &replaced)?;
if file_state(path)? != FileState::Plain {
return Ok(false);
}
let conn = Connection::open(path)?;
conn.busy_timeout(Duration::from_secs(5))?;
keep_wal_on_close(&conn)?;
// Brings in what the WAL holds, so the export is complete however the last run ended.
conn.query_row("PRAGMA wal_checkpoint(FULL)", [], |_| Ok(()))?;
let original = fingerprint(&conn)?;
let user_version: i64 = conn.query_row("PRAGMA user_version", [], |r| r.get(0))?;
let exporting = || -> rusqlite::Result<()> {
conn.execute(
&format!("ATTACH DATABASE ?1 AS encrypted KEY {}", key.sql_literal()),
[exported.to_string_lossy()],
)?;
conn.query_row("SELECT sqlcipher_export('encrypted')", [], |_| Ok(()))?;
conn.execute_batch(&format!(
"PRAGMA encrypted.user_version = {user_version}; DETACH DATABASE encrypted;"
))
};
let done = std::sync::atomic::AtomicBool::new(false);
let exported_result = std::thread::scope(|s| {
s.spawn(|| {
while !done.load(std::sync::atomic::Ordering::Relaxed) {
progress(fs::metadata(&exported).map(|m| m.len()).unwrap_or(0));
std::thread::sleep(Duration::from_millis(200));
}
});
let result = exporting();
done.store(true, std::sync::atomic::Ordering::Relaxed);
result
});
if let Err(e) = exported_result {
drop(conn);
let _ = fs::remove_file(&exported);
return Err(e.into());
}
// Closing checkpoints what is left; the WAL stays, to be overwritten below.
drop(conn);
// Writable, as Windows flushes only a handle that can write.
fs::OpenOptions::new().write(true).open(&exported)?.sync_all()?;
progress(fs::metadata(&exported)?.len());
// The copy must open with the key and hold everything, every row under its own rowid,
// before the original goes.
let check = Connection::open(&exported)?;
apply_key(&check, key)?;
let copied = fingerprint(&check)?;
drop(check);
if copied != original {
let _ = fs::remove_file(&exported);
return Err(Error::Database(format!(
"The encrypted copy of {} differs from it, so it is left as it was",
path.display()
)));
}
for suffix in ["-wal", "-shm", "-journal"] {
wipe(&sidecar(path, suffix))?;
}
fs::rename(path, &replaced)?;
fs::rename(&exported, path)?;
sync_dir(path);
wipe(&replaced)?;
// Another caller waiting on the lock holds its own handle, so this only tidies.
let _ = fs::remove_file(sidecar(path, "-encrypt.lock"));
Ok(true)
}
/// Finishes or undoes a conversion that stopped part way: the copy swapped in when the plain
/// file had already moved aside, the plain file put back when there was no copy to swap in,
/// and a half-written copy deleted.
fn recover(path: &Path, exported: &Path, replaced: &Path) -> Result<()> {
if replaced.exists() {
if !path.exists() {
if file_state(exported)? == FileState::Encrypted {
fs::rename(exported, path)?;
} else {
fs::rename(replaced, path)?;
}
}
if replaced.exists() {
wipe(replaced)?;
}
}
if exported.exists() {
fs::remove_file(exported)?;
}
Ok(())
}
/// What a copy must match: every table and index by name, and each table's row count and the
/// total of its rowids, so rows renumbered in the copy show. `sqlcipher_export` keeps rowids by
/// copying each table whole, which SQLite does for an empty table of the same schema.
fn fingerprint(conn: &Connection) -> rusqlite::Result<Vec<(String, i64, f64)>> {
let names: Vec<(String, String)> = conn
.prepare(
"SELECT type, name FROM sqlite_master WHERE type IN ('table', 'index') ORDER BY 1, 2",
)?
.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?
.collect::<rusqlite::Result<_>>()?;
names
.into_iter()
.map(|(kind, name)| {
if kind != "table" {
return Ok((format!("{kind} {name}"), 0, 0.0));
}
let table = format!("\"{}\"", name.replace('"', "\"\""));
let count = |sql: String| conn.query_row(&sql, [], |r| Ok((r.get(0)?, r.get(1)?)));
// A table without rowids, or a virtual one that has none, by its rows alone.
let (rows, rowids) = count(format!("SELECT count(*), total(rowid) FROM {table}"))
.or_else(|_| count(format!("SELECT count(*), 0.0 FROM {table}")))?;
Ok((format!("{kind} {name}"), rows, rowids))
})
.collect()
}
/// Keeps the WAL file when `conn` closes, rather than SQLite deleting it unread, so it can be
/// overwritten before it goes.
fn keep_wal_on_close(conn: &Connection) -> Result<()> {
let mut on: std::ffi::c_int = 1;
// SAFETY: `conn` is open for the call, "main" is a NUL-terminated schema name, and
// SQLITE_FCNTL_PERSIST_WAL takes a pointer to an int it reads and may write.
let rc = unsafe {
rusqlite::ffi::sqlite3_file_control(
conn.handle(),
c"main".as_ptr(),
rusqlite::ffi::SQLITE_FCNTL_PERSIST_WAL,
(&mut on as *mut std::ffi::c_int).cast(),
)
};
if rc != rusqlite::ffi::SQLITE_OK {
return Err(Error::Database(format!("Failed to keep the WAL on close: {rc}")));
}
Ok(())
}
/// Overwrites the file at `path` with zeros, flushed, then deletes it. Nothing there is fine.
fn wipe(path: &Path) -> Result<()> {
let mut file = match fs::OpenOptions::new().write(true).open(path) {
Ok(file) => file,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(e.into()),
};
let len = file.metadata()?.len();
let zeros = vec![0u8; 1 << 20];
let mut left = len;
while left > 0 {
let n = left.min(zeros.len() as u64) as usize;
file.write_all(&zeros[..n])?;
left -= n as u64;
}
file.sync_all()?;
drop(file);
fs::remove_file(path)?;
Ok(())
}
/// Flushes the directory holding `path`, so its renames survive a crash. Only Unix can.
fn sync_dir(path: &Path) {
#[cfg(unix)]
if let Some(dir) = path.parent()
&& let Ok(dir) = fs::File::open(dir)
{
let _ = dir.sync_all();
}
#[cfg(not(unix))]
let _ = path;
}
/// `path` with `suffix` added, as SQLite names a database's WAL `<path>-wal`.
fn sidecar(path: &Path, suffix: &str) -> PathBuf {
let mut name = path.as_os_str().to_os_string();
name.push(suffix);
PathBuf::from(name)
}
#[cfg(test)]
mod tests {
use super::*;
fn key(n: u8) -> DatabaseKey {
DatabaseKey::from_bytes([n; 32])
}
fn contains(path: &Path, needle: &[u8]) -> bool {
fs::read(path).map(|b| b.windows(needle.len()).any(|w| w == needle)).unwrap_or(false)
}
#[test]
fn opens_only_with_its_key() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("db.sqlite");
let conn = Connection::open(&path).unwrap();
apply_key(&conn, &key(1)).unwrap();
conn.execute_batch("CREATE TABLE t (v TEXT); INSERT INTO t VALUES ('Bearer secret');")
.unwrap();
drop(conn);
assert_eq!(file_state(&path).unwrap(), FileState::Encrypted);
assert!(!contains(&path, b"Bearer secret"));
let wrong = Connection::open(&path).unwrap();
let err = apply_key(&wrong, &key(2)).unwrap_err();
assert!(err.to_string().contains("file is not a database"), "{err}");
let none = Connection::open(&path).unwrap();
assert!(none.query_row("SELECT count(*) FROM t", [], |r| r.get::<_, i64>(0)).is_err());
let right = Connection::open(&path).unwrap();
apply_key(&right, &key(1)).unwrap();
let v: String = right.query_row("SELECT v FROM t", [], |r| r.get(0)).unwrap();
assert_eq!(v, "Bearer secret");
assert_eq!(format!("{:?}", key(1)), "DatabaseKey(..)");
}
#[test]
fn a_plain_file_does_not_open_with_a_key() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("db.sqlite");
assert_eq!(file_state(&path).unwrap(), FileState::Missing);
Connection::open(&path).unwrap().execute_batch("CREATE TABLE t (v TEXT)").unwrap();
assert_eq!(file_state(&path).unwrap(), FileState::Plain);
assert!(apply_key(&Connection::open(&path).unwrap(), &key(1)).is_err());
}
#[test]
fn converts_a_plain_database_with_its_wal() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("db.sqlite");
let live = dir.path().join("live.sqlite");
let conn = Connection::open(&live).unwrap();
conn.pragma_update(None, "journal_mode", "WAL").unwrap();
conn.execute_batch(
"PRAGMA user_version = 7;
CREATE TABLE t (v TEXT); CREATE INDEX t_v ON t (v);
INSERT INTO t VALUES ('in the main file');",
)
.unwrap();
conn.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |_| Ok(())).unwrap();
// Left in the WAL, as a crash would leave it.
keep_wal_on_close(&conn).unwrap();
conn.execute_batch("PRAGMA wal_autocheckpoint = 0; INSERT INTO t VALUES ('only in wal');")
.unwrap();
// The files as a crash leaves them, nothing checkpointed or closed: copied while the
// connection is open, which then closes, so no handle outlives the test.
fs::copy(&live, &path).unwrap();
fs::copy(sidecar(&live, "-wal"), sidecar(&path, "-wal")).unwrap();
drop(conn);
assert!(contains(&sidecar(&path, "-wal"), b"only in wal"));
assert!(!contains(&path, b"only in wal"));
assert!(plain_size(&path) > 0);
let seen = std::sync::Mutex::new(Vec::new());
assert!(encrypt_in_place(&path, &key(1), &|n| seen.lock().unwrap().push(n)).unwrap());
assert!(seen.lock().unwrap().last().is_some_and(|n| *n > 0));
assert_eq!(file_state(&path).unwrap(), FileState::Encrypted);
for suffix in ["-wal", "-shm", "-plain", "-encrypting"] {
assert!(!sidecar(&path, suffix).exists(), "{suffix} left behind");
}
assert!(!contains(&path, b"only in wal") && !contains(&path, b"in the main file"));
let conn = Connection::open(&path).unwrap();
apply_key(&conn, &key(1)).unwrap();
let rows: Vec<String> = conn
.prepare("SELECT v FROM t ORDER BY v")
.unwrap()
.query_map([], |r| r.get(0))
.unwrap()
.map(|r| r.unwrap())
.collect();
assert_eq!(rows, ["in the main file", "only in wal"]);
let version: i64 = conn.query_row("PRAGMA user_version", [], |r| r.get(0)).unwrap();
assert_eq!(version, 7);
drop(conn);
// Already encrypted, the second call does nothing.
assert!(!encrypt_in_place(&path, &key(1), &|_| {}).unwrap());
}
/// Rows keep their rowids, gaps from deleted rows and all, as flows' numbers are theirs.
#[test]
fn keeps_rowids() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("db.sqlite");
let conn = Connection::open(&path).unwrap();
conn.execute_batch(
"CREATE TABLE flows (id TEXT NOT NULL PRIMARY KEY, started INTEGER);
CREATE INDEX flows_started ON flows (started, id);
CREATE TABLE marks (id INTEGER PRIMARY KEY, max_rowid INTEGER);",
)
.unwrap();
for i in 0..20 {
conn.execute("INSERT INTO flows VALUES (?1, ?1)", [i]).unwrap();
}
conn.execute_batch(
"DELETE FROM flows WHERE started < 5 OR started IN (9, 12);
INSERT INTO marks VALUES (3, 12);",
)
.unwrap();
let rowids = |conn: &Connection| -> Vec<(i64, String)> {
conn.prepare("SELECT rowid, id FROM flows ORDER BY rowid")
.unwrap()
.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
.unwrap()
.map(|r| r.unwrap())
.collect()
};
let before = rowids(&conn);
drop(conn);
assert!(encrypt_in_place(&path, &key(1), &|_| {}).unwrap());
let conn = Connection::open(&path).unwrap();
apply_key(&conn, &key(1)).unwrap();
assert_eq!(rowids(&conn), before);
let mark: (i64, i64) = conn
.query_row("SELECT id, max_rowid FROM marks", [], |r| Ok((r.get(0)?, r.get(1)?)))
.unwrap();
assert_eq!(mark, (3, 12));
// New rows carry on from the last, not from the count.
conn.execute("INSERT INTO flows VALUES ('new', 99)", []).unwrap();
let last: i64 = conn.query_row("SELECT max(rowid) FROM flows", [], |r| r.get(0)).unwrap();
assert_eq!(last, 21);
}
#[test]
fn finishes_a_swap_that_stopped_halfway() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("db.sqlite");
let plain = sidecar(&path, "-plain");
let exported = sidecar(&path, "-encrypting");
Connection::open(&path).unwrap().execute_batch("CREATE TABLE t (v TEXT)").unwrap();
// Stopped mid-export: the half copy goes and the conversion runs again.
fs::write(&exported, b"half").unwrap();
assert!(encrypt_in_place(&path, &key(1), &|_| {}).unwrap());
assert!(!exported.exists());
// Stopped between the renames: the plain file aside, the copy not yet in place.
fs::rename(&path, &exported).unwrap();
Connection::open(&path).unwrap().execute_batch("CREATE TABLE t (v TEXT)").unwrap();
fs::rename(&path, &plain).unwrap();
assert!(!encrypt_in_place(&path, &key(1), &|_| {}).unwrap());
assert_eq!(file_state(&path).unwrap(), FileState::Encrypted);
assert!(!plain.exists() && !exported.exists());
// Stopped with the plain file aside and no copy: it is put back and converted.
fs::remove_file(&path).unwrap();
Connection::open(&plain).unwrap().execute_batch("CREATE TABLE t (v TEXT)").unwrap();
assert!(encrypt_in_place(&path, &key(1), &|_| {}).unwrap());
assert!(!plain.exists());
let conn = Connection::open(&path).unwrap();
apply_key(&conn, &key(1)).unwrap();
assert_eq!(fingerprint(&conn).unwrap(), [("table t".to_string(), 0, 0.0)]);
}
}
+2
View File
@@ -1,5 +1,7 @@
pub mod connection_or_tx;
pub mod db_context;
#[cfg(all(feature = "sqlcipher", not(target_arch = "wasm32")))]
pub mod encryption;
pub mod error;
pub mod migrate;
pub mod pool;
+11
View File
@@ -13,3 +13,14 @@ log = { workspace = true }
serde = { workspace = true, features = ["derive"] }
thiserror = { workspace = true }
yaak-models = { workspace = true }
# The Secret Service backend keyring uses, to tell a missing service from a refusal.
[target.'cfg(target_os = "linux")'.dependencies]
dbus-secret-service = "4.1.0"
[features]
# `MasterKeySource::memory` and `MasterKeySource::refusing`, for other crates' tests.
test-support = []
[dev-dependencies]
tempfile = "3"
+218
View File
@@ -0,0 +1,218 @@
//! A key for an app's own data at rest, outside any workspace. The data key encrypts the data;
//! it is kept beside it wrapped (encrypted) by the app's master key, so only the master key
//! lives in the OS credential store.
use crate::encryption::{decrypt_data, encrypt_data};
use crate::error::Error::{GenericError, IncorrectMasterKey, MissingMasterKey};
use crate::error::Result;
use crate::master_key::{write_secret_file, MasterKey, MasterKeySource};
use base64::prelude::BASE64_STANDARD;
use base64::Engine;
use chacha20poly1305::aead::{Generate, Key};
use chacha20poly1305::XChaCha20Poly1305;
use std::fmt;
use std::fs;
use std::io;
use std::path::Path;
#[derive(Clone)]
pub struct DataKey {
key: Key<XChaCha20Poly1305>,
}
impl DataKey {
/// Bytes [`DataKey::encrypt`] adds to what it encrypts: a tag and version, the nonce and the
/// authentication tag.
pub const OVERHEAD: usize = 48;
/// A new data key, and it wrapped by the master key in `source`, for storing. Makes the
/// master key when `source` has none.
pub fn create(source: &MasterKeySource) -> Result<(Self, String)> {
let master = MasterKey::get_or_create_in(source.store())?;
let key = Key::<XChaCha20Poly1305>::generate();
let wrapped = BASE64_STANDARD.encode(master.encrypt(key.as_slice())?);
Ok((Self { key }, wrapped))
}
/// The data key in `wrapped`, as [`DataKey::create`] gave it, unwrapped with the master key
/// in `source`. Never makes a master key: one made now could not unwrap it.
pub fn from_wrapped(source: &MasterKeySource, wrapped: &str) -> Result<Self> {
let master = MasterKey::get_in(source.store())?.ok_or(MissingMasterKey)?;
let wrapped = BASE64_STANDARD
.decode(wrapped.trim())
.map_err(|e| GenericError(format!("Failed to decode the wrapped data key: {e}")))?;
let raw = master.decrypt(&wrapped).map_err(|_| IncorrectMasterKey)?;
let key = Key::<XChaCha20Poly1305>::try_from(raw.as_slice())
.map_err(|_| GenericError("The data key is the wrong length".to_string()))?;
Ok(Self { key })
}
/// The data key kept wrapped in the file at `path`, made and written there the first time.
/// Safe to call from several threads and processes at once: a lock beside the file
/// serializes them, so every caller gets the one key that ends up written.
pub fn load_or_create_file(source: &MasterKeySource, path: &Path) -> Result<Self> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let lock = fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(path.with_extension("lock"))?;
lock.lock()?;
match fs::read_to_string(path) {
Ok(wrapped) => Self::from_wrapped(source, &wrapped),
Err(e) if e.kind() == io::ErrorKind::NotFound => {
let (key, wrapped) = Self::create(source)?;
// Written beside and renamed over, so a crash never leaves half a key.
let tmp = path.with_extension("tmp");
write_secret_file(&tmp, &wrapped)?;
fs::rename(&tmp, path)?;
Ok(key)
}
Err(e) => Err(e.into()),
}
}
/// The data key kept wrapped in the file at `path`, or None when there is no file. Never
/// makes one, for a reader that must not create keys.
pub fn load_file(source: &MasterKeySource, path: &Path) -> Result<Option<Self>> {
match fs::read_to_string(path) {
Ok(wrapped) => Self::from_wrapped(source, &wrapped).map(Some),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e.into()),
}
}
/// The key's 32 bytes, for a cipher outside this crate, such as SQLCipher's raw key.
pub fn raw_bytes(&self) -> [u8; 32] {
let mut bytes = [0u8; 32];
bytes.copy_from_slice(self.key.as_slice());
bytes
}
pub fn encrypt(&self, data: &[u8]) -> Result<Vec<u8>> {
encrypt_data(data, &self.key)
}
pub fn decrypt(&self, data: &[u8]) -> Result<Vec<u8>> {
decrypt_data(data, &self.key)
}
}
/// Never prints the key.
impl fmt::Debug for DataKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("DataKey(..)")
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::Error;
#[test]
fn round_trips_through_its_wrapped_form() -> Result<()> {
let source = MasterKeySource::memory();
let (key, wrapped) = DataKey::create(&source)?;
let sealed = key.encrypt(b"authorization: Bearer abc")?;
assert_eq!(sealed.len(), b"authorization: Bearer abc".len() + DataKey::OVERHEAD);
assert!(!sealed.windows(6).any(|w| w == b"Bearer"));
let unwrapped = DataKey::from_wrapped(&source, &wrapped)?;
assert_eq!(unwrapped.decrypt(&sealed)?, b"authorization: Bearer abc");
assert_eq!(key.decrypt(&unwrapped.encrypt(b"")?)?, b"");
assert_eq!(unwrapped.raw_bytes(), key.raw_bytes());
Ok(())
}
#[test]
fn unwrapping_needs_the_same_master_key() -> Result<()> {
let (_, wrapped) = DataKey::create(&MasterKeySource::memory())?;
// One that has never had a master key does not get one made.
let empty = MasterKeySource::memory();
assert!(matches!(DataKey::from_wrapped(&empty, &wrapped), Err(Error::MissingMasterKey)));
assert!(empty.store().get()?.is_none());
let other = MasterKeySource::memory();
DataKey::create(&other)?;
assert!(matches!(DataKey::from_wrapped(&other, &wrapped), Err(Error::IncorrectMasterKey)));
let refusing = MasterKeySource::refusing("denied");
assert!(DataKey::create(&refusing).is_err());
assert!(DataKey::from_wrapped(&refusing, &wrapped).is_err());
Ok(())
}
#[test]
fn a_wrong_key_does_not_decrypt() -> Result<()> {
let source = MasterKeySource::memory();
let (a, _) = DataKey::create(&source)?;
let (b, _) = DataKey::create(&source)?;
let sealed = a.encrypt(b"secret")?;
assert!(matches!(b.decrypt(&sealed), Err(Error::DecryptionError)));
assert_eq!(format!("{a:?}"), "DataKey(..)");
Ok(())
}
#[test]
fn the_key_file_is_made_once_and_read_back() -> Result<()> {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("data.key");
let source = MasterKeySource::memory();
let made = DataKey::load_or_create_file(&source, &path)?;
let stored = fs::read_to_string(&path)?;
assert!(!stored.is_empty());
assert!(!dir.path().join("data.tmp").exists());
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(fs::metadata(&path)?.permissions().mode() & 0o777, 0o600);
}
let read = DataKey::load_or_create_file(&source, &path)?;
assert_eq!(read.raw_bytes(), made.raw_bytes());
let loaded = DataKey::load_file(&source, &path)?.unwrap();
assert_eq!(loaded.raw_bytes(), made.raw_bytes());
assert!(DataKey::load_file(&source, &dir.path().join("none.key"))?.is_none());
assert!(!dir.path().join("none.key").exists());
assert_eq!(fs::read_to_string(&path)?, stored, "not rewritten");
// Another master key cannot read it, and does not replace it.
let other = MasterKeySource::memory();
assert!(matches!(
DataKey::load_or_create_file(&other, &path),
Err(Error::MissingMasterKey)
));
assert!(DataKey::load_or_create_file(&MasterKeySource::refusing("denied"), &path).is_err());
assert_eq!(fs::read_to_string(&path)?, stored);
Ok(())
}
/// Everyone asking at once, the first time, gets the same key: one master key, one data key.
#[test]
fn concurrent_first_loads_agree() -> Result<()> {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("data.key");
let source = MasterKeySource::memory();
let barrier = std::sync::Barrier::new(8);
let keys: Vec<[u8; 32]> = std::thread::scope(|s| {
let handles: Vec<_> = (0..8)
.map(|_| {
s.spawn(|| {
barrier.wait();
DataKey::load_or_create_file(&source, &path).unwrap().raw_bytes()
})
})
.collect();
handles.into_iter().map(|h| h.join().unwrap()).collect()
});
assert!(keys.windows(2).all(|w| w[0] == w[1]));
assert_eq!(DataKey::load_or_create_file(&source, &path)?.raw_bytes(), keys[0]);
Ok(())
}
}
+6
View File
@@ -19,6 +19,12 @@ pub enum Error {
#[error("Failed to decrypt workspace key: {0}")]
WorkspaceKeyDecryptionError(String),
#[error("The encryption key is missing from the keychain")]
MissingMasterKey,
#[error("The keychain's encryption key does not match the one this data was encrypted with")]
IncorrectMasterKey,
#[error("Crypto IO error: {0}")]
IoError(#[from] io::Error),
+3
View File
@@ -1,7 +1,10 @@
extern crate core;
pub mod data_key;
pub mod encryption;
pub mod error;
pub mod manager;
mod master_key;
mod workspace_key;
pub use master_key::MasterKeySource;
+330 -23
View File
@@ -5,7 +5,9 @@ use base32::Alphabet;
use chacha20poly1305::aead::{Generate, Key};
use chacha20poly1305::XChaCha20Poly1305;
use keyring::{Entry, Error};
use log::info;
use log::{info, warn};
use std::path::{Path, PathBuf};
use std::sync::Arc;
const HUMAN_PREFIX: &str = "YKM_";
@@ -16,31 +18,38 @@ pub(crate) struct MasterKey {
impl MasterKey {
pub(crate) fn get_or_create(app_id: &str, user: &str) -> Result<Self> {
let id = format!("{app_id}.EncryptionKey");
let entry = Entry::new(&id, user)?;
let key = match entry.get_password() {
Ok(encoded) => {
let without_prefix = encoded.strip_prefix(HUMAN_PREFIX).unwrap_or(&encoded);
let key_bytes = base32::decode(Alphabet::Crockford {}, &without_prefix)
.ok_or(GenericError("Failed to decode master key".to_string()))?;
Key::<XChaCha20Poly1305>::try_from(key_bytes.as_slice())
.map_err(|_| GenericError("Master key is the wrong length".to_string()))?
}
Err(Error::NoEntry) => {
info!("Creating new master key");
let key = Key::<XChaCha20Poly1305>::generate();
let encoded = base32::encode(Alphabet::Crockford {}, key.as_slice());
let with_prefix = format!("{HUMAN_PREFIX}{encoded}");
entry.set_password(&with_prefix)?;
key
}
Err(e) => return Err(GenericError(e.to_string())),
};
Self::get_or_create_in(&KeychainStore::new(app_id, user))
}
/// The master key in `store`, or a new one put there when it has none. Callers in this
/// process take turns, so two asking at once for the first time do not each make one, the
/// second replacing the first and leaving what it wrapped unreadable.
pub(crate) fn get_or_create_in(store: &dyn SecretStore) -> Result<Self> {
static CREATING: std::sync::Mutex<()> = std::sync::Mutex::new(());
let _turn = CREATING.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(key) = Self::get_in(store)? {
return Ok(key);
}
info!("Creating new master key");
let key = Key::<XChaCha20Poly1305>::generate();
let encoded = base32::encode(Alphabet::Crockford {}, key.as_slice());
store.set(&format!("{HUMAN_PREFIX}{encoded}"))?;
Ok(Self { key })
}
/// The master key in `store`, or None when it has none.
pub(crate) fn get_in(store: &dyn SecretStore) -> Result<Option<Self>> {
let Some(encoded) = store.get()? else {
return Ok(None);
};
let without_prefix = encoded.strip_prefix(HUMAN_PREFIX).unwrap_or(&encoded);
let key_bytes = base32::decode(Alphabet::Crockford {}, without_prefix)
.ok_or(GenericError("Failed to decode master key".to_string()))?;
let key = Key::<XChaCha20Poly1305>::try_from(key_bytes.as_slice())
.map_err(|_| GenericError("Master key is the wrong length".to_string()))?;
Ok(Some(Self { key }))
}
pub(crate) fn encrypt(&self, data: &[u8]) -> Result<Vec<u8>> {
encrypt_data(data, &self.key)
}
@@ -56,10 +65,223 @@ impl MasterKey {
}
}
/// Where a master key is kept, as the text [`MasterKey`] encodes it to.
pub(crate) trait SecretStore: Send + Sync {
/// The secret, or None when none has been set.
fn get(&self) -> Result<Option<String>>;
fn set(&self, secret: &str) -> Result<()>;
}
/// Where an app's master key is kept: the OS credential store, normally. Cheap to clone.
#[derive(Clone)]
pub struct MasterKeySource {
store: Arc<dyn SecretStore>,
}
impl MasterKeySource {
/// The OS credential store, under `{app_id}.EncryptionKey`, where Yaak keeps its own: the
/// macOS Keychain, the Windows Credential Manager or the Secret Service on Linux.
pub fn keychain(app_id: &str) -> Self {
Self { store: Arc::new(KeychainStore::new(app_id, KEYCHAIN_USER)) }
}
/// [`Self::keychain`], but on Linux without a Secret Service running, the key is kept in
/// `path`, readable by the user alone, with a warning, rather than not at all.
pub fn keychain_or_file(app_id: &str, path: impl Into<PathBuf>) -> Self {
let store = KeychainStore::new(app_id, KEYCHAIN_USER).with_fallback_file(path.into());
Self { store: Arc::new(store) }
}
/// A key held in memory for as long as this source and its clones live.
#[cfg(any(test, feature = "test-support"))]
pub fn memory() -> Self {
Self { store: Arc::new(test_support::MemoryStore::default()) }
}
/// A store that refuses with `reason`, as a keychain does when access is denied.
#[cfg(any(test, feature = "test-support"))]
pub fn refusing(reason: impl Into<String>) -> Self {
Self { store: Arc::new(test_support::RefusingStore(reason.into())) }
}
pub(crate) fn store(&self) -> &dyn SecretStore {
self.store.as_ref()
}
}
/// The account master keys are kept under, as [`crate::manager::EncryptionManager`] keeps
/// Yaak's.
const KEYCHAIN_USER: &str = "encryption-key";
/// The OS credential store, with a file to fall back to on Linux without a Secret Service.
struct KeychainStore {
service: String,
user: String,
fallback_file: Option<PathBuf>,
}
impl KeychainStore {
fn new(app_id: &str, user: &str) -> Self {
Self { service: format!("{app_id}.EncryptionKey"), user: user.into(), fallback_file: None }
}
fn with_fallback_file(self, path: PathBuf) -> Self {
Self { fallback_file: Some(path), ..self }
}
/// The fallback file, when `e` says there is no credential store to use and there is one.
/// Denied access is not that: it stays an error.
fn fallback_for(&self, e: &Error) -> Option<&Path> {
let path = self.fallback_file.as_deref()?;
is_missing_secret_service(e).then_some(path)
}
/// A key kept in the fallback file while there was no credential store. Read before
/// creating one, so a Secret Service that turns up later does not replace it.
fn fallback_secret(&self) -> Result<Option<String>> {
match &self.fallback_file {
Some(path) => read_secret_file(path),
None => Ok(None),
}
}
}
impl SecretStore for KeychainStore {
fn get(&self) -> Result<Option<String>> {
let entry = Entry::new(&self.service, &self.user)?;
match entry.get_password() {
Ok(secret) => Ok(Some(secret)),
Err(Error::NoEntry) => self.fallback_secret(),
Err(e) => match self.fallback_for(&e) {
Some(path) => read_secret_file(path),
None => Err(GenericError(e.to_string())),
},
}
}
fn set(&self, secret: &str) -> Result<()> {
let entry = Entry::new(&self.service, &self.user)?;
match entry.set_password(secret) {
Ok(()) => Ok(()),
Err(e) => match self.fallback_for(&e) {
Some(path) => {
warn!(
"No Secret Service is running ({e}), so the encryption key for {} is kept \
in {}, readable only by you, instead of the keyring",
self.service,
path.display()
);
write_secret_file(path, secret)
}
None => Err(e.into()),
},
}
}
}
/// D-Bus errors that mean there is no session bus, or no Secret Service on it.
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
const MISSING_SERVICE_ERRORS: [&str; 7] = [
"org.freedesktop.DBus.Error.ServiceUnknown",
"org.freedesktop.DBus.Error.NameHasNoOwner",
"org.freedesktop.DBus.Error.NoServer",
"org.freedesktop.DBus.Error.FileNotFound",
"org.freedesktop.DBus.Error.BadAddress",
// Autolaunching a session bus without X11
"org.freedesktop.DBus.Error.NotSupported",
"org.freedesktop.DBus.Error.Spawn.ExecFailed",
];
/// `e` says there is no Secret Service to keep a key in: no session bus, or nothing on it
/// providing `org.freedesktop.secrets`. Every other failure, access denied, a locked
/// collection or a dismissed prompt among them, is not, so it stays an error rather than
/// putting the key in a file.
#[cfg(target_os = "linux")]
fn is_missing_secret_service(e: &Error) -> bool {
let Error::PlatformFailure(inner) = e else {
return false;
};
match inner.downcast_ref::<dbus_secret_service::Error>() {
Some(dbus_secret_service::Error::Unavailable) => true,
Some(dbus_secret_service::Error::Dbus(e)) => {
e.name().is_some_and(|name| MISSING_SERVICE_ERRORS.contains(&name))
}
_ => false,
}
}
/// Only Linux keeps keys in a service that may not be running.
#[cfg(not(target_os = "linux"))]
fn is_missing_secret_service(_e: &Error) -> bool {
false
}
fn read_secret_file(path: &Path) -> Result<Option<String>> {
match std::fs::read_to_string(path) {
Ok(secret) => Ok(Some(secret.trim().to_string())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e.into()),
}
}
/// Writes `secret` to `path`, readable and writable by the user alone where the platform has
/// such modes.
pub(crate) fn write_secret_file(path: &Path, secret: &str) -> Result<()> {
use std::io::Write;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut options = std::fs::OpenOptions::new();
options.write(true).create(true).truncate(true);
#[cfg(unix)]
std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
let mut file = options.open(path)?;
// A file that already existed keeps its mode through `open`.
#[cfg(unix)]
file.set_permissions(std::os::unix::fs::PermissionsExt::from_mode(0o600))?;
file.write_all(secret.as_bytes())?;
file.sync_all()?;
Ok(())
}
#[cfg(any(test, feature = "test-support"))]
mod test_support {
use super::SecretStore;
use crate::error::Error::GenericError;
use crate::error::Result;
use std::sync::Mutex;
#[derive(Default)]
pub(super) struct MemoryStore(Mutex<Option<String>>);
impl SecretStore for MemoryStore {
fn get(&self) -> Result<Option<String>> {
Ok(self.0.lock().unwrap().clone())
}
fn set(&self, secret: &str) -> Result<()> {
*self.0.lock().unwrap() = Some(secret.to_string());
Ok(())
}
}
pub(super) struct RefusingStore(pub(super) String);
impl SecretStore for RefusingStore {
fn get(&self) -> Result<Option<String>> {
Err(GenericError(self.0.clone()))
}
fn set(&self, _secret: &str) -> Result<()> {
Err(GenericError(self.0.clone()))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::Result;
use crate::master_key::MasterKey;
#[test]
fn test_master_key() -> Result<()> {
@@ -75,4 +297,89 @@ mod tests {
Ok(())
}
#[test]
fn creates_a_master_key_once() -> Result<()> {
let source = MasterKeySource::memory();
assert!(MasterKey::get_in(source.store())?.is_none());
let created = MasterKey::get_or_create_in(source.store())?;
let stored = source.store().get()?.unwrap();
assert!(stored.starts_with(HUMAN_PREFIX), "{stored}");
let again = MasterKey::get_or_create_in(source.store())?;
assert_eq!(again.key, created.key);
assert_eq!(source.store().get()?.unwrap(), stored);
Ok(())
}
/// Several asking at once for the first time all get the one key that is stored.
#[test]
fn concurrent_first_creates_agree() {
let source = MasterKeySource::memory();
let barrier = std::sync::Barrier::new(8);
let keys: Vec<_> = std::thread::scope(|s| {
let handles: Vec<_> = (0..8)
.map(|_| {
s.spawn(|| {
barrier.wait();
MasterKey::get_or_create_in(source.store()).unwrap().key
})
})
.collect();
handles.into_iter().map(|h| h.join().unwrap()).collect()
});
let stored = MasterKey::get_in(source.store()).unwrap().unwrap().key;
assert!(keys.iter().all(|k| *k == stored));
}
#[test]
fn a_refusing_store_gives_no_key() {
let source = MasterKeySource::refusing("User canceled the operation");
let err = MasterKey::get_or_create_in(source.store()).unwrap_err();
assert_eq!(err.to_string(), "Encryption error: User canceled the operation");
}
#[test]
fn the_fallback_file_is_private_and_read_back() -> Result<()> {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("keys").join("master.key");
assert_eq!(read_secret_file(&path)?, None);
write_secret_file(&path, "YKM_ABC")?;
assert_eq!(read_secret_file(&path)?.as_deref(), Some("YKM_ABC"));
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&path)?.permissions().mode();
assert_eq!(mode & 0o777, 0o600);
}
// Rewritten, it stays private and holds only the new key.
write_secret_file(&path, "YKM_D")?;
assert_eq!(read_secret_file(&path)?.as_deref(), Some("YKM_D"));
Ok(())
}
#[test]
fn falls_back_only_without_a_store() {
let store = KeychainStore::new("app.yaak.test", "u").with_fallback_file("k".into());
// Failures that do not say the Secret Service is missing, such as a denied D-Bus call,
// which the backend also reports as a platform failure.
let other = Error::PlatformFailure("org.freedesktop.DBus.Error.AccessDenied".into());
let denied = Error::NoStorageAccess("prompt dismissed".into());
assert!(store.fallback_for(&other).is_none());
assert!(store.fallback_for(&denied).is_none());
assert!(store.fallback_for(&Error::NoEntry).is_none());
}
#[cfg(target_os = "linux")]
#[test]
fn falls_back_on_linux_without_a_secret_service() {
let store = KeychainStore::new("app.yaak.test", "u").with_fallback_file("k".into());
let missing = Error::PlatformFailure(Box::new(dbus_secret_service::Error::Unavailable));
assert!(store.fallback_for(&missing).is_some());
assert!(KeychainStore::new("app.yaak.test", "u").fallback_for(&missing).is_none());
let locked = Error::PlatformFailure(Box::new(dbus_secret_service::Error::Locked));
assert!(store.fallback_for(&locked).is_none());
}
}
+4
View File
@@ -24,6 +24,10 @@ ts-rs = { workspace = true, features = ["chrono-impl", "serde-json-impl"] }
yaak-core = { workspace = true }
yaak-templates = { path = "../yaak-templates", default-features = false }
[features]
# Opening databases encrypted with SQLCipher; see `yaak_database::encryption`.
sqlcipher = ["yaak-database/sqlcipher"]
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
r2d2 = "0.8.10"
r2d2_sqlite = { version = "0.32" }
+25
View File
@@ -223,6 +223,16 @@ impl BodyChunkWriter {
Ok(self.total_bytes)
}
/// [`Self::finish`] without inserting the partial last chunk: it is returned for the caller
/// to insert, in a transaction it shares with other writes.
pub fn finish_deferred(mut self) -> Option<BodyChunk> {
if self.buf.is_empty() {
return None;
}
let data = std::mem::take(&mut self.buf);
Some(BodyChunk::new(&self.body_id, self.chunk_index, data))
}
fn insert(&mut self, data: Vec<u8>) -> Result<()> {
let chunk = BodyChunk::new(&self.body_id, self.chunk_index, data);
self.blob_manager.with_tx(|b| b.insert_chunk(&chunk))?;
@@ -318,6 +328,21 @@ mod tests {
.unwrap();
}
/// The last chunk is handed back unwritten, after the full chunks written as they filled.
#[test]
fn finish_deferred_returns_the_last_chunk_unwritten() {
let manager = create_test_manager();
let mut writer = BodyChunkWriter::new(manager.clone(), "fl_1.response");
writer.write(&vec![b'a'; BODY_CHUNK_SIZE + 3]).unwrap();
let last = writer.finish_deferred().unwrap();
assert_eq!((last.body_id.as_str(), last.chunk_index), ("fl_1.response", 1));
assert_eq!(last.data, b"aaa");
assert_eq!(manager.connect().get_chunks("fl_1.response").unwrap().len(), 1);
let empty = BodyChunkWriter::new(manager.clone(), "fl_2.response");
assert!(empty.finish_deferred().is_none());
}
#[test]
fn test_insert_and_get_chunks() {
let manager = create_test_manager();
+41
View File
@@ -58,6 +58,31 @@ mod open {
std::fs::create_dir_all(parent)?;
}
let manager = SqliteConnectionManager::file(path).with_init(|c| init_file_connection(c));
build(manager, max_size, min_idle)
}
/// [`file_pool`] over a database encrypted with `key`, keying each connection first.
#[cfg(feature = "sqlcipher")]
pub fn encrypted_file_pool(
path: impl Into<PathBuf>,
max_size: u32,
min_idle: u32,
key: &yaak_database::encryption::DatabaseKey,
) -> Result<SqlitePool> {
let path: PathBuf = path.into();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
yaak_database::encryption::init_crypto();
let key = key.clone();
let manager = SqliteConnectionManager::file(path).with_init(move |c| {
yaak_database::encryption::apply_key(c, &key)?;
init_file_connection(c)
});
build(manager, max_size, min_idle)
}
fn build(manager: SqliteConnectionManager, max_size: u32, min_idle: u32) -> Result<SqlitePool> {
Pool::builder()
.max_size(max_size)
.min_idle(Some(min_idle))
@@ -160,6 +185,22 @@ pub fn init_blob_store(blob_path: impl AsRef<Path>) -> Result<BlobManager> {
Ok(BlobManager::new(blob_readers, blob_writer))
}
/// [`init_blob_store`] for a blobs database encrypted with `key`, as
/// [`yaak_database::encryption`] makes one. A plain one is converted first with
/// [`yaak_database::encryption::encrypt_in_place`].
#[cfg(all(feature = "sqlcipher", not(target_arch = "wasm32")))]
pub fn init_encrypted_blob_store(
blob_path: impl AsRef<Path>,
key: &yaak_database::encryption::DatabaseKey,
) -> Result<BlobManager> {
let blob_path = blob_path.as_ref();
info!("Initializing encrypted blobs database {blob_path:?}");
let readers = open::encrypted_file_pool(blob_path, 10, 1, key)?;
let writer = open::encrypted_file_pool(blob_path, 1, 1, key)?;
migrate_blob_db(&writer)?;
Ok(BlobManager::new(readers, writer))
}
/// Initialize the database managers with in-memory SQLite databases.
/// Useful for testing and CI environments.
///
+149 -15
View File
@@ -28,10 +28,28 @@ pub struct CaStore {
leaf_configs: Mutex<HashMap<(String, bool), Arc<ServerConfig>>>,
}
/// Encrypts the CA's private key at rest, for [`CaStore::load_or_create_encrypted`].
pub trait KeyCipher: Send + Sync {
fn encrypt(&self, data: &[u8]) -> Result<Vec<u8>, String>;
fn decrypt(&self, data: &[u8]) -> Result<Vec<u8>, String>;
}
impl CaStore {
/// Loads the CA from `dir`, or creates one there (key mode 0600) if either file is missing.
/// Safe to call from several processes at once: `ca.lock` serializes them.
pub fn load_or_create(dir: &Path) -> io::Result<Self> {
Self::open(dir, None)
}
/// [`Self::load_or_create`], keeping `ca.key` encrypted with `cipher`. A plaintext `ca.key`,
/// as builds before encryption wrote, is encrypted in place. One `cipher` cannot decrypt,
/// encrypted with a key since lost, is replaced along with the certificate: unusable, it
/// could only leave the proxy unable to start, and the new one has to be trusted again.
pub fn load_or_create_encrypted(dir: &Path, cipher: &dyn KeyCipher) -> io::Result<Self> {
Self::open(dir, Some(cipher))
}
fn open(dir: &Path, cipher: Option<&dyn KeyCipher>) -> io::Result<Self> {
let cert_path = dir.join("ca.pem");
let key_path = dir.join("ca.key");
@@ -43,22 +61,26 @@ impl CaStore {
.open(dir.join("ca.lock"))?;
lock.lock()?;
let (pem, key) = if cert_path.exists() && key_path.exists() {
let key =
KeyPair::from_pem(&fs::read_to_string(&key_path)?).map_err(io::Error::other)?;
(fs::read_to_string(&cert_path)?, key)
let stored = if cert_path.exists() && key_path.exists() {
load_key(&key_path, cipher)?
} else {
log::info!("Creating proxy CA in {}", dir.display());
let key = KeyPair::generate().map_err(io::Error::other)?;
let now = OffsetDateTime::now_utc();
let mut params = ca_params();
params.not_before = now - Duration::days(1);
params.not_after = now + Duration::days(CA_VALIDITY_DAYS);
let pem = params.self_signed(&key).map_err(io::Error::other)?.pem();
remove_if_exists(&cert_path)?;
write_atomic(&key_path, key.serialize_pem().as_bytes(), true)?;
write_atomic(&cert_path, pem.as_bytes(), false)?;
(pem, key)
None
};
let (pem, key) = match stored {
Some(key) => (fs::read_to_string(&cert_path)?, key),
None => {
log::info!("Creating proxy CA in {}", dir.display());
let key = KeyPair::generate().map_err(io::Error::other)?;
let now = OffsetDateTime::now_utc();
let mut params = ca_params();
params.not_before = now - Duration::days(1);
params.not_after = now + Duration::days(CA_VALIDITY_DAYS);
let pem = params.self_signed(&key).map_err(io::Error::other)?.pem();
remove_if_exists(&cert_path)?;
write_key(&key_path, &key, cipher)?;
write_atomic(&cert_path, pem.as_bytes(), false)?;
(pem, key)
}
};
drop(lock);
@@ -179,6 +201,48 @@ fn ca_params() -> CertificateParams {
params
}
/// The key in `ca.key`: PEM as written without a cipher, or encrypted with one. A plaintext key
/// is encrypted in place when there is a cipher. None when the cipher cannot decrypt it.
fn load_key(path: &Path, cipher: Option<&dyn KeyCipher>) -> io::Result<Option<KeyPair>> {
let stored = fs::read(path)?;
if stored.starts_with(b"-----BEGIN") {
let pem = String::from_utf8(stored).map_err(io::Error::other)?;
let key = KeyPair::from_pem(&pem).map_err(io::Error::other)?;
if cipher.is_some() {
write_key(path, &key, cipher)?;
log::info!("Encrypted the proxy CA's private key in {}", path.display());
}
return Ok(Some(key));
}
let Some(cipher) = cipher else {
return Err(io::Error::other(format!(
"{} is encrypted, with no key to read it",
path.display()
)));
};
let pem = match cipher.decrypt(&stored) {
Ok(pem) => String::from_utf8(pem).map_err(io::Error::other)?,
Err(e) => {
log::warn!(
"Failed to decrypt {} ({e}), so the proxy CA is replaced with a new one",
path.display()
);
return Ok(None);
}
};
KeyPair::from_pem(&pem).map(Some).map_err(io::Error::other)
}
/// Writes `ca.key`, mode 0600, encrypted when there is a cipher.
fn write_key(path: &Path, key: &KeyPair, cipher: Option<&dyn KeyCipher>) -> io::Result<()> {
let pem = key.serialize_pem();
let contents = match cipher {
Some(cipher) => cipher.encrypt(pem.as_bytes()).map_err(io::Error::other)?,
None => pem.into_bytes(),
};
write_atomic(path, &contents, true)
}
fn remove_if_exists(path: &Path) -> io::Result<()> {
match fs::remove_file(path) {
Err(e) if e.kind() != io::ErrorKind::NotFound => Err(e),
@@ -216,6 +280,76 @@ mod tests {
use super::*;
use std::sync::mpsc;
/// Flips every bit behind a marker that names the key, so a wrong key fails to decrypt.
struct TestCipher(u8);
impl KeyCipher for TestCipher {
fn encrypt(&self, data: &[u8]) -> Result<Vec<u8>, String> {
Ok([b'k', self.0].into_iter().chain(data.iter().map(|b| !b)).collect())
}
fn decrypt(&self, data: &[u8]) -> Result<Vec<u8>, String> {
let rest = data.strip_prefix(&[b'k', self.0][..]).ok_or("wrong key")?;
Ok(rest.iter().map(|b| !b).collect())
}
}
fn private(path: &Path) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(fs::metadata(path).unwrap().permissions().mode() & 0o777, 0o600);
}
#[cfg(not(unix))]
let _ = path;
}
#[test]
fn keeps_the_key_encrypted() {
let dir = tempfile::tempdir().unwrap();
let created = CaStore::load_or_create_encrypted(dir.path(), &TestCipher(1)).unwrap();
let stored = fs::read(created.key_path()).unwrap();
assert!(!stored.windows(11).any(|w| w == b"PRIVATE KEY"), "ca.key is plaintext");
private(created.key_path());
// The certificate is public, and stays as it was.
assert!(fs::read_to_string(created.cert_path()).unwrap().starts_with("-----BEGIN"));
let loaded = CaStore::load_or_create_encrypted(dir.path(), &TestCipher(1)).unwrap();
assert_eq!(loaded.pem(), created.pem());
assert_eq!(fs::read(loaded.key_path()).unwrap(), stored, "rewritten");
loaded.server_config("example.test", true).unwrap();
let err = CaStore::load_or_create(dir.path()).err().unwrap();
assert!(err.to_string().ends_with("ca.key is encrypted, with no key to read it"), "{err}");
}
#[test]
fn encrypts_a_plaintext_key_in_place() {
let dir = tempfile::tempdir().unwrap();
let plain = CaStore::load_or_create(dir.path()).unwrap();
let pem_key = fs::read_to_string(plain.key_path()).unwrap();
let encrypted = CaStore::load_or_create_encrypted(dir.path(), &TestCipher(1)).unwrap();
assert_eq!(encrypted.pem(), plain.pem(), "the same CA, still trusted");
let stored = fs::read(encrypted.key_path()).unwrap();
assert_eq!(TestCipher(1).decrypt(&stored).unwrap(), pem_key.as_bytes());
private(encrypted.key_path());
assert!(!dir.path().join("ca.key.tmp").exists());
}
#[test]
fn replaces_a_ca_whose_key_cannot_be_decrypted() {
let dir = tempfile::tempdir().unwrap();
let lost = CaStore::load_or_create_encrypted(dir.path(), &TestCipher(1)).unwrap();
let replaced = CaStore::load_or_create_encrypted(dir.path(), &TestCipher(2)).unwrap();
assert_ne!(replaced.pem(), lost.pem());
let stored = fs::read(replaced.key_path()).unwrap();
assert!(TestCipher(2).decrypt(&stored).is_ok());
let again = CaStore::load_or_create_encrypted(dir.path(), &TestCipher(2)).unwrap();
assert_eq!(again.pem(), replaced.pem());
}
/// A host whose leaf is still being signed holds up no lookup of another host's.
#[test]
fn signing_one_host_does_not_block_another() {
+1 -1
View File
@@ -30,7 +30,7 @@ use rustls::ClientConfig;
use rustls::pki_types::CertificateDer;
use tokio::net::TcpListener;
pub use cert::CaStore;
pub use cert::{CaStore, KeyCipher};
use connection::handle_connection;
pub use grpc::{detect as detect_grpc, service_and_method};
pub use messages::{MessageDirection, MessageKind, RAW_LIMIT, StreamKind, StreamMessage};