mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-11-13 12:13:42 +01:00
Hosts, apps and entries named in the why and opened-before lines render as InlineCode, an info button beside the why line explains decryption in a tooltip, and the "New connections only" line is gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
416 lines
14 KiB
TypeScript
416 lines
14 KiB
TypeScript
import type { DecryptListEntry, Flow } from "@yaakapp-internal/proxy-lib";
|
|
import {
|
|
type DecryptQuery,
|
|
type DecryptSubject,
|
|
decrypts,
|
|
decryptsAnything,
|
|
detailActions,
|
|
EVERYTHING,
|
|
entryMatches,
|
|
entrySubject,
|
|
listEdit,
|
|
setDecrypted,
|
|
subjectEntry,
|
|
} from "./decrypt";
|
|
import { hostnameOf } from "./search";
|
|
|
|
/** Where an encrypted connection went, and the app that opened it, as the list judges it */
|
|
export interface Connection {
|
|
/** The host alone, an IPv6 address without brackets */
|
|
host: string;
|
|
port: number;
|
|
/** The host as a button names it: with its port when that is not 443 */
|
|
label: string;
|
|
/** `host:port`, an IPv6 host in brackets, as the engine's pass-through set lists it */
|
|
address: string;
|
|
app: { name: string; bundleId: string | null } | null;
|
|
}
|
|
|
|
type ConnectionFlow = Pick<Flow, "url" | "clientName" | "clientBundleId">;
|
|
|
|
/** The connection a tunnel or a refused handshake records: `https://host:port` or `host:port` */
|
|
export function connectionOf(flow: ConnectionFlow): Connection {
|
|
const scheme = flow.url.indexOf("://");
|
|
const rest = scheme === -1 ? flow.url : flow.url.slice(scheme + 3);
|
|
const authority = (rest.split(/[/?#]/)[0] ?? "").toLowerCase();
|
|
const name = hostnameOf(authority);
|
|
const portText = authority.slice(name.length + 1);
|
|
const port = /^\d+$/.test(portText) ? Number(portText) : 443;
|
|
const host = name.replace(/^\[|\]$/g, "");
|
|
return {
|
|
host,
|
|
port,
|
|
label: port === 443 ? name : `${name}:${port}`,
|
|
address: `${name}:${port}`,
|
|
app: flow.clientName == null ? null : { name: flow.clientName, bundleId: flow.clientBundleId },
|
|
};
|
|
}
|
|
|
|
function queryOf(c: Connection): DecryptQuery {
|
|
return { host: c.host, port: c.port, app: c.app };
|
|
}
|
|
|
|
/** What a button on the card does */
|
|
export type CardActionKind =
|
|
| "decrypt"
|
|
| "stop"
|
|
| "reconnect"
|
|
| "trust"
|
|
| "try_again"
|
|
| "copy_environment";
|
|
|
|
export interface CardAction {
|
|
kind: CardActionKind;
|
|
label: string;
|
|
/** What it writes, shown beside it: an entry, or a hint like `next connection` */
|
|
hint: string | null;
|
|
/** The hint is an entry it takes off the list, so it is struck through */
|
|
removes?: boolean;
|
|
/** The list change, for `decrypt` and `stop` */
|
|
edit?: { add: string[]; remove: string[] };
|
|
/** What the toast says was chosen */
|
|
toast?: { message: string; sub?: string };
|
|
/** The suggested action */
|
|
primary?: boolean;
|
|
/** The narrowest override of an exclusion that ships for a reason */
|
|
dashed?: boolean;
|
|
/** What the icon shows: a host, the app, everything, or the action's own */
|
|
icon: "host" | "app" | "everything" | "lock" | "lock_open" | "refresh" | "shield" | "terminal";
|
|
}
|
|
|
|
/** A line of text, the hosts, apps and entries in it shown as code, as Yaak shows literal values */
|
|
export type Line = (string | { code: string })[];
|
|
|
|
/** A line as plain text, its code in backticks */
|
|
export function lineText(line: Line): string {
|
|
return line.map((part) => (typeof part === "string" ? part : `\`${part.code}\``)).join("");
|
|
}
|
|
|
|
export interface EncryptedCardModel {
|
|
rejected: boolean;
|
|
/** Why the connection is encrypted, in one line */
|
|
why: Line;
|
|
/** Quieter, after it */
|
|
whySub: string | null;
|
|
/** A fact about the connection since the list changed: when it opened */
|
|
fact: Line | null;
|
|
actions: CardAction[];
|
|
/** Small lines under the buttons */
|
|
foot: string[];
|
|
}
|
|
|
|
export interface EncryptedCardInput {
|
|
flow: Pick<
|
|
Flow,
|
|
| "url"
|
|
| "clientName"
|
|
| "clientBundleId"
|
|
| "clientPath"
|
|
| "tunnelReason"
|
|
| "tunnelExcludedBy"
|
|
| "tunnelExcludedBuiltIn"
|
|
| "tlsRejected"
|
|
| "elapsedMs"
|
|
| "error"
|
|
>;
|
|
list: DecryptListEntry[];
|
|
/** The certificate is trusted, null until read */
|
|
trusted: boolean | null;
|
|
/** The app can trust the certificate itself, as on macOS */
|
|
canTrust: boolean;
|
|
/** The addresses passed through after a rejection, null until read */
|
|
passthrough: string[] | null;
|
|
/** For a tunnel passed through after a rejection: the client that refused, when known */
|
|
rejectedBy?: Pick<Flow, "clientName" | "clientBundleId" | "clientPath"> | null;
|
|
}
|
|
|
|
/** A refused handshake, or a tunnel passed through because of one */
|
|
export function isRejection(flow: Pick<Flow, "tlsRejected" | "tunnelReason">): boolean {
|
|
return flow.tlsRejected != null || flow.tunnelReason === "rejected";
|
|
}
|
|
|
|
/** A tunnel still open: written as it opens, done once it closes */
|
|
export function isOpen(flow: Pick<Flow, "elapsedMs" | "error">): boolean {
|
|
return flow.elapsedMs == null && flow.error == null;
|
|
}
|
|
|
|
/**
|
|
* The card for an HTTPS connection the proxy did not decrypt: why, from what the engine recorded
|
|
* as it opened, and the changes to the list that would decrypt the next one like it, judged
|
|
* against the list as it is now. Once the list decrypts it, the card says it opened before and
|
|
* offers the opposite, and Reconnect while it is open. Nothing on it narrates what happens next.
|
|
*/
|
|
export function encryptedCard(input: EncryptedCardInput): EncryptedCardModel {
|
|
return isRejection(input.flow) ? rejectedCard(input) : tunnelCard(input);
|
|
}
|
|
|
|
function tunnelCard({ flow, list, trusted, canTrust }: EncryptedCardInput): EncryptedCardModel {
|
|
const c = connectionOf(flow);
|
|
const q = queryOf(c);
|
|
const decryptsNow = decrypts(list, q);
|
|
const { why, whySub } = whyLine(flow, list, c, decryptsNow);
|
|
const foot: string[] = [];
|
|
|
|
if (decryptsNow) {
|
|
// Decrypted from the next connection on: the entries that do it, each to take back.
|
|
const decrypting = list.filter(
|
|
(e) => !e.builtIn && !e.entry.startsWith("!") && entryMatches(e.entry, q),
|
|
);
|
|
const actions: CardAction[] = decrypting.map((e) => stopEntry(e.entry));
|
|
if (isOpen(flow)) {
|
|
actions.unshift({ kind: "reconnect", label: "Reconnect", hint: null, icon: "refresh" });
|
|
}
|
|
const first = decrypting[0]?.entry;
|
|
const what: Line[number] =
|
|
first === EVERYTHING ? "everything" : { code: first == null ? c.label : entrySubject(first) };
|
|
return {
|
|
rejected: false,
|
|
why,
|
|
whySub,
|
|
fact: ["Opened before ", what, " was decrypted"],
|
|
actions,
|
|
foot: [],
|
|
};
|
|
}
|
|
|
|
const actions: CardAction[] = [];
|
|
const builtInBlock = list.find(
|
|
(e) => e.builtIn && e.entry.startsWith("!") && entryMatches(e.entry, q),
|
|
);
|
|
const hostSubject: DecryptSubject = { kind: "host", host: c.label };
|
|
const judged = detailActions(list, c.label, c.app);
|
|
if (builtInBlock != null) {
|
|
// Kept out by default for a reason: only the host itself, the narrowest override.
|
|
if (judged.host) {
|
|
actions.push({ ...decryptSubject(list, hostSubject, c.label, "host"), dashed: true });
|
|
foot.length = 0;
|
|
foot.push(
|
|
builtInBlock.kind === "apple" || c.app == null
|
|
? "macOS may refuse it"
|
|
: `${c.app.name} may refuse it`,
|
|
);
|
|
}
|
|
} else {
|
|
if (judged.host) actions.push(decryptSubject(list, hostSubject, c.label, "host"));
|
|
if (judged.app && c.app != null) {
|
|
actions.push(decryptSubject(list, { kind: "app", ...c.app }, c.app.name, "app"));
|
|
}
|
|
const everything = list.some((e) => e.entry === EVERYTHING);
|
|
if (!everything && decrypts([{ entry: EVERYTHING, builtIn: false }, ...list], q)) {
|
|
actions.push({
|
|
kind: "decrypt",
|
|
label: "Decrypt Everything",
|
|
hint: EVERYTHING,
|
|
edit: { add: [EVERYTHING], remove: [] },
|
|
toast: { message: "Decrypting everything", sub: "Except Apple services and apps that pin" },
|
|
icon: "everything",
|
|
});
|
|
}
|
|
if (actions.length === 0) {
|
|
// The person's own exclusions keep it out: taking them off, when that alone decrypts it.
|
|
const own = list.filter(
|
|
(e) => !e.builtIn && e.entry.startsWith("!") && entryMatches(e.entry, q),
|
|
);
|
|
const without = list.filter((e) => !own.includes(e));
|
|
if (own.length > 0 && decrypts(without, q)) {
|
|
const [only] = own;
|
|
const label =
|
|
own.length === 1 && only != null
|
|
? `Stop Excluding ${entrySubject(only.entry.slice(1))}`
|
|
: `Stop Excluding ${own.length} Entries`;
|
|
actions.push({
|
|
kind: "decrypt",
|
|
label,
|
|
hint: own.map((e) => e.entry).join(", "),
|
|
removes: true,
|
|
edit: { add: [], remove: own.map((e) => e.entry) },
|
|
toast: { message: label.replace("Stop Excluding", "Stopped excluding") },
|
|
icon: "lock_open",
|
|
});
|
|
}
|
|
}
|
|
}
|
|
if (actions.length > 0 && canTrust && trusted === false) foot.push("Asks for your password once");
|
|
return {
|
|
rejected: false,
|
|
why,
|
|
whySub,
|
|
fact: null,
|
|
actions,
|
|
foot: actions.length === 0 ? [] : foot,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The why line, from the reason the engine recorded as the tunnel opened. One that nothing on
|
|
* the list covers names its host; once the list covers it, it opened before that.
|
|
*/
|
|
function whyLine(
|
|
flow: EncryptedCardInput["flow"],
|
|
list: DecryptListEntry[],
|
|
c: Connection,
|
|
decryptsNow: boolean,
|
|
): { why: Line; whySub: string | null } {
|
|
if (flow.tunnelReason === "excluded" && flow.tunnelExcludedBy != null) {
|
|
if (flow.tunnelExcludedBuiltIn) {
|
|
const kind = list.find((e) => e.entry === flow.tunnelExcludedBy)?.kind;
|
|
const whySub =
|
|
kind === "apple" ? "Apple service" : kind === "pinned" ? "pins its certificate" : null;
|
|
return { why: ["Excluded by default"], whySub };
|
|
}
|
|
return { why: ["Excluded by ", { code: flow.tunnelExcludedBy }], whySub: null };
|
|
}
|
|
return {
|
|
why: !decryptsAnything(list)
|
|
? ["Nothing is decrypted yet"]
|
|
: decryptsNow
|
|
? ["Not on the decrypt list"]
|
|
: ["Decryption not configured for ", { code: c.label }],
|
|
whySub: null,
|
|
};
|
|
}
|
|
|
|
function decryptSubject(
|
|
list: DecryptListEntry[],
|
|
subject: DecryptSubject,
|
|
name: string,
|
|
icon: CardAction["icon"],
|
|
): CardAction {
|
|
return {
|
|
kind: "decrypt",
|
|
label: `Decrypt ${name}`,
|
|
hint: subjectEntry(subject),
|
|
edit: listEdit(list, setDecrypted(list, [subject], true)),
|
|
toast: { message: `Decrypting ${name}` },
|
|
icon,
|
|
};
|
|
}
|
|
|
|
function stopEntry(entry: string): CardAction {
|
|
const name = entrySubject(entry);
|
|
return {
|
|
kind: "stop",
|
|
label: `Stop Decrypting ${name}`,
|
|
hint: entry,
|
|
removes: true,
|
|
edit: { add: [], remove: [entry] },
|
|
toast: { message: `Stopped decrypting ${entry === EVERYTHING ? "everything" : name}` },
|
|
icon: "lock",
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Stop Decrypting on a refused connection, judged with its host, port and app together: takes
|
|
* off the person's entry naming exactly this host when that alone stops it, else excludes the
|
|
* app when it is known, else the host, which the list always honours. Null when the list does
|
|
* not decrypt it.
|
|
*/
|
|
export function stopDecryptingConnection(
|
|
list: DecryptListEntry[],
|
|
connection: Connection,
|
|
): CardAction | null {
|
|
const q = queryOf(connection);
|
|
if (!decrypts(list, q)) return null;
|
|
const hostEntry = subjectEntry({ kind: "host", host: connection.label });
|
|
const own = list.find((e) => !e.builtIn && e.entry === hostEntry);
|
|
if (
|
|
own != null &&
|
|
!decrypts(
|
|
list.filter((e) => e !== own),
|
|
q,
|
|
)
|
|
) {
|
|
return { ...stopEntry(own.entry), label: `Stop Decrypting ${connection.label}` };
|
|
}
|
|
const name = connection.app?.name ?? connection.label;
|
|
const add = connection.app == null ? `!${hostEntry}` : `!app:${connection.app.name}`;
|
|
return {
|
|
kind: "stop",
|
|
label: `Stop Decrypting ${name}`,
|
|
hint: add,
|
|
edit: { add: [add], remove: [] },
|
|
toast: { message: `Stopped decrypting ${name}` },
|
|
icon: "lock",
|
|
};
|
|
}
|
|
|
|
/** Who refused: an app with a bundle, a program without one, or a client the app couldn't name */
|
|
type Refuser =
|
|
| { kind: "app"; name: string }
|
|
| { kind: "program"; name: string }
|
|
| { kind: "unknown" };
|
|
|
|
function refuserOf(
|
|
client: Pick<Flow, "clientName" | "clientBundleId" | "clientPath"> | null | undefined,
|
|
): Refuser {
|
|
if (client?.clientName == null) return { kind: "unknown" };
|
|
if (client.clientBundleId != null) return { kind: "app", name: client.clientName };
|
|
return { kind: "program", name: client.clientName };
|
|
}
|
|
|
|
function rejectedCard(input: EncryptedCardInput): EncryptedCardModel {
|
|
const { flow, list, trusted, canTrust, passthrough } = input;
|
|
const c = connectionOf(flow);
|
|
// The refused handshake names its own client; a tunnel after it, the one that refused.
|
|
const own = flow.tlsRejected != null;
|
|
const refuser = refuserOf(own ? flow : input.rejectedBy);
|
|
const stop = stopDecryptingConnection(list, c);
|
|
const passedThrough = passthrough == null ? null : passthrough.includes(c.address);
|
|
const tryAgain: CardAction | null =
|
|
passedThrough === false
|
|
? null
|
|
: { kind: "try_again", label: "Try Again", hint: "next connection", icon: "refresh" };
|
|
const until = passedThrough === false ? "No longer passed through" : null;
|
|
|
|
if (canTrust && trusted === false && decryptsAnything(list)) {
|
|
return {
|
|
rejected: true,
|
|
why: ["Certificate not trusted"],
|
|
whySub: null,
|
|
fact: null,
|
|
actions: [
|
|
{ kind: "trust", label: "Trust Certificate", hint: null, primary: true, icon: "shield" },
|
|
...(stop == null ? [] : [stop]),
|
|
],
|
|
foot: [until ?? "Passed through until trusted"],
|
|
};
|
|
}
|
|
|
|
const forHost = own ? "" : " for this host";
|
|
if (refuser.kind === "program") {
|
|
return {
|
|
rejected: true,
|
|
why: [`${refuser.name} rejected the certificate${forHost}`],
|
|
whySub: "uses its own trust store",
|
|
fact: null,
|
|
actions: [
|
|
{
|
|
kind: "copy_environment",
|
|
label: "Copy Environment",
|
|
hint: "SSL_CERT_FILE, …",
|
|
primary: true,
|
|
icon: "terminal",
|
|
},
|
|
...(stop == null ? [] : [stop]),
|
|
...(tryAgain == null ? [] : [tryAgain]),
|
|
],
|
|
foot: until == null ? [`Then rerun ${refuser.name}`] : [until],
|
|
};
|
|
}
|
|
return {
|
|
rejected: true,
|
|
why: [
|
|
refuser.kind === "app"
|
|
? `${refuser.name} rejected the certificate${forHost}`
|
|
: "A client rejected the certificate",
|
|
],
|
|
whySub: refuser.kind === "app" ? "likely pinned" : null,
|
|
fact: null,
|
|
actions: [
|
|
...(stop == null ? [] : [{ ...stop, primary: true }]),
|
|
...(tryAgain == null ? [] : [tryAgain]),
|
|
],
|
|
foot: [until ?? "Passed through until relaunch"],
|
|
};
|
|
}
|