mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-11-13 12:13:42 +01:00
Yaak Proxy: code-styled names and an info button in the encrypted card's why line
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>
This commit is contained in:
1 parent
ae666adfb7
commit
f2555bdcd5
3 files changed
+90
-54
No files matched your search
@@ -9,6 +9,8 @@ import {
|
||||
IconButton,
|
||||
type IconProps,
|
||||
InlineCode,
|
||||
Tooltip,
|
||||
CONTROL_TOOLTIP_DELAY,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { type ReactNode, useEffect, useState } from "react";
|
||||
@@ -22,6 +24,7 @@ import {
|
||||
encryptedCard,
|
||||
isOpen,
|
||||
isRejection,
|
||||
type Line,
|
||||
} from "../lib/encryptedCard";
|
||||
import { useFlowActions } from "../lib/flowActions";
|
||||
import { utcTimestamp } from "../lib/flowViews";
|
||||
@@ -122,17 +125,22 @@ export function EncryptedCard({ flow }: { flow: Flow }) {
|
||||
<div className="mx-3 border-t border-border-subtle overflow-auto">
|
||||
<div className="mx-auto max-w-[27rem] py-10 grid gap-2">
|
||||
<div className="text-sm text-text mb-1">
|
||||
{model.why}
|
||||
{model.whyEntry != null && (
|
||||
<>
|
||||
{" "}
|
||||
<InlineCode>{model.whyEntry}</InlineCode>
|
||||
</>
|
||||
)}
|
||||
<LineText line={model.why} />
|
||||
{model.whySub != null && <span className="text-text-subtle"> · {model.whySub}</span>}
|
||||
<Tooltip wrap content={ABOUT} delay={CONTROL_TOOLTIP_DELAY}>
|
||||
<IconButton
|
||||
size="2xs"
|
||||
icon="info"
|
||||
iconColor="secondary"
|
||||
title="About decryption"
|
||||
className="ml-1 align-middle inline-flex!"
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{model.fact != null && (
|
||||
<div className="text-sm text-text-subtle -mt-1 mb-1">{model.fact}</div>
|
||||
<div className="text-sm text-text-subtle -mt-1 mb-1">
|
||||
<LineText line={model.fact} />
|
||||
</div>
|
||||
)}
|
||||
{model.actions.map((action) => (
|
||||
<ActionButton
|
||||
@@ -165,6 +173,27 @@ export function EncryptedCard({ flow }: { flow: Flow }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** What the info button beside the why line says */
|
||||
const ABOUT =
|
||||
"HTTPS is encrypted between the app and the server. Yaak Proxy shows requests only for hosts and apps you decrypt, from their next connection on.";
|
||||
|
||||
/** A why or fact line, its hosts, apps and entries shown as code */
|
||||
function LineText({ line }: { line: Line }) {
|
||||
return (
|
||||
<>
|
||||
{line.map((part, i) =>
|
||||
typeof part === "string" ? (
|
||||
// oxlint-disable-next-line react/no-array-index-key -- the parts of one fixed line
|
||||
<span key={i}>{part}</span>
|
||||
) : (
|
||||
// oxlint-disable-next-line react/no-array-index-key -- the parts of one fixed line
|
||||
<InlineCode key={i}>{part.code}</InlineCode>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const ICONS: Record<Exclude<CardAction["icon"], "app">, IconProps["icon"]> = {
|
||||
host: "globe",
|
||||
everything: "layers",
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
connectionOf,
|
||||
type EncryptedCardInput,
|
||||
encryptedCard,
|
||||
lineText,
|
||||
stopDecryptingConnection,
|
||||
} from "./encryptedCard";
|
||||
|
||||
@@ -67,7 +68,7 @@ describe("connectionOf", () => {
|
||||
describe("the card of an encrypted connection", () => {
|
||||
test("nothing decrypted yet: the host, the app and everything, each with what it writes", () => {
|
||||
const c = card();
|
||||
expect(c.why).toBe("Nothing is decrypted yet");
|
||||
expect(lineText(c.why)).toBe("Nothing is decrypted yet");
|
||||
expect(summary(c)).toEqual([
|
||||
[
|
||||
"Decrypt www.google.com",
|
||||
@@ -77,21 +78,19 @@ describe("the card of an encrypted connection", () => {
|
||||
["Decrypt Dia", "app:Dia", { add: ["app:Dia"], remove: [] }],
|
||||
["Decrypt Everything", "*", { add: ["*"], remove: [] }],
|
||||
]);
|
||||
expect(c.foot).toEqual(["New connections only"]);
|
||||
// Nothing under the buttons while the certificate is trusted
|
||||
expect(c.foot).toEqual([]);
|
||||
expect(c.fact).toBeNull();
|
||||
});
|
||||
|
||||
test("asks for the password once while the certificate isn't trusted, where it can be", () => {
|
||||
expect(card({ trusted: false }).foot).toEqual([
|
||||
"New connections only",
|
||||
"Asks for your password once",
|
||||
]);
|
||||
expect(card({ trusted: false, canTrust: false }).foot).toEqual(["New connections only"]);
|
||||
expect(card({ trusted: false }).foot).toEqual(["Asks for your password once"]);
|
||||
expect(card({ trusted: false, canTrust: false }).foot).toEqual([]);
|
||||
});
|
||||
|
||||
test("something else listed: not on the list, with the same choices", () => {
|
||||
const c = card({ list: [...user("host:api.stripe.com"), ...BUILT_INS] });
|
||||
expect(c.why).toBe("Decryption not configured for www.google.com");
|
||||
expect(lineText(c.why)).toBe("Decryption not configured for `www.google.com`");
|
||||
expect(c.actions.map((a) => a.label)).toEqual([
|
||||
"Decrypt www.google.com",
|
||||
"Decrypt Dia",
|
||||
@@ -108,11 +107,11 @@ describe("the card of an encrypted connection", () => {
|
||||
|
||||
test("names the host nothing on the list covers, its port only when not 443", () => {
|
||||
const list = [...user("host:api.stripe.com"), ...BUILT_INS];
|
||||
expect(card({ list, flow: { ...tunnel, url: "t.yaak.app:443" } }).why).toBe(
|
||||
"Decryption not configured for t.yaak.app",
|
||||
expect(lineText(card({ list, flow: { ...tunnel, url: "t.yaak.app:443" } }).why)).toBe(
|
||||
"Decryption not configured for `t.yaak.app`",
|
||||
);
|
||||
expect(card({ list, flow: { ...tunnel, url: "home.ts.net:8317" } }).why).toBe(
|
||||
"Decryption not configured for home.ts.net:8317",
|
||||
expect(lineText(card({ list, flow: { ...tunnel, url: "home.ts.net:8317" } }).why)).toBe(
|
||||
"Decryption not configured for `home.ts.net:8317`",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -139,7 +138,10 @@ describe("the card of an encrypted connection", () => {
|
||||
tunnelExcludedBuiltIn: true,
|
||||
},
|
||||
});
|
||||
expect([pinned.why, pinned.whySub]).toEqual(["Excluded by default", "pins its certificate"]);
|
||||
expect([lineText(pinned.why), pinned.whySub]).toEqual([
|
||||
"Excluded by default",
|
||||
"pins its certificate",
|
||||
]);
|
||||
expect(summary(pinned)).toEqual([
|
||||
[
|
||||
"Decrypt my.1password.com",
|
||||
@@ -171,7 +173,7 @@ describe("the card of an encrypted connection", () => {
|
||||
list,
|
||||
flow: { ...tunnel, tunnelReason: "excluded", tunnelExcludedBy: "!host:*.google.com" },
|
||||
});
|
||||
expect([c.why, c.whyEntry]).toEqual(["Excluded by", "!host:*.google.com"]);
|
||||
expect(lineText(c.why)).toBe("Excluded by `!host:*.google.com`");
|
||||
// Nothing added wins over the person's own exclusion, so taking it off is the way in
|
||||
expect(summary(c)).toEqual([
|
||||
[
|
||||
@@ -204,8 +206,10 @@ describe("the card of an encrypted connection", () => {
|
||||
test("once the list decrypts it: opened before, the opposite, and Reconnect while open", () => {
|
||||
const list = [...user("host:www.google.com"), ...BUILT_INS];
|
||||
const closed = card({ list });
|
||||
expect(closed.why).toBe("Not on the decrypt list");
|
||||
expect(closed.fact).toBe("Opened before www.google.com was decrypted");
|
||||
expect(lineText(closed.why)).toBe("Not on the decrypt list");
|
||||
expect(closed.fact && lineText(closed.fact)).toBe(
|
||||
"Opened before `www.google.com` was decrypted",
|
||||
);
|
||||
expect(summary(closed)).toEqual([
|
||||
[
|
||||
"Stop Decrypting www.google.com",
|
||||
@@ -219,7 +223,9 @@ describe("the card of an encrypted connection", () => {
|
||||
expect(open.actions.map((a) => a.kind)).toEqual(["reconnect", "stop"]);
|
||||
|
||||
const everything = card({ list: [...user("*"), ...BUILT_INS] });
|
||||
expect(everything.fact).toBe("Opened before everything was decrypted");
|
||||
expect(everything.fact && lineText(everything.fact)).toBe(
|
||||
"Opened before everything was decrypted",
|
||||
);
|
||||
expect(everything.actions[0]?.label).toBe("Stop Decrypting Everything");
|
||||
});
|
||||
});
|
||||
@@ -240,7 +246,7 @@ describe("the card of a refused handshake", () => {
|
||||
test("an app: likely pinned, Stop Decrypting first, Try Again while passed through", () => {
|
||||
const c = card({ flow: refused, list: listed, passthrough: ["sync.acme.dev:443"] });
|
||||
expect(c.rejected).toBe(true);
|
||||
expect([c.why, c.whySub]).toEqual(["Acme rejected the certificate", "likely pinned"]);
|
||||
expect([lineText(c.why), c.whySub]).toEqual(["Acme rejected the certificate", "likely pinned"]);
|
||||
expect(summary(c)).toEqual([
|
||||
["Stop Decrypting Acme", "!app:Acme", { add: ["!app:Acme"], remove: [] }],
|
||||
["Try Again", "next connection", null],
|
||||
@@ -264,7 +270,7 @@ describe("the card of a refused handshake", () => {
|
||||
clientPath: "/opt/homebrew/bin/python3",
|
||||
};
|
||||
const c = card({ flow: python, list: listed, passthrough: ["pypi.org:443"] });
|
||||
expect([c.why, c.whySub]).toEqual([
|
||||
expect([lineText(c.why), c.whySub]).toEqual([
|
||||
"python3 rejected the certificate",
|
||||
"uses its own trust store",
|
||||
]);
|
||||
@@ -281,7 +287,7 @@ describe("the card of a refused handshake", () => {
|
||||
flow: { ...refused, clientName: null, clientBundleId: null, clientPath: null },
|
||||
list: listed,
|
||||
});
|
||||
expect(c.why).toBe("A client rejected the certificate");
|
||||
expect(lineText(c.why)).toBe("A client rejected the certificate");
|
||||
expect(summary(c)[0]).toEqual([
|
||||
"Stop Decrypting sync.acme.dev",
|
||||
"!host:sync.acme.dev",
|
||||
@@ -296,7 +302,7 @@ describe("the card of a refused handshake", () => {
|
||||
trusted: false,
|
||||
passthrough: ["sync.acme.dev:443"],
|
||||
});
|
||||
expect(c.why).toBe("Certificate not trusted");
|
||||
expect(lineText(c.why)).toBe("Certificate not trusted");
|
||||
expect(c.actions.map((a) => a.kind)).toEqual(["trust", "stop"]);
|
||||
expect(c.foot).toEqual(["Passed through until trusted"]);
|
||||
});
|
||||
@@ -312,7 +318,7 @@ describe("the card of a refused handshake", () => {
|
||||
list: listed,
|
||||
rejectedBy: { clientName: "Acme", clientBundleId: "dev.acme", clientPath: null },
|
||||
});
|
||||
expect(c.why).toBe("Acme rejected the certificate for this host");
|
||||
expect(lineText(c.why)).toBe("Acme rejected the certificate for this host");
|
||||
// Stop Decrypting acts on this connection, so on Dia's own
|
||||
expect(c.actions[0]?.hint).toBe("!app:Dia");
|
||||
});
|
||||
|
||||
@@ -78,16 +78,22 @@ export interface CardAction {
|
||||
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: string;
|
||||
why: Line;
|
||||
/** Quieter, after it */
|
||||
whySub: string | null;
|
||||
/** The exclusion that kept it out, when the person's own, shown as code after "Excluded by" */
|
||||
whyEntry: string | null;
|
||||
/** A fact about the connection since the list changed: when it opened */
|
||||
fact: string | null;
|
||||
fact: Line | null;
|
||||
actions: CardAction[];
|
||||
/** Small lines under the buttons */
|
||||
foot: string[];
|
||||
@@ -142,8 +148,8 @@ function tunnelCard({ flow, list, trusted, canTrust }: EncryptedCardInput): Encr
|
||||
const c = connectionOf(flow);
|
||||
const q = queryOf(c);
|
||||
const decryptsNow = decrypts(list, q);
|
||||
const { why, whySub, whyEntry } = whyLine(flow, list, c, decryptsNow);
|
||||
const foot = ["New connections only"];
|
||||
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.
|
||||
@@ -155,14 +161,13 @@ function tunnelCard({ flow, list, trusted, canTrust }: EncryptedCardInput): Encr
|
||||
actions.unshift({ kind: "reconnect", label: "Reconnect", hint: null, icon: "refresh" });
|
||||
}
|
||||
const first = decrypting[0]?.entry;
|
||||
const what =
|
||||
first == null ? c.label : first === EVERYTHING ? "everything" : entrySubject(first);
|
||||
const what: Line[number] =
|
||||
first === EVERYTHING ? "everything" : { code: first == null ? c.label : entrySubject(first) };
|
||||
return {
|
||||
rejected: false,
|
||||
why,
|
||||
whySub,
|
||||
whyEntry,
|
||||
fact: `Opened before ${what} was decrypted`,
|
||||
fact: ["Opened before ", what, " was decrypted"],
|
||||
actions,
|
||||
foot: [],
|
||||
};
|
||||
@@ -230,7 +235,6 @@ function tunnelCard({ flow, list, trusted, canTrust }: EncryptedCardInput): Encr
|
||||
rejected: false,
|
||||
why,
|
||||
whySub,
|
||||
whyEntry,
|
||||
fact: null,
|
||||
actions,
|
||||
foot: actions.length === 0 ? [] : foot,
|
||||
@@ -246,24 +250,23 @@ function whyLine(
|
||||
list: DecryptListEntry[],
|
||||
c: Connection,
|
||||
decryptsNow: boolean,
|
||||
): { why: string; whySub: string | null; whyEntry: string | null } {
|
||||
): { 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, whyEntry: null };
|
||||
return { why: ["Excluded by default"], whySub };
|
||||
}
|
||||
return { why: "Excluded by", whySub: null, whyEntry: flow.tunnelExcludedBy };
|
||||
return { why: ["Excluded by ", { code: flow.tunnelExcludedBy }], whySub: null };
|
||||
}
|
||||
return {
|
||||
why: !decryptsAnything(list)
|
||||
? "Nothing is decrypted yet"
|
||||
? ["Nothing is decrypted yet"]
|
||||
: decryptsNow
|
||||
? "Not on the decrypt list"
|
||||
: `Decryption not configured for ${c.label}`,
|
||||
? ["Not on the decrypt list"]
|
||||
: ["Decryption not configured for ", { code: c.label }],
|
||||
whySub: null,
|
||||
whyEntry: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -362,9 +365,8 @@ function rejectedCard(input: EncryptedCardInput): EncryptedCardModel {
|
||||
if (canTrust && trusted === false && decryptsAnything(list)) {
|
||||
return {
|
||||
rejected: true,
|
||||
why: "Certificate not trusted",
|
||||
why: ["Certificate not trusted"],
|
||||
whySub: null,
|
||||
whyEntry: null,
|
||||
fact: null,
|
||||
actions: [
|
||||
{ kind: "trust", label: "Trust Certificate", hint: null, primary: true, icon: "shield" },
|
||||
@@ -378,9 +380,8 @@ function rejectedCard(input: EncryptedCardInput): EncryptedCardModel {
|
||||
if (refuser.kind === "program") {
|
||||
return {
|
||||
rejected: true,
|
||||
why: `${refuser.name} rejected the certificate${forHost}`,
|
||||
why: [`${refuser.name} rejected the certificate${forHost}`],
|
||||
whySub: "uses its own trust store",
|
||||
whyEntry: null,
|
||||
fact: null,
|
||||
actions: [
|
||||
{
|
||||
@@ -398,12 +399,12 @@ function rejectedCard(input: EncryptedCardInput): EncryptedCardModel {
|
||||
}
|
||||
return {
|
||||
rejected: true,
|
||||
why:
|
||||
why: [
|
||||
refuser.kind === "app"
|
||||
? `${refuser.name} rejected the certificate${forHost}`
|
||||
: "A client rejected the certificate",
|
||||
],
|
||||
whySub: refuser.kind === "app" ? "likely pinned" : null,
|
||||
whyEntry: null,
|
||||
fact: null,
|
||||
actions: [
|
||||
...(stop == null ? [] : [{ ...stop, primary: true }]),
|
||||
|
||||
Reference in new issue
Block a user