import type { DecryptListEntry, Flow } from "@yaakapp-internal/proxy-lib"; import { type DecryptQuery, type DecryptSubject, decrypts, decryptsAnything, detailActions, EVERYTHING, entryMatches, entrySubject, listEdit, setDecrypted, subjectEntry, } from "./decrypt"; import { hostnameOf } from "./search"; /** Where an encrypted connection went, and the app that opened it, as the list judges it */ export interface Connection { /** The host alone, an IPv6 address without brackets */ host: string; port: number; /** The host as a button names it: with its port when that is not 443 */ label: string; /** `host:port`, an IPv6 host in brackets, as the engine's pass-through set lists it */ address: string; app: { name: string; bundleId: string | null } | null; } type ConnectionFlow = Pick; /** The connection a tunnel or a refused handshake records: `https://host:port` or `host:port` */ export function connectionOf(flow: ConnectionFlow): Connection { const scheme = flow.url.indexOf("://"); const rest = scheme === -1 ? flow.url : flow.url.slice(scheme + 3); const authority = (rest.split(/[/?#]/)[0] ?? "").toLowerCase(); const name = hostnameOf(authority); const portText = authority.slice(name.length + 1); const port = /^\d+$/.test(portText) ? Number(portText) : 443; const host = name.replace(/^\[|\]$/g, ""); return { host, port, label: port === 443 ? name : `${name}:${port}`, address: `${name}:${port}`, app: flow.clientName == null ? null : { name: flow.clientName, bundleId: flow.clientBundleId }, }; } function queryOf(c: Connection): DecryptQuery { return { host: c.host, port: c.port, app: c.app }; } /** What a button on the card does */ export type CardActionKind = | "decrypt" | "stop" | "reconnect" | "trust" | "try_again" | "copy_environment"; export interface CardAction { kind: CardActionKind; label: string; /** What it writes, shown beside it: an entry, or a hint like `next connection` */ hint: string | null; /** The hint is an entry it takes off the list, so it is struck through */ removes?: boolean; /** The list change, for `decrypt` and `stop` */ edit?: { add: string[]; remove: string[] }; /** What the toast says was chosen */ toast?: { message: string; sub?: string }; /** The suggested action */ primary?: boolean; /** The narrowest override of an exclusion that ships for a reason */ dashed?: boolean; /** What the icon shows: a host, the app, everything, or the action's own */ icon: "host" | "app" | "everything" | "lock" | "lock_open" | "refresh" | "shield" | "terminal"; } /** 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 | null; } /** A refused handshake, or a tunnel passed through because of one */ export function isRejection(flow: Pick): boolean { return flow.tlsRejected != null || flow.tunnelReason === "rejected"; } /** A tunnel still open: written as it opens, done once it closes */ export function isOpen(flow: Pick): boolean { return flow.elapsedMs == null && flow.error == null; } /** * The card for an HTTPS connection the proxy did not decrypt: why, from what the engine recorded * as it opened, and the changes to the list that would decrypt the next one like it, judged * against the list as it is now. Once the list decrypts it, the card says it opened before and * offers the opposite, and Reconnect while it is open. Nothing on it narrates what happens next. */ export function encryptedCard(input: EncryptedCardInput): EncryptedCardModel { return isRejection(input.flow) ? rejectedCard(input) : tunnelCard(input); } function tunnelCard({ flow, list, trusted, canTrust }: EncryptedCardInput): EncryptedCardModel { const c = connectionOf(flow); const q = queryOf(c); const 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 | 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"], }; }