| author | |
| committer | |
| log | 498ab9c399d5e8c3097a286be14d03238e071ac1 |
| tree | 69b851eb6d1ff0ed322f73c969032c000c1c84d4 |
| parent | ff5843410d44796676b06627788d94354849c4d7 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>27 files changed, 1707 insertions(+), 200 deletions(-)
.github/workflows/ci.yml+6| ... | ... | @@ -51,6 +51,9 @@ jobs: |
| 51 | 51 | - name: Typecheck |
| 52 | 52 | run: vpr typecheck |
| 53 | 53 | |
| 54 | - name: Install browser secret helper build libraries | |
| 55 | run: sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config | |
| 56 | ||
| 54 | 57 | - name: Build desktop pipeline |
| 55 | 58 | run: vp run build:desktop |
| 56 | 59 | |
| ... | ... | @@ -85,6 +88,9 @@ jobs: |
| 85 | 88 | - name: Ensure Electron runtime is installed |
| 86 | 89 | run: vp run --filter @t3tools/desktop ensure:electron |
| 87 | 90 | |
| 91 | - name: Install browser secret helper build libraries | |
| 92 | run: sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config | |
| 93 | ||
| 88 | 94 | - name: Test |
| 89 | 95 | run: vp run --parallel --concurrency-limit 4 --filter '!t3' --filter '!@t3tools/monorepo' test |
| 90 | 96 |
.github/workflows/release.yml+6-2| ... | ... | @@ -216,6 +216,9 @@ jobs: |
| 216 | 216 | - name: Typecheck |
| 217 | 217 | run: vp run typecheck |
| 218 | 218 | |
| 219 | - name: Install browser secret helper build libraries | |
| 220 | run: sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config | |
| 221 | ||
| 219 | 222 | - name: Test |
| 220 | 223 | run: vp run test |
| 221 | 224 | |
| ... | ... | @@ -524,12 +527,13 @@ jobs: |
| 524 | 527 | exit $code |
| 525 | 528 | } |
| 526 | 529 | |
| 527 | - name: Install ImageMagick | |
| 530 | - name: Install Linux desktop build libraries | |
| 528 | 531 | if: matrix.platform == 'linux' |
| 529 | 532 | shell: bash |
| 530 | 533 | run: | |
| 534 | sudo apt-get update | |
| 535 | sudo apt-get install -y libsecret-1-dev pkg-config | |
| 531 | 536 | if ! command -v magick >/dev/null 2>&1 && ! command -v convert >/dev/null 2>&1; then |
| 532 | sudo apt-get update | |
| 533 | 537 | sudo apt-get install -y imagemagick |
| 534 | 538 | fi |
| 535 | 539 |
apps/desktop/scripts/browser-secret-native.test.mjs created+103| ... | ... | @@ -0,0 +1,103 @@ |
| 1 | import * as NodeChildProcess from "node:child_process"; | |
| 2 | import * as NodeFS from "node:fs"; | |
| 3 | import * as NodeOS from "node:os"; | |
| 4 | import * as NodePath from "node:path"; | |
| 5 | import * as NodeURL from "node:url"; | |
| 6 | import { afterAll, beforeAll, describe, expect, it } from "vite-plus/test"; | |
| 7 | ||
| 8 | // oxlint-disable-next-line t3code/no-global-process-runtime -- The native compiler targets the actual host; this script has no Effect runtime. | |
| 9 | const hostArch = process.arch; | |
| 10 | // oxlint-disable-next-line t3code/no-global-process-runtime -- Native compilation only runs on the actual Linux host. | |
| 11 | const hostPlatform = process.platform; | |
| 12 | ||
| 13 | describe.skipIf(hostPlatform !== "linux")("bundled libsecret helper", () => { | |
| 14 | let directory; | |
| 15 | let executable; | |
| 16 | beforeAll(() => { | |
| 17 | directory = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-browser-secret-test-")); | |
| 18 | executable = NodePath.join(directory, "t3-browser-secret"); | |
| 19 | const root = NodeURL.fileURLToPath(new URL("../../../native/browser-secret/", import.meta.url)); | |
| 20 | const flags = NodeChildProcess.execFileSync( | |
| 21 | "pkg-config", | |
| 22 | ["--cflags", "--libs", "libsecret-1"], | |
| 23 | { | |
| 24 | encoding: "utf8", | |
| 25 | }, | |
| 26 | ) | |
| 27 | .trim() | |
| 28 | .split(/\s+/); | |
| 29 | NodeChildProcess.execFileSync( | |
| 30 | process.env.CC || "cc", | |
| 31 | [ | |
| 32 | "-std=c11", | |
| 33 | "-Wall", | |
| 34 | "-Wextra", | |
| 35 | "-Werror", | |
| 36 | NodePath.join(root, "main.c"), | |
| 37 | NodePath.join(root, "test.c"), | |
| 38 | "-Wl,--wrap=secret_service_search_sync", | |
| 39 | "-Wl,--wrap=secret_item_get_locked", | |
| 40 | "-Wl,--wrap=secret_item_get_secret", | |
| 41 | "-o", | |
| 42 | executable, | |
| 43 | ...flags, | |
| 44 | ], | |
| 45 | { stdio: "pipe" }, | |
| 46 | ); | |
| 47 | }); | |
| 48 | afterAll(() => { | |
| 49 | if (directory) NodeFS.rmSync(directory, { recursive: true, force: true }); | |
| 50 | }); | |
| 51 | ||
| 52 | const run = (args) => | |
| 53 | NodeChildProcess.spawnSync(executable, args, { | |
| 54 | env: { ...process.env, DBUS_SESSION_BUS_ADDRESS: "unix:path=/unused-test-bus" }, | |
| 55 | }); | |
| 56 | ||
| 57 | it("builds an executable for the requested architecture into a staged resource directory", () => { | |
| 58 | const output = NodePath.join(directory, "resources", "browser-secret", "t3-browser-secret"); | |
| 59 | NodeChildProcess.execFileSync(process.execPath, [ | |
| 60 | NodeURL.fileURLToPath(new URL("./build-browser-secret.mjs", import.meta.url)), | |
| 61 | "--arch", | |
| 62 | hostArch, | |
| 63 | "--output", | |
| 64 | output, | |
| 65 | ]); | |
| 66 | const header = NodeFS.readFileSync(output).subarray(0, 20); | |
| 67 | expect(header.toString("hex", 0, 6)).toBe("7f454c460201"); | |
| 68 | expect(header.readUInt16LE(18)).toBe({ x64: 62, arm64: 183 }[hostArch]); | |
| 69 | expect(NodeFS.statSync(output).mode & 0o111).not.toBe(0); | |
| 70 | // Invalid arguments exit before the real executable could contact a keyring. | |
| 71 | expect(NodeChildProcess.spawnSync(output, []).status).toBe(64); | |
| 72 | }); | |
| 73 | ||
| 74 | it("preserves the exact secret bytes with no added or removed delimiter", () => { | |
| 75 | const result = run(["success"]); | |
| 76 | expect(result.status).toBe(0); | |
| 77 | expect(result.stdout).toEqual(Buffer.from("secret\0with whitespace \t\r\n")); | |
| 78 | expect(result.stderr.length).toBe(0); | |
| 79 | }); | |
| 80 | ||
| 81 | for (const [scenario, code] of [ | |
| 82 | ["missing", 2], | |
| 83 | ["empty", 2], | |
| 84 | ["locked", 3], | |
| 85 | ["cancelled", 3], | |
| 86 | ["denied", 3], | |
| 87 | ["unavailable", 4], | |
| 88 | ["unloaded", 4], | |
| 89 | ]) { | |
| 90 | it(`reports ${scenario} without emitting a secret`, () => { | |
| 91 | const result = run([scenario]); | |
| 92 | expect(result.status).toBe(code); | |
| 93 | expect(result.stdout.length).toBe(0); | |
| 94 | }); | |
| 95 | } | |
| 96 | it("rejects invalid arguments before accessing the keyring", () => { | |
| 97 | for (const args of [[], [""], ["chrome", "extra"]]) { | |
| 98 | const result = run(args); | |
| 99 | expect(result.status).toBe(64); | |
| 100 | expect(result.stdout.length).toBe(0); | |
| 101 | } | |
| 102 | }); | |
| 103 | }); |
apps/desktop/scripts/build-browser-secret.mjs created+66| ... | ... | @@ -0,0 +1,66 @@ |
| 1 | import * as NodeChildProcess from "node:child_process"; | |
| 2 | import * as NodeFS from "node:fs"; | |
| 3 | import * as NodePath from "node:path"; | |
| 4 | import * as NodeURL from "node:url"; | |
| 5 | import * as NodeUtil from "node:util"; | |
| 6 | ||
| 7 | // oxlint-disable-next-line t3code/no-global-process-runtime -- The native compiler targets the actual host; this script has no Effect runtime. | |
| 8 | const hostArch = process.arch; | |
| 9 | // oxlint-disable-next-line t3code/no-global-process-runtime -- Native compilation only runs on the actual Linux host. | |
| 10 | const hostPlatform = process.platform; | |
| 11 | ||
| 12 | const { values } = NodeUtil.parseArgs({ | |
| 13 | options: { output: { type: "string" }, arch: { type: "string", default: hostArch } }, | |
| 14 | }); | |
| 15 | ||
| 16 | if (hostPlatform === "linux") { | |
| 17 | const machine = { x64: 62, arm64: 183 }[values.arch]; | |
| 18 | if (machine === undefined) throw new Error(`Unsupported Linux architecture: ${values.arch}`); | |
| 19 | const root = NodeURL.fileURLToPath(new URL("../../../native/browser-secret/", import.meta.url)); | |
| 20 | const source = NodePath.resolve(root, "main.c"); | |
| 21 | const output = values.output ?? NodePath.resolve(root, "build", values.arch, "t3-browser-secret"); | |
| 22 | const matchesArchitecture = (file) => { | |
| 23 | const header = NodeFS.readFileSync(file).subarray(0, 20); | |
| 24 | return header.toString("hex", 0, 6) === "7f454c460201" && header.readUInt16LE(18) === machine; | |
| 25 | }; | |
| 26 | let current = false; | |
| 27 | try { | |
| 28 | current = | |
| 29 | NodeFS.statSync(output).mtimeMs >= | |
| 30 | Math.max( | |
| 31 | NodeFS.statSync(source).mtimeMs, | |
| 32 | NodeFS.statSync(NodeURL.fileURLToPath(import.meta.url)).mtimeMs, | |
| 33 | ) && matchesArchitecture(output); | |
| 34 | } catch { | |
| 35 | /* The first build has no output yet. */ | |
| 36 | } | |
| 37 | if (!current) { | |
| 38 | let flags; | |
| 39 | try { | |
| 40 | flags = NodeChildProcess.execFileSync("pkg-config", ["--cflags", "--libs", "libsecret-1"], { | |
| 41 | encoding: "utf8", | |
| 42 | }) | |
| 43 | .trim() | |
| 44 | .split(/\s+/); | |
| 45 | } catch (cause) { | |
| 46 | throw new Error( | |
| 47 | "Building the Linux browser import helper requires pkg-config and libsecret development headers (Ubuntu/Debian: libsecret-1-dev).", | |
| 48 | { cause }, | |
| 49 | ); | |
| 50 | } | |
| 51 | NodeFS.mkdirSync(NodePath.dirname(output), { recursive: true }); | |
| 52 | const temporary = `${output}.${process.pid}.tmp`; | |
| 53 | try { | |
| 54 | NodeChildProcess.execFileSync( | |
| 55 | process.env.CC || "cc", | |
| 56 | ["-std=c11", "-O2", "-Wall", "-Wextra", "-Werror", source, "-o", temporary, ...flags], | |
| 57 | { stdio: "inherit" }, | |
| 58 | ); | |
| 59 | if (!matchesArchitecture(temporary)) | |
| 60 | throw new Error(`C compiler did not produce a Linux ${values.arch} executable.`); | |
| 61 | NodeFS.renameSync(temporary, output); | |
| 62 | } finally { | |
| 63 | NodeFS.rmSync(temporary, { force: true }); | |
| 64 | } | |
| 65 | } | |
| 66 | } |
apps/desktop/scripts/dev-electron.mjs+6| ... | ... | @@ -37,6 +37,12 @@ const remoteDebuggingPort = process.env.T3CODE_DESKTOP_REMOTE_DEBUGGING_PORT?.tr |
| 37 | 37 | // oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone dev script has no Effect runtime. |
| 38 | 38 | const hostPlatform = NodeOS.platform(); |
| 39 | 39 | |
| 40 | NodeChildProcess.execFileSync( | |
| 41 | process.execPath, | |
| 42 | [NodePath.join(desktopDir, "scripts/build-browser-secret.mjs")], | |
| 43 | { stdio: "inherit" }, | |
| 44 | ); | |
| 45 | ||
| 40 | 46 | await waitForResources({ |
| 41 | 47 | baseDir: desktopDir, |
| 42 | 48 | files: requiredFiles, |
apps/desktop/scripts/start-electron.mjs+7| ... | ... | @@ -1,7 +1,14 @@ |
| 1 | 1 | import * as NodeChildProcess from "node:child_process"; |
| 2 | import * as NodePath from "node:path"; | |
| 2 | 3 | |
| 3 | 4 | import { desktopDir, resolveElectronLaunchCommand } from "./electron-launcher.mjs"; |
| 4 | 5 | |
| 6 | NodeChildProcess.execFileSync( | |
| 7 | process.execPath, | |
| 8 | [NodePath.join(desktopDir, "scripts/build-browser-secret.mjs")], | |
| 9 | { stdio: "inherit" }, | |
| 10 | ); | |
| 11 | ||
| 5 | 12 | const childEnv = { ...process.env }; |
| 6 | 13 | delete childEnv.ELECTRON_RUN_AS_NODE; |
| 7 | 14 |
apps/desktop/src/main.ts+2-1| ... | ... | @@ -59,6 +59,7 @@ import * as DesktopState from "./app/DesktopState.ts"; |
| 59 | 59 | import * as DesktopTelemetryPublisher from "./telemetry/DesktopTelemetryPublisher.ts"; |
| 60 | 60 | import * as DesktopUpdates from "./updates/DesktopUpdates.ts"; |
| 61 | 61 | import * as BrowserImport from "./preview/BrowserImport/BrowserImport.ts"; |
| 62 | import * as LinuxBrowserSecret from "./preview/BrowserImport/LinuxBrowserSecret.ts"; | |
| 62 | 63 | import * as BrowserSession from "./preview/BrowserSession.ts"; |
| 63 | 64 | import * as PreviewManager from "./preview/Manager.ts"; |
| 64 | 65 | import * as DesktopWindow from "./window/DesktopWindow.ts"; |
| ... | ... | @@ -152,7 +153,7 @@ const desktopServerExposureLayer = DesktopServerExposure.layer.pipe( |
| 152 | 153 | const desktopPreviewLayer = PreviewManager.layer.pipe( |
| 153 | 154 | // Merged rather than provided so the IPC handlers can reach the import |
| 154 | 155 | // service alongside the manager; both sit on the same BrowserSession. |
| 155 | Layer.provideMerge(BrowserImport.layer), | |
| 156 | Layer.provideMerge(BrowserImport.layer.pipe(Layer.provide(LinuxBrowserSecret.layer))), | |
| 156 | 157 | Layer.provideMerge(BrowserSession.layer), |
| 157 | 158 | Layer.provideMerge(desktopFoundationLayer), |
| 158 | 159 | ); |
apps/desktop/src/preview/BrowserImport/BrowserImport.ts+10-10| ... | ... | @@ -90,11 +90,6 @@ const unavailableReason = Effect.fn("BrowserImport.unavailableReason")(function* |
| 90 | 90 | FileSystem.FileSystem | ChildProcessSpawner.ChildProcessSpawner |
| 91 | 91 | > { |
| 92 | 92 | if (!definition.platforms.includes(context.platform)) return "unsupportedPlatform"; |
| 93 | // Chromium's key lives in an OS credential store, and only the macOS one is | |
| 94 | // implemented; Firefox needs no key at all, so it works everywhere. | |
| 95 | if (definition.engine === "chromium" && context.platform !== "darwin") { | |
| 96 | return "unsupportedPlatform"; | |
| 97 | } | |
| 98 | 93 | if (!(yield* isSourceInstalled(definition, context))) return "notInstalled"; |
| 99 | 94 | if (yield* isSourceRunning(definition, context)) return "browserRunning"; |
| 100 | 95 | return undefined; |
| ... | ... | @@ -259,10 +254,11 @@ export const make = Effect.gen(function* BrowserImportMake() { |
| 259 | 254 | // identifiable and each tag is handled on its own below. The success side |
| 260 | 255 | // is normalized to one shape too, so the skipped tally survives either |
| 261 | 256 | // engine — Firefox stores plaintext, so nothing there is ever unreadable. |
| 257 | const userDataDirectory = definition.userDataDirectory(pathContext); | |
| 262 | 258 | const read: Effect.Effect< |
| 263 | 259 | CookieReadResult, |
| 264 | 260 | ChromiumCookieReadError | FirefoxCookieReadError, |
| 265 | FileSystem.FileSystem | Path.Path | Scope.Scope | |
| 261 | FileSystem.FileSystem | Path.Path | Scope.Scope | ChildProcessSpawner.ChildProcessSpawner | |
| 266 | 262 | > = |
| 267 | 263 | definition.engine === "firefox" |
| 268 | 264 | ? readFirefoxCookies(databasePath).pipe( |
| ... | ... | @@ -270,10 +266,14 @@ export const make = Effect.gen(function* BrowserImportMake() { |
| 270 | 266 | ) |
| 271 | 267 | : readChromiumCookies({ |
| 272 | 268 | cookieDatabasePath: databasePath, |
| 273 | // Only reached on macOS: `unavailableReason` rejects Chromium | |
| 274 | // elsewhere until those key stores are implemented. | |
| 275 | keychainService: definition.keychainService ?? "", | |
| 276 | keychainAccount: definition.keychainAccount ?? "", | |
| 269 | keychainService: definition.keychainService, | |
| 270 | keychainAccount: definition.keychainAccount, | |
| 271 | linuxSecretApplication: definition.linuxSecretApplication, | |
| 272 | ...(platform === "win32" && userDataDirectory !== undefined | |
| 273 | ? { | |
| 274 | windowsLocalStatePath: pathContext.path.join(userDataDirectory, "Local State"), | |
| 275 | } | |
| 276 | : {}), | |
| 277 | 277 | platform, |
| 278 | 278 | }); |
| 279 | 279 |
apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts+216-15| ... | ... | @@ -8,12 +8,32 @@ import * as Effect from "effect/Effect"; |
| 8 | 8 | import * as FileSystem from "effect/FileSystem"; |
| 9 | 9 | import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| 10 | 10 | |
| 11 | import { readChromiumCookieDatabase } from "./ChromiumCookies.ts"; | |
| 11 | import { | |
| 12 | decryptChromiumValue, | |
| 13 | readChromiumCookieDatabase, | |
| 14 | readChromiumCookies, | |
| 15 | } from "./ChromiumCookies.ts"; | |
| 16 | import { ChromiumKeyError } from "./ChromiumKeys.ts"; | |
| 17 | import { LinuxBrowserSecretPath } from "./LinuxBrowserSecret.ts"; | |
| 12 | 18 | import { cookieScope } from "./CookieDatabase.ts"; |
| 13 | 19 | |
| 14 | const encryptV10 = (value: string | Buffer, key: Buffer): Uint8Array => { | |
| 20 | const encryptChromium = ( | |
| 21 | prefix: "v10" | "v11", | |
| 22 | value: string | Buffer, | |
| 23 | key: Buffer, | |
| 24 | ): Uint8Array => { | |
| 15 | 25 | const cipher = NodeCrypto.createCipheriv("aes-128-cbc", key, Buffer.alloc(16, 0x20)); |
| 16 | return Buffer.concat([Buffer.from("v10"), cipher.update(value), cipher.final()]); | |
| 26 | return Buffer.concat([Buffer.from(prefix), cipher.update(value), cipher.final()]); | |
| 27 | }; | |
| 28 | ||
| 29 | const encryptV10 = (value: string | Buffer, key: Buffer): Uint8Array => | |
| 30 | encryptChromium("v10", value, key); | |
| 31 | ||
| 32 | const encryptWindowsV10 = (value: string | Buffer, key: Buffer): Uint8Array => { | |
| 33 | const nonce = Buffer.from("0123456789ab"); | |
| 34 | const cipher = NodeCrypto.createCipheriv("aes-256-gcm", key, nonce); | |
| 35 | const encrypted = Buffer.concat([cipher.update(value), cipher.final()]); | |
| 36 | return Buffer.concat([Buffer.from("v10"), nonce, encrypted, cipher.getAuthTag()]); | |
| 17 | 37 | }; |
| 18 | 38 | |
| 19 | 39 | describe("cookieScope", () => { |
| ... | ... | @@ -51,6 +71,87 @@ describe("cookieScope", () => { |
| 51 | 71 | }); |
| 52 | 72 | |
| 53 | 73 | describe("readChromiumCookieDatabase", () => { |
| 74 | it("decrypts Windows v10 AES-GCM records and rejects app-bound v20 records", () => { | |
| 75 | const key = Buffer.from("0123456789abcdef0123456789abcdef"); | |
| 76 | const host = ".example.test"; | |
| 77 | const bound = Buffer.concat([ | |
| 78 | NodeCrypto.createHash("sha256").update(host).digest(), | |
| 79 | Buffer.from("windows value"), | |
| 80 | ]); | |
| 81 | ||
| 82 | expect( | |
| 83 | decryptChromiumValue(encryptWindowsV10(bound, key), { gcmV10: key }, host, 24, "win32"), | |
| 84 | ).toBe("windows value"); | |
| 85 | expect( | |
| 86 | decryptChromiumValue(Buffer.from("v20app-bound"), { gcmV10: key }, host, 24, "win32"), | |
| 87 | ).toBeNull(); | |
| 88 | expect( | |
| 89 | decryptChromiumValue( | |
| 90 | encryptWindowsV10(bound, Buffer.alloc(32, 1)), | |
| 91 | { gcmV10: key }, | |
| 92 | host, | |
| 93 | 24, | |
| 94 | "win32", | |
| 95 | ), | |
| 96 | ).toBeNull(); | |
| 97 | }); | |
| 98 | ||
| 99 | it.effect( | |
| 100 | "reports the missing key when no cookies can be read, while preserving partial imports", | |
| 101 | () => | |
| 102 | Effect.gen(function* () { | |
| 103 | const fileSystem = yield* FileSystem.FileSystem; | |
| 104 | const directory = yield* fileSystem.makeTempDirectoryScoped({ | |
| 105 | prefix: "t3code-missing-key-", | |
| 106 | }); | |
| 107 | const filename = `${directory}/Cookies`; | |
| 108 | const key = Buffer.from("0123456789abcdef"); | |
| 109 | yield* Effect.gen(function* () { | |
| 110 | const sql = yield* SqlClient.SqlClient; | |
| 111 | yield* sql`create table meta (key text primary key, value text not null)`; | |
| 112 | yield* sql`insert into meta values ('version', 23)`; | |
| 113 | yield* sql`create table cookies ( | |
| 114 | host_key text not null, name text not null, value text not null, | |
| 115 | encrypted_value blob not null, path text not null, expires_utc integer not null, | |
| 116 | is_secure integer not null, is_httponly integer not null, samesite integer not null, | |
| 117 | top_frame_site_key text not null default '' | |
| 118 | )`; | |
| 119 | yield* sql`insert into cookies values ('v11.example', 'session', '', ${encryptChromium("v11", "secret", key)}, '/', 0, 1, 1, 1, '')`; | |
| 120 | }).pipe(Effect.provide(NodeSqliteClient.layer({ filename }))); | |
| 121 | ||
| 122 | const error = yield* readChromiumCookies({ | |
| 123 | cookieDatabasePath: filename, | |
| 124 | platform: "linux", | |
| 125 | linuxSecretApplication: "chromium", | |
| 126 | keychainService: undefined, | |
| 127 | keychainAccount: undefined, | |
| 128 | }).pipe(Effect.provideService(LinuxBrowserSecretPath, undefined), Effect.flip); | |
| 129 | expect(error.reason).toBe("keychainUnavailable"); | |
| 130 | ||
| 131 | yield* Effect.gen(function* () { | |
| 132 | const sql = yield* SqlClient.SqlClient; | |
| 133 | yield* sql`insert into cookies values ('v10.example', 'readable', '', ${encryptV10("kept", key)}, '/', 0, 1, 1, 1, '')`; | |
| 134 | }).pipe(Effect.provide(NodeSqliteClient.layer({ filename }))); | |
| 135 | const keys = { | |
| 136 | cbcV10: key, | |
| 137 | cbcV11Error: new ChromiumKeyError({ reason: "keychainUnavailable" }), | |
| 138 | }; | |
| 139 | const partial = yield* readChromiumCookieDatabase(filename, keys, "linux"); | |
| 140 | expect(partial.cookies.map((cookie) => cookie.value)).toEqual(["kept"]); | |
| 141 | expect(partial.undecryptable).toBe(1); | |
| 142 | ||
| 143 | // A partitioned-only jar does not need its key: it is skipped separately. | |
| 144 | yield* Effect.gen(function* () { | |
| 145 | const sql = yield* SqlClient.SqlClient; | |
| 146 | yield* sql`delete from cookies where name = 'readable'`; | |
| 147 | yield* sql`update cookies set top_frame_site_key = 'https://top.example'`; | |
| 148 | }).pipe(Effect.provide(NodeSqliteClient.layer({ filename }))); | |
| 149 | const partitioned = yield* readChromiumCookieDatabase(filename, keys, "linux"); | |
| 150 | expect(partitioned.cookies).toEqual([]); | |
| 151 | expect(partitioned.undecryptable).toBe(1); | |
| 152 | }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), | |
| 153 | ); | |
| 154 | ||
| 54 | 155 | it.effect("reads plaintext, encrypted, and genuinely empty cookie values", () => |
| 55 | 156 | Effect.gen(function* () { |
| 56 | 157 | const fileSystem = yield* FileSystem.FileSystem; |
| ... | ... | @@ -92,7 +193,7 @@ describe("readChromiumCookieDatabase", () => { |
| 92 | 193 | `; |
| 93 | 194 | }).pipe(Effect.provide(NodeSqliteClient.layer({ filename }))); |
| 94 | 195 | |
| 95 | const result = yield* readChromiumCookieDatabase(filename, key, "darwin"); | |
| 196 | const result = yield* readChromiumCookieDatabase(filename, { cbcV10: key }, "darwin"); | |
| 96 | 197 | |
| 97 | 198 | expect(result.undecryptable).toBe(0); |
| 98 | 199 | expect(result.cookies.map(({ name, value }) => ({ name, value }))).toEqual([ |
| ... | ... | @@ -134,7 +235,7 @@ describe("readChromiumCookieDatabase", () => { |
| 134 | 235 | ('short.example', 'short', '', ${encryptV10("short value", key)}, '/', 0, 1, 0, 0)`; |
| 135 | 236 | }).pipe(Effect.provide(NodeSqliteClient.layer({ filename }))); |
| 136 | 237 | |
| 137 | const result = yield* readChromiumCookieDatabase(filename, key, "darwin"); | |
| 238 | const result = yield* readChromiumCookieDatabase(filename, { cbcV10: key }, "darwin"); | |
| 138 | 239 | |
| 139 | 240 | expect(result.cookies.map(({ name, value }) => ({ name, value }))).toEqual([ |
| 140 | 241 | { name: "valid", value: "kept" }, |
| ... | ... | @@ -144,6 +245,104 @@ describe("readChromiumCookieDatabase", () => { |
| 144 | 245 | }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), |
| 145 | 246 | ); |
| 146 | 247 | |
| 248 | it.effect("decrypts mixed v10 and v11 cookies with their respective keys", () => | |
| 249 | Effect.gen(function* () { | |
| 250 | const fileSystem = yield* FileSystem.FileSystem; | |
| 251 | const directory = yield* fileSystem.makeTempDirectoryScoped({ | |
| 252 | prefix: "t3code-chromium-cookies-", | |
| 253 | }); | |
| 254 | const filename = `${directory}/Cookies`; | |
| 255 | const cbcV10 = Buffer.from("0123456789abcdef"); | |
| 256 | const cbcV11 = Buffer.from("fedcba9876543210"); | |
| 257 | const boundValue = (host: string, value: string) => | |
| 258 | Buffer.concat([NodeCrypto.createHash("sha256").update(host).digest(), Buffer.from(value)]); | |
| 259 | ||
| 260 | yield* Effect.gen(function* () { | |
| 261 | const sql = yield* SqlClient.SqlClient; | |
| 262 | yield* sql`create table meta (key text primary key, value text not null)`; | |
| 263 | yield* sql`insert into meta values ('version', '24')`; | |
| 264 | yield* sql` | |
| 265 | create table cookies ( | |
| 266 | host_key text not null, name text not null, value text not null, | |
| 267 | encrypted_value blob not null, path text not null, expires_utc integer not null, | |
| 268 | is_secure integer not null, is_httponly integer not null, samesite integer not null, | |
| 269 | top_frame_site_key text not null default '' | |
| 270 | ) | |
| 271 | `; | |
| 272 | yield* sql`insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values | |
| 273 | ('v10.example', 'v10-cookie', '', ${encryptChromium("v10", boundValue("v10.example", "v10 value"), cbcV10)}, '/', 0, 1, 0, 0)`; | |
| 274 | yield* sql`insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values | |
| 275 | ('v11.example', 'v11-cookie', '', ${encryptChromium("v11", boundValue("v11.example", "v11 value"), cbcV11)}, '/', 0, 1, 0, 0)`; | |
| 276 | }).pipe(Effect.provide(NodeSqliteClient.layer({ filename }))); | |
| 277 | ||
| 278 | const complete = yield* readChromiumCookieDatabase(filename, { cbcV10, cbcV11 }, "linux"); | |
| 279 | expect(complete.cookies.map(({ name, value }) => ({ name, value }))).toEqual([ | |
| 280 | { name: "v10-cookie", value: "v10 value" }, | |
| 281 | { name: "v11-cookie", value: "v11 value" }, | |
| 282 | ]); | |
| 283 | expect(complete.undecryptable).toBe(0); | |
| 284 | ||
| 285 | const v10Only = yield* readChromiumCookieDatabase(filename, { cbcV10 }, "linux"); | |
| 286 | expect(v10Only.cookies.map(({ name, value }) => ({ name, value }))).toEqual([ | |
| 287 | { name: "v10-cookie", value: "v10 value" }, | |
| 288 | ]); | |
| 289 | expect(v10Only.undecryptable).toBe(1); | |
| 290 | expect(v10Only.undecryptableHosts).toEqual(["v11.example"]); | |
| 291 | }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), | |
| 292 | ); | |
| 293 | ||
| 294 | it.effect("recovers records written with the empty-passphrase key", () => | |
| 295 | Effect.gen(function* () { | |
| 296 | const fileSystem = yield* FileSystem.FileSystem; | |
| 297 | const directory = yield* fileSystem.makeTempDirectoryScoped({ | |
| 298 | prefix: "t3code-chromium-cookies-", | |
| 299 | }); | |
| 300 | const filename = `${directory}/Cookies`; | |
| 301 | const cbcV10 = Buffer.from("0123456789abcdef"); | |
| 302 | const cbcV11 = Buffer.from("fedcba9876543210"); | |
| 303 | // The key some Linux clients actually encrypted with (crbug.com/1195256): | |
| 304 | // OSCrypt's derivation over an empty passphrase. | |
| 305 | const cbcEmpty = NodeCrypto.pbkdf2Sync("", "saltysalt", 1, 16, "sha1"); | |
| 306 | ||
| 307 | yield* Effect.gen(function* () { | |
| 308 | const sql = yield* SqlClient.SqlClient; | |
| 309 | yield* sql`create table meta (key text primary key, value text not null)`; | |
| 310 | yield* sql`insert into meta values ('version', '23')`; | |
| 311 | yield* sql` | |
| 312 | create table cookies ( | |
| 313 | host_key text not null, name text not null, value text not null, | |
| 314 | encrypted_value blob not null, path text not null, expires_utc integer not null, | |
| 315 | is_secure integer not null, is_httponly integer not null, samesite integer not null, | |
| 316 | top_frame_site_key text not null default '' | |
| 317 | ) | |
| 318 | `; | |
| 319 | yield* sql`insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values | |
| 320 | ('ev10.example', 'empty-v10', '', ${encryptChromium("v10", "empty v10 value", cbcEmpty)}, '/', 0, 1, 0, 0)`; | |
| 321 | yield* sql`insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values | |
| 322 | ('ev11.example', 'empty-v11', '', ${encryptChromium("v11", "empty v11 value", cbcEmpty)}, '/', 0, 1, 0, 0)`; | |
| 323 | }).pipe(Effect.provide(NodeSqliteClient.layer({ filename }))); | |
| 324 | ||
| 325 | // The records' own keys fail, and the empty key recovers both — the | |
| 326 | // retry Chromium itself performs. | |
| 327 | const recovered = yield* readChromiumCookieDatabase( | |
| 328 | filename, | |
| 329 | { cbcV10, cbcV11, cbcEmpty }, | |
| 330 | "linux", | |
| 331 | ); | |
| 332 | expect(recovered.cookies.map(({ name, value }) => ({ name, value }))).toEqual([ | |
| 333 | { name: "empty-v10", value: "empty v10 value" }, | |
| 334 | { name: "empty-v11", value: "empty v11 value" }, | |
| 335 | ]); | |
| 336 | expect(recovered.undecryptable).toBe(0); | |
| 337 | ||
| 338 | // Matching Chromium: a record whose own key is missing entirely is not | |
| 339 | // retried with the empty key. | |
| 340 | const noV11 = yield* readChromiumCookieDatabase(filename, { cbcV10, cbcEmpty }, "linux"); | |
| 341 | expect(noV11.cookies.map(({ name }) => name)).toEqual(["empty-v10"]); | |
| 342 | expect(noV11.undecryptableHosts).toEqual(["ev11.example"]); | |
| 343 | }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), | |
| 344 | ); | |
| 345 | ||
| 147 | 346 | it.effect("preserves arbitrary long encrypted values from pre-24 schemas", () => |
| 148 | 347 | Effect.gen(function* () { |
| 149 | 348 | const fileSystem = yield* FileSystem.FileSystem; |
| ... | ... | @@ -170,7 +369,7 @@ describe("readChromiumCookieDatabase", () => { |
| 170 | 369 | ('legacy.example', 'legacy', '', ${encryptV10(value, key)}, '/', 0, 0, 0, 0)`; |
| 171 | 370 | }).pipe(Effect.provide(NodeSqliteClient.layer({ filename }))); |
| 172 | 371 | |
| 173 | const result = yield* readChromiumCookieDatabase(filename, key, "darwin"); | |
| 372 | const result = yield* readChromiumCookieDatabase(filename, { cbcV10: key }, "darwin"); | |
| 174 | 373 | expect(result.cookies[0]?.value).toBe(value); |
| 175 | 374 | expect(result.undecryptable).toBe(0); |
| 176 | 375 | }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), |
| ... | ... | @@ -192,7 +391,7 @@ describe("readChromiumCookieDatabase", () => { |
| 192 | 391 | |
| 193 | 392 | const error = yield* readChromiumCookieDatabase( |
| 194 | 393 | filename, |
| 195 | Buffer.from("0123456789abcdef"), | |
| 394 | { cbcV10: Buffer.from("0123456789abcdef") }, | |
| 196 | 395 | "darwin", |
| 197 | 396 | ).pipe(Effect.flip); |
| 198 | 397 | |
| ... | ... | @@ -200,7 +399,7 @@ describe("readChromiumCookieDatabase", () => { |
| 200 | 399 | }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), |
| 201 | 400 | ); |
| 202 | 401 | |
| 203 | it.effect("treats unversioned encrypted values as legacy plaintext only on macOS", () => | |
| 402 | it.effect("treats unversioned encrypted values as legacy plaintext on macOS and Linux", () => | |
| 204 | 403 | Effect.gen(function* () { |
| 205 | 404 | const fileSystem = yield* FileSystem.FileSystem; |
| 206 | 405 | const directory = yield* fileSystem.makeTempDirectoryScoped({ |
| ... | ... | @@ -225,14 +424,16 @@ describe("readChromiumCookieDatabase", () => { |
| 225 | 424 | ('legacy.example', 'legacy', '', ${Buffer.from("legacy cleartext")}, '/', 0, 0, 0, 0)`; |
| 226 | 425 | }).pipe(Effect.provide(NodeSqliteClient.layer({ filename }))); |
| 227 | 426 | |
| 228 | const mac = yield* readChromiumCookieDatabase(filename, key, "darwin"); | |
| 229 | const linux = yield* readChromiumCookieDatabase(filename, key, "linux"); | |
| 427 | // Chromium's OSCrypt returns unprefixed data as-is on both platforms | |
| 428 | // (os_crypt_mac.mm and os_crypt_linux.cc: "old data saved as clear | |
| 429 | // text"), so neither counts it as undecryptable. | |
| 430 | const mac = yield* readChromiumCookieDatabase(filename, { cbcV10: key }, "darwin"); | |
| 431 | const linux = yield* readChromiumCookieDatabase(filename, { cbcV10: key }, "linux"); | |
| 230 | 432 | |
| 231 | 433 | expect(mac.cookies[0]?.value).toBe("legacy cleartext"); |
| 232 | 434 | expect(mac.undecryptable).toBe(0); |
| 233 | expect(linux.cookies).toEqual([]); | |
| 234 | expect(linux.undecryptable).toBe(1); | |
| 235 | expect(linux.undecryptableHosts).toEqual(["legacy.example"]); | |
| 435 | expect(linux.cookies[0]?.value).toBe("legacy cleartext"); | |
| 436 | expect(linux.undecryptable).toBe(0); | |
| 236 | 437 | }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), |
| 237 | 438 | ); |
| 238 | 439 | |
| ... | ... | @@ -279,8 +480,8 @@ describe("readChromiumCookieDatabase", () => { |
| 279 | 480 | ('partitioned.example', 'partitioned', 'must skip', ${new Uint8Array()}, '/', 0, 1, 0, 0, 'https://top.example')`; |
| 280 | 481 | }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: chipsFilename }))); |
| 281 | 482 | |
| 282 | const legacy = yield* readChromiumCookieDatabase(legacyFilename, key, "darwin"); | |
| 283 | const chips = yield* readChromiumCookieDatabase(chipsFilename, key, "darwin"); | |
| 483 | const legacy = yield* readChromiumCookieDatabase(legacyFilename, { cbcV10: key }, "darwin"); | |
| 484 | const chips = yield* readChromiumCookieDatabase(chipsFilename, { cbcV10: key }, "darwin"); | |
| 284 | 485 | |
| 285 | 486 | expect(legacy.cookies.map(({ name }) => name)).toEqual(["legacy"]); |
| 286 | 487 | expect(legacy.undecryptable).toBe(0); |
apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts+181-144| ... | ... | @@ -3,25 +3,34 @@ |
| 3 | 3 | /** |
| 4 | 4 | * Chromium cookie extraction. |
| 5 | 5 | * |
| 6 | * Reads a Chromium-family browser's cookie database and decrypts it with the | |
| 7 | * key the OS keychain hands us, which is the mechanism the browser itself | |
| 8 | * uses. macOS mediates that with a per-app consent prompt, so the user | |
| 9 | * explicitly approves T3 Code reading it. | |
| 6 | * Reads a Chromium-family browser's cookie database and decrypts each record | |
| 7 | * with the key its prefix calls for. Key acquisition — and the consent it | |
| 8 | * needs — lives in `ChromiumKeys`. | |
| 10 | 9 | * |
| 11 | * Deliberately no fallback when the keychain says no: the alternative | |
| 12 | * techniques exist to defeat that consent, and this feature is not worth | |
| 13 | * shipping them. | |
| 10 | * Records whose scheme we hold no key for are skipped rather than failing the | |
| 11 | * whole import: a Linux database can mix `v10` and `v11`. A partial result | |
| 12 | * reported honestly is more useful than an all-or-nothing error. | |
| 14 | 13 | * |
| 15 | 14 | * @module ChromiumCookies |
| 16 | 15 | */ |
| 17 | import * as Keyring from "@napi-rs/keyring"; | |
| 18 | 16 | import * as NodeCrypto from "node:crypto"; |
| 19 | 17 | |
| 20 | 18 | import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; |
| 21 | 19 | import * as Effect from "effect/Effect"; |
| 20 | import * as FileSystem from "effect/FileSystem"; | |
| 21 | import * as Path from "effect/Path"; | |
| 22 | 22 | import * as Schema from "effect/Schema"; |
| 23 | import * as Scope from "effect/Scope"; | |
| 23 | 24 | import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| 25 | import { ChildProcessSpawner } from "effect/unstable/process"; | |
| 24 | 26 | |
| 27 | import { | |
| 28 | ChromiumKeyError, | |
| 29 | ChromiumKeyFailure, | |
| 30 | readWindowsKey, | |
| 31 | resolveChromiumKeys, | |
| 32 | type ChromiumKeyMaterial, | |
| 33 | } from "./ChromiumKeys.ts"; | |
| 25 | 34 | import { |
| 26 | 35 | bareHost, |
| 27 | 36 | cookieScope, |
| ... | ... | @@ -30,20 +39,20 @@ import { |
| 30 | 39 | type ImportedCookie, |
| 31 | 40 | } from "./CookieDatabase.ts"; |
| 32 | 41 | |
| 33 | /** macOS OSCrypt parameters. Chromium has used these since the feature landed. */ | |
| 34 | const MAC_KEY_ITERATIONS = 1003; | |
| 35 | const MAC_KEY_SALT = "saltysalt"; | |
| 36 | const MAC_KEY_LENGTH = 16; | |
| 37 | /** OSCrypt uses a fixed IV of 16 spaces rather than a per-record one. */ | |
| 38 | const AES_IV = Buffer.alloc(16, 0x20); | |
| 39 | const V10_PREFIX = "v10"; | |
| 42 | /** OSCrypt's CBC mode uses a fixed IV of 16 spaces rather than a per-record one. */ | |
| 43 | const AES_CBC_IV = Buffer.alloc(16, 0x20); | |
| 44 | const AES_GCM_NONCE_LENGTH = 12; | |
| 45 | const AES_GCM_TAG_LENGTH = 16; | |
| 46 | const isChromiumKeyError = Schema.is(ChromiumKeyError); | |
| 40 | 47 | |
| 48 | /** | |
| 49 | * Every way the read can fail: the key failures, plus the ones this module | |
| 50 | * raises itself. | |
| 51 | */ | |
| 41 | 52 | export const ChromiumCookieReadReason = Schema.Literals([ |
| 42 | "needsKeychainApproval", | |
| 43 | "keychainItemMissing", | |
| 53 | // `readFailed` already comes from the key failures, so it is not repeated. | |
| 54 | ...ChromiumKeyFailure.literals, | |
| 44 | 55 | "browserRunning", |
| 45 | "unsupportedPlatform", | |
| 46 | "readFailed", | |
| 47 | 56 | ]); |
| 48 | 57 | export type ChromiumCookieReadReason = typeof ChromiumCookieReadReason.Type; |
| 49 | 58 | |
| ... | ... | @@ -79,7 +88,6 @@ const CookieRow = Schema.Struct({ |
| 79 | 88 | samesite: Schema.Number, |
| 80 | 89 | top_frame_site_key: Schema.String, |
| 81 | 90 | }); |
| 82 | ||
| 83 | 91 | const decodeCookieRows = Schema.decodeUnknownEffect(Schema.Array(CookieRow)); |
| 84 | 92 | const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)); |
| 85 | 93 | const SchemaVersion = Schema.Union([ |
| ... | ... | @@ -106,11 +114,9 @@ const sameSiteFromColumn = (value: number): ImportedCookie["sameSite"] => { |
| 106 | 114 | |
| 107 | 115 | /** |
| 108 | 116 | * Chromium timestamps count microseconds from 1601-01-01; Electron wants |
| 109 | * seconds from the UNIX epoch. | |
| 110 | * | |
| 111 | * The microsecond value overflows JavaScript's safe integer range, and | |
| 112 | * `node:sqlite` refuses to narrow it, so the division happens in SQL and this | |
| 113 | * only ever sees seconds. | |
| 117 | * seconds from the UNIX epoch. The microsecond value overflows JavaScript's | |
| 118 | * safe integer range and `node:sqlite` refuses to narrow it, so the division | |
| 119 | * happens in SQL and this only ever sees seconds. | |
| 114 | 120 | */ |
| 115 | 121 | const WEBKIT_EPOCH_OFFSET_SECONDS = 11_644_473_600; |
| 116 | 122 | const toUnixSeconds = (webkitSeconds: number): number | undefined => { |
| ... | ... | @@ -119,136 +125,150 @@ const toUnixSeconds = (webkitSeconds: number): number | undefined => { |
| 119 | 125 | }; |
| 120 | 126 | |
| 121 | 127 | /** |
| 122 | * Reads the OSCrypt key from the login keychain. | |
| 123 | * | |
| 124 | * Uses the in-process Keychain API rather than shelling out to | |
| 125 | * `/usr/bin/security`, because the keychain attributes both the consent prompt | |
| 126 | * and the resulting ACL entry to the binary that asks. Via the CLI the prompt | |
| 127 | * says "security" and "Always Allow" grants trust to a tool every process on | |
| 128 | * the machine can invoke; in-process it names this app and the grant belongs | |
| 129 | * to it. (In an unsigned dev build the name is the dev Electron binary rather | |
| 130 | * than the shipped app identity.) | |
| 131 | * | |
| 132 | * Deliberately untimed: macOS answers this with a modal, and a timeout racing | |
| 133 | * the user means the prompt can be approved while nothing is left listening — | |
| 134 | * which reads as "approving did nothing". | |
| 128 | * Chromium >= 127 prefixes the plaintext with SHA-256 of the host key, binding | |
| 129 | * a cookie to its domain. Strip it when present. | |
| 135 | 130 | */ |
| 136 | const readMacKeychainPassword = Effect.fn("ChromiumCookies.readMacKeychainPassword")(function* ( | |
| 137 | service: string, | |
| 138 | account: string, | |
| 139 | cookieDatabasePath: string, | |
| 140 | ) { | |
| 141 | const password = yield* Effect.try({ | |
| 142 | try: () => new Keyring.Entry(service, account).getPassword(), | |
| 143 | catch: (cause) => { | |
| 144 | const message = String((cause as { message?: unknown } | undefined)?.message ?? ""); | |
| 145 | // Distinguish the causes rather than telling the user to approve a | |
| 146 | // prompt when approving cannot fix the failure. | |
| 147 | const missing = /no (matching )?entry|not found/i.test(message); | |
| 148 | return new ChromiumCookieReadError({ | |
| 149 | reason: missing ? "keychainItemMissing" : "needsKeychainApproval", | |
| 150 | cookieDatabasePath, | |
| 151 | cause, | |
| 152 | }); | |
| 153 | }, | |
| 154 | }); | |
| 155 | if (password === null || password === "") { | |
| 156 | return yield* new ChromiumCookieReadError({ | |
| 157 | reason: "keychainItemMissing", | |
| 158 | cookieDatabasePath, | |
| 159 | }); | |
| 160 | } | |
| 161 | return password; | |
| 162 | }); | |
| 131 | const stripDomainBinding = ( | |
| 132 | plaintext: Buffer, | |
| 133 | domain: string, | |
| 134 | schemaVersion: number, | |
| 135 | ): Buffer | null => { | |
| 136 | if (schemaVersion < 24) return plaintext; | |
| 137 | const domainHash = NodeCrypto.createHash("sha256").update(domain).digest(); | |
| 138 | return plaintext.length >= 32 && plaintext.subarray(0, 32).equals(domainHash) | |
| 139 | ? plaintext.subarray(32) | |
| 140 | : null; | |
| 141 | }; | |
| 163 | 142 | |
| 164 | const decryptValue = ( | |
| 165 | encrypted: Uint8Array, | |
| 143 | const decryptCbc = ( | |
| 144 | payload: Buffer, | |
| 166 | 145 | key: Buffer, |
| 167 | 146 | domain: string, |
| 168 | 147 | schemaVersion: number, |
| 169 | platform: NodeJS.Platform, | |
| 170 | 148 | ): string | null => { |
| 171 | const buffer = Buffer.from(encrypted); | |
| 172 | const version = buffer.subarray(0, 3).toString("latin1"); | |
| 149 | try { | |
| 150 | const decipher = NodeCrypto.createDecipheriv("aes-128-cbc", key, AES_CBC_IV); | |
| 151 | decipher.setAutoPadding(true); | |
| 152 | const plaintext = Buffer.concat([decipher.update(payload), decipher.final()]); | |
| 153 | return stripDomainBinding(plaintext, domain, schemaVersion)?.toString("utf8") ?? null; | |
| 154 | } catch { | |
| 155 | return null; | |
| 156 | } | |
| 157 | }; | |
| 173 | 158 | |
| 159 | const decryptGcm = ( | |
| 160 | payload: Buffer, | |
| 161 | key: Buffer, | |
| 162 | domain: string, | |
| 163 | schemaVersion: number, | |
| 164 | ): string | null => { | |
| 165 | if (payload.length < AES_GCM_NONCE_LENGTH + AES_GCM_TAG_LENGTH) return null; | |
| 174 | 166 | try { |
| 175 | let plaintext: Buffer; | |
| 176 | if (version === V10_PREFIX) { | |
| 177 | const decipher = NodeCrypto.createDecipheriv("aes-128-cbc", key, AES_IV); | |
| 178 | decipher.setAutoPadding(true); | |
| 179 | plaintext = Buffer.concat([decipher.update(buffer.subarray(3)), decipher.final()]); | |
| 180 | } else if (platform === "darwin") { | |
| 181 | // macOS OSCrypt explicitly treats an unversioned encrypted_value as | |
| 182 | // legacy cleartext. This is platform-specific: Linux uses other version | |
| 183 | // prefixes, which must not be widened into plaintext cookies. | |
| 184 | plaintext = buffer; | |
| 185 | } else { | |
| 186 | return null; | |
| 187 | } | |
| 188 | // Cookie schema 24 requires SHA-256(host_key) at the front of every | |
| 189 | // encrypted value. Treat a missing or mismatched binding as undecryptable; | |
| 190 | // older schemas stored arbitrary plaintext here, including long values | |
| 191 | // whose first 32 bytes must not be interpreted as a hash. | |
| 192 | if (schemaVersion >= 24) { | |
| 193 | const domainHash = NodeCrypto.createHash("sha256").update(domain).digest(); | |
| 194 | if (plaintext.length < domainHash.length || !plaintext.subarray(0, 32).equals(domainHash)) { | |
| 195 | return null; | |
| 196 | } | |
| 197 | plaintext = plaintext.subarray(32); | |
| 198 | } | |
| 199 | return plaintext.toString("utf8"); | |
| 167 | const nonce = payload.subarray(0, AES_GCM_NONCE_LENGTH); | |
| 168 | const ciphertext = payload.subarray(AES_GCM_NONCE_LENGTH, -AES_GCM_TAG_LENGTH); | |
| 169 | const tag = payload.subarray(-AES_GCM_TAG_LENGTH); | |
| 170 | const decipher = NodeCrypto.createDecipheriv("aes-256-gcm", key, nonce); | |
| 171 | decipher.setAuthTag(tag); | |
| 172 | const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]); | |
| 173 | return stripDomainBinding(plaintext, domain, schemaVersion)?.toString("utf8") ?? null; | |
| 200 | 174 | } catch { |
| 201 | 175 | return null; |
| 202 | 176 | } |
| 203 | 177 | }; |
| 204 | 178 | |
| 179 | /** | |
| 180 | * Decrypts one stored value, choosing the scheme from its prefix. Returns null | |
| 181 | * when no key covers that scheme — including Windows' app-bound `v20`, which | |
| 182 | * this build has no key for at all. | |
| 183 | */ | |
| 184 | export function decryptChromiumValue( | |
| 185 | encrypted: Uint8Array, | |
| 186 | keys: ChromiumKeyMaterial, | |
| 187 | domain: string, | |
| 188 | schemaVersion = 23, | |
| 189 | platform: NodeJS.Platform = "linux", | |
| 190 | ): string | null { | |
| 191 | const buffer = Buffer.from(encrypted); | |
| 192 | if (buffer.length === 0) return ""; | |
| 193 | const prefix = buffer.subarray(0, 3).toString("latin1"); | |
| 194 | const payload = buffer.subarray(3); | |
| 195 | ||
| 196 | // Windows' legacy v10 format is AES-256-GCM. App-bound records use v20 and | |
| 197 | // intentionally have no key here, so they fall through as undecryptable. | |
| 198 | if (platform === "win32") { | |
| 199 | return prefix === "v10" && keys.gcmV10 | |
| 200 | ? decryptGcm(payload, keys.gcmV10, domain, schemaVersion) | |
| 201 | : null; | |
| 202 | } | |
| 203 | ||
| 204 | // Chromium retries a failed record with a key derived from an empty | |
| 205 | // passphrase, because some Linux clients wrote data that way | |
| 206 | // (crbug.com/1195256). A record whose own key is missing entirely stays | |
| 207 | // skipped, matching Chromium. | |
| 208 | if (prefix === "v10") { | |
| 209 | if (!keys.cbcV10) return null; | |
| 210 | return ( | |
| 211 | decryptCbc(payload, keys.cbcV10, domain, schemaVersion) ?? | |
| 212 | (keys.cbcEmpty ? decryptCbc(payload, keys.cbcEmpty, domain, schemaVersion) : null) | |
| 213 | ); | |
| 214 | } | |
| 215 | if (prefix === "v11") { | |
| 216 | if (!keys.cbcV11) return null; | |
| 217 | return ( | |
| 218 | decryptCbc(payload, keys.cbcV11, domain, schemaVersion) ?? | |
| 219 | (keys.cbcEmpty ? decryptCbc(payload, keys.cbcEmpty, domain, schemaVersion) : null) | |
| 220 | ); | |
| 221 | } | |
| 222 | // No recognised prefix: Chromium on macOS and Linux both treat this as | |
| 223 | // legacy data stored in the clear and return it as-is, so it is a readable | |
| 224 | // cookie rather than an undecryptable one. Windows is the exception — its | |
| 225 | // app-bound `v20` blobs also lack these prefixes and must not be read as | |
| 226 | // plaintext — but Windows Chromium is not importable here at all. | |
| 227 | if (platform === "darwin" || platform === "linux") { | |
| 228 | return stripDomainBinding(buffer, domain, schemaVersion)?.toString("utf8") ?? null; | |
| 229 | } | |
| 230 | return null; | |
| 231 | } | |
| 232 | ||
| 205 | 233 | /** Reads and decodes one snapshotted Chromium cookie database. */ |
| 206 | 234 | export const readChromiumCookieDatabase = Effect.fn("ChromiumCookies.readChromiumCookieDatabase")( |
| 207 | function* (snapshotPath: string, key: Buffer, platform: NodeJS.Platform) { | |
| 208 | const rows = yield* Effect.gen(function* () { | |
| 235 | function* (snapshotPath: string, keys: ChromiumKeyMaterial, platform: NodeJS.Platform) { | |
| 236 | const result = yield* Effect.gen(function* () { | |
| 209 | 237 | const sql = yield* SqlClient.SqlClient; |
| 210 | 238 | const schemaVersion = yield* sql`select value from meta where key = 'version' limit 1`.pipe( |
| 211 | 239 | Effect.flatMap(decodeSchemaVersion), |
| 212 | 240 | Effect.map(([row]) => row.value), |
| 213 | 241 | ); |
| 214 | // CHIPS added top_frame_site_key in schema 15. Keep the old query valid | |
| 215 | // for earlier databases, which do not have the column at all. | |
| 216 | 242 | const raw = |
| 217 | 243 | schemaVersion >= 15 |
| 218 | ? yield* sql` | |
| 219 | select host_key, name, value, encrypted_value, path, | |
| 220 | expires_utc / 1000000 as expires_seconds, | |
| 221 | is_secure, is_httponly, samesite, top_frame_site_key | |
| 222 | from cookies | |
| 223 | ` | |
| 224 | : yield* sql` | |
| 225 | select host_key, name, value, encrypted_value, path, | |
| 226 | expires_utc / 1000000 as expires_seconds, | |
| 227 | is_secure, is_httponly, samesite, '' as top_frame_site_key | |
| 228 | from cookies | |
| 229 | `; | |
| 244 | ? yield* sql`select host_key, name, value, encrypted_value, path, | |
| 245 | expires_utc / 1000000 as expires_seconds, is_secure, is_httponly, | |
| 246 | samesite, top_frame_site_key from cookies` | |
| 247 | : yield* sql`select host_key, name, value, encrypted_value, path, | |
| 248 | expires_utc / 1000000 as expires_seconds, is_secure, is_httponly, | |
| 249 | samesite, '' as top_frame_site_key from cookies`; | |
| 230 | 250 | return { rows: yield* decodeCookieRows(raw), schemaVersion }; |
| 231 | 251 | }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: snapshotPath, readonly: true }))); |
| 232 | 252 | |
| 233 | 253 | const cookies: ImportedCookie[] = []; |
| 234 | 254 | let undecryptable = 0; |
| 235 | 255 | const undecryptableHosts = new Set<string>(); |
| 236 | for (const row of rows.rows) { | |
| 237 | // Electron's cookies.set contract cannot represent a CHIPS partition | |
| 238 | // key. Importing this row without one would widen it into an ordinary | |
| 239 | // unpartitioned cookie, so count it as skipped instead. | |
| 256 | for (const row of result.rows) { | |
| 240 | 257 | if (row.top_frame_site_key !== "") { |
| 241 | 258 | undecryptable += 1; |
| 242 | 259 | undecryptableHosts.add(bareHost(row.host_key)); |
| 243 | 260 | continue; |
| 244 | 261 | } |
| 245 | // Chromium stores legacy/plaintext cookies in `value` with an empty | |
| 246 | // encrypted blob. Preserve an actually empty cookie by falling back to | |
| 247 | // `value`, rather than treating every empty blob as the empty string. | |
| 248 | 262 | const value = |
| 249 | 263 | row.encrypted_value.length === 0 |
| 250 | 264 | ? row.value |
| 251 | : decryptValue(row.encrypted_value, key, row.host_key, rows.schemaVersion, platform); | |
| 265 | : decryptChromiumValue( | |
| 266 | row.encrypted_value, | |
| 267 | keys, | |
| 268 | row.host_key, | |
| 269 | result.schemaVersion, | |
| 270 | platform, | |
| 271 | ); | |
| 252 | 272 | if (value === null) { |
| 253 | 273 | undecryptable += 1; |
| 254 | 274 | undecryptableHosts.add(bareHost(row.host_key)); |
| ... | ... | @@ -268,6 +288,19 @@ export const readChromiumCookieDatabase = Effect.fn("ChromiumCookies.readChromiu |
| 268 | 288 | sameSite: sameSiteFromColumn(row.samesite), |
| 269 | 289 | }); |
| 270 | 290 | } |
| 291 | // Keep partial imports, but do not call a missing key a successful import | |
| 292 | // when it prevented every otherwise importable cookie from being read. | |
| 293 | if ( | |
| 294 | cookies.length === 0 && | |
| 295 | keys.cbcV11Error !== undefined && | |
| 296 | result.rows.some( | |
| 297 | (row) => | |
| 298 | row.top_frame_site_key === "" && | |
| 299 | Buffer.from(row.encrypted_value.subarray(0, 3)).toString("latin1") === "v11", | |
| 300 | ) | |
| 301 | ) { | |
| 302 | return yield* keys.cbcV11Error; | |
| 303 | } | |
| 271 | 304 | return { |
| 272 | 305 | cookies, |
| 273 | 306 | undecryptable, |
| ... | ... | @@ -278,35 +311,39 @@ export const readChromiumCookieDatabase = Effect.fn("ChromiumCookies.readChromiu |
| 278 | 311 | |
| 279 | 312 | export interface ChromiumCookieSource { |
| 280 | 313 | readonly cookieDatabasePath: string; |
| 281 | readonly keychainService: string; | |
| 282 | readonly keychainAccount: string; | |
| 314 | readonly keychainService: string | undefined; | |
| 315 | readonly keychainAccount: string | undefined; | |
| 316 | readonly linuxSecretApplication: string | undefined; | |
| 317 | readonly windowsLocalStatePath?: string; | |
| 283 | 318 | /** Supplied by the caller from `HostProcessPlatform` rather than read here. */ |
| 284 | 319 | readonly platform: NodeJS.Platform; |
| 285 | 320 | } |
| 286 | 321 | |
| 287 | 322 | export const readChromiumCookies = Effect.fn("ChromiumCookies.readChromiumCookies")(function* ( |
| 288 | 323 | source: ChromiumCookieSource, |
| 289 | ) { | |
| 290 | if (source.platform !== "darwin") { | |
| 291 | // Linux (libsecret) and Windows (DPAPI, and App-Bound Encryption on | |
| 292 | // current Chrome) each need their own key path; only macOS is implemented. | |
| 293 | return yield* new ChromiumCookieReadError({ | |
| 294 | reason: "unsupportedPlatform", | |
| 295 | cookieDatabasePath: source.cookieDatabasePath, | |
| 296 | }); | |
| 297 | } | |
| 298 | ||
| 299 | const password = yield* readMacKeychainPassword( | |
| 300 | source.keychainService, | |
| 301 | source.keychainAccount, | |
| 302 | source.cookieDatabasePath, | |
| 303 | ); | |
| 304 | const key = NodeCrypto.pbkdf2Sync( | |
| 305 | password, | |
| 306 | MAC_KEY_SALT, | |
| 307 | MAC_KEY_ITERATIONS, | |
| 308 | MAC_KEY_LENGTH, | |
| 309 | "sha1", | |
| 324 | ): Effect.fn.Return< | |
| 325 | CookieReadResult, | |
| 326 | ChromiumCookieReadError, | |
| 327 | FileSystem.FileSystem | Path.Path | Scope.Scope | ChildProcessSpawner.ChildProcessSpawner | |
| 328 | > { | |
| 329 | const keys = yield* ( | |
| 330 | source.platform === "win32" && source.windowsLocalStatePath | |
| 331 | ? readWindowsKey(source.windowsLocalStatePath).pipe(Effect.map((gcmV10) => ({ gcmV10 }))) | |
| 332 | : resolveChromiumKeys({ | |
| 333 | platform: source.platform, | |
| 334 | keychainService: source.keychainService, | |
| 335 | keychainAccount: source.keychainAccount, | |
| 336 | linuxSecretApplication: source.linuxSecretApplication, | |
| 337 | }) | |
| 338 | ).pipe( | |
| 339 | Effect.mapError( | |
| 340 | (cause: ChromiumKeyError) => | |
| 341 | new ChromiumCookieReadError({ | |
| 342 | reason: cause.reason, | |
| 343 | cookieDatabasePath: source.cookieDatabasePath, | |
| 344 | cause, | |
| 345 | }), | |
| 346 | ), | |
| 310 | 347 | ); |
| 311 | 348 | |
| 312 | 349 | const snapshotPath = yield* snapshotCookieDatabase(source.cookieDatabasePath).pipe( |
| ... | ... | @@ -320,11 +357,11 @@ export const readChromiumCookies = Effect.fn("ChromiumCookies.readChromiumCookie |
| 320 | 357 | ), |
| 321 | 358 | ); |
| 322 | 359 | |
| 323 | return yield* readChromiumCookieDatabase(snapshotPath, key, source.platform).pipe( | |
| 360 | return yield* readChromiumCookieDatabase(snapshotPath, keys, source.platform).pipe( | |
| 324 | 361 | Effect.mapError( |
| 325 | 362 | (cause) => |
| 326 | 363 | new ChromiumCookieReadError({ |
| 327 | reason: "readFailed", | |
| 364 | reason: isChromiumKeyError(cause) ? cause.reason : "readFailed", | |
| 328 | 365 | cookieDatabasePath: source.cookieDatabasePath, |
| 329 | 366 | cause, |
| 330 | 367 | }), |
apps/desktop/src/preview/BrowserImport/ChromiumKeys.test.ts created+274| ... | ... | @@ -0,0 +1,274 @@ |
| 1 | import { describe, expect, it } from "@effect/vitest"; | |
| 2 | import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; | |
| 3 | import * as Deferred from "effect/Deferred"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as Layer from "effect/Layer"; | |
| 6 | import * as Sink from "effect/Sink"; | |
| 7 | import * as Stream from "effect/Stream"; | |
| 8 | import * as PlatformError from "effect/PlatformError"; | |
| 9 | import { ChildProcessSpawner } from "effect/unstable/process"; | |
| 10 | ||
| 11 | import { | |
| 12 | ChromiumKeyError, | |
| 13 | decodeWindowsWrappedKey, | |
| 14 | readLinuxSecret, | |
| 15 | resolveChromiumKeys, | |
| 16 | unwrapWindowsDpapiKey, | |
| 17 | } from "./ChromiumKeys.ts"; | |
| 18 | import { LinuxBrowserSecretPath } from "./LinuxBrowserSecret.ts"; | |
| 19 | ||
| 20 | type CapturedCommand = { | |
| 21 | readonly command: string; | |
| 22 | readonly args: ReadonlyArray<string>; | |
| 23 | readonly options: { | |
| 24 | readonly stdin?: string; | |
| 25 | readonly env?: Readonly<Record<string, string | undefined>>; | |
| 26 | }; | |
| 27 | }; | |
| 28 | ||
| 29 | const helperLayer = (input: { | |
| 30 | readonly stdout?: string; | |
| 31 | readonly stderr?: string; | |
| 32 | readonly stdoutStream?: Stream.Stream<Uint8Array>; | |
| 33 | readonly stderrStream?: Stream.Stream<Uint8Array>; | |
| 34 | readonly exitCode?: number; | |
| 35 | readonly spawnError?: PlatformError.PlatformError; | |
| 36 | readonly capture?: (command: CapturedCommand) => void; | |
| 37 | }) => | |
| 38 | Layer.merge( | |
| 39 | Layer.succeed(LinuxBrowserSecretPath, "/bundled/browser-secret/t3-browser-secret"), | |
| 40 | Layer.succeed( | |
| 41 | ChildProcessSpawner.ChildProcessSpawner, | |
| 42 | ChildProcessSpawner.make((command) => | |
| 43 | input.spawnError | |
| 44 | ? Effect.fail(input.spawnError) | |
| 45 | : Effect.succeed( | |
| 46 | ChildProcessSpawner.makeHandle({ | |
| 47 | pid: ChildProcessSpawner.ProcessId(1), | |
| 48 | exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(input.exitCode ?? 0)), | |
| 49 | isRunning: Effect.succeed(false), | |
| 50 | kill: () => Effect.void, | |
| 51 | unref: Effect.succeed(Effect.void), | |
| 52 | stdin: Sink.drain, | |
| 53 | stdout: input.stdoutStream ?? Stream.encodeText(Stream.make(input.stdout ?? "")), | |
| 54 | stderr: input.stderrStream ?? Stream.encodeText(Stream.make(input.stderr ?? "")), | |
| 55 | all: Stream.empty, | |
| 56 | getInputFd: () => Sink.drain, | |
| 57 | getOutputFd: () => Stream.empty, | |
| 58 | }), | |
| 59 | ).pipe( | |
| 60 | Effect.tap(() => Effect.sync(() => input.capture?.(command as CapturedCommand))), | |
| 61 | ), | |
| 62 | ), | |
| 63 | ), | |
| 64 | ); | |
| 65 | ||
| 66 | describe("Linux Chromium secrets", () => { | |
| 67 | it.effect("retains a missing helper failure alongside the keyring-free fallback", () => | |
| 68 | Effect.gen(function* () { | |
| 69 | const keys = yield* resolveChromiumKeys({ | |
| 70 | platform: "linux", | |
| 71 | keychainService: undefined, | |
| 72 | keychainAccount: undefined, | |
| 73 | linuxSecretApplication: "chromium", | |
| 74 | }); | |
| 75 | expect(keys.cbcV10).toHaveLength(16); | |
| 76 | expect(keys.cbcV11).toBeUndefined(); | |
| 77 | expect(keys.cbcV11Error?.reason).toBe("keychainUnavailable"); | |
| 78 | }).pipe( | |
| 79 | Effect.provide( | |
| 80 | helperLayer({ | |
| 81 | spawnError: PlatformError.systemError({ | |
| 82 | _tag: "NotFound", | |
| 83 | module: "ChildProcess", | |
| 84 | method: "spawn", | |
| 85 | }), | |
| 86 | }), | |
| 87 | ), | |
| 88 | ), | |
| 89 | ); | |
| 90 | ||
| 91 | it.effect("reports an unconfigured helper without searching PATH", () => | |
| 92 | readLinuxSecret("chromium").pipe( | |
| 93 | Effect.flip, | |
| 94 | Effect.tap((error) => Effect.sync(() => expect(error.reason).toBe("keychainUnavailable"))), | |
| 95 | Effect.provideService( | |
| 96 | ChildProcessSpawner.ChildProcessSpawner, | |
| 97 | ChildProcessSpawner.make(() => Effect.die("must not spawn")), | |
| 98 | ), | |
| 99 | Effect.provideService(LinuxBrowserSecretPath, undefined), | |
| 100 | ), | |
| 101 | ); | |
| 102 | ||
| 103 | it.effect("looks up the browser's libsecret application attribute", () => { | |
| 104 | let captured: CapturedCommand | undefined; | |
| 105 | return Effect.gen(function* () { | |
| 106 | const keys = yield* resolveChromiumKeys({ | |
| 107 | platform: "linux", | |
| 108 | keychainService: "ignored macOS service", | |
| 109 | keychainAccount: "ignored macOS account", | |
| 110 | linuxSecretApplication: "msedge", | |
| 111 | }); | |
| 112 | ||
| 113 | expect(captured?.command).toBe("/bundled/browser-secret/t3-browser-secret"); | |
| 114 | expect(captured?.args).toEqual(["msedge"]); | |
| 115 | expect(captured?.options.stdin).toBe("ignore"); | |
| 116 | expect(keys.cbcV10).toHaveLength(16); | |
| 117 | expect(keys.cbcV11).toHaveLength(16); | |
| 118 | }).pipe( | |
| 119 | Effect.provide( | |
| 120 | helperLayer({ stdout: "linux-secret", capture: (value) => (captured = value) }), | |
| 121 | ), | |
| 122 | ); | |
| 123 | }); | |
| 124 | ||
| 125 | it.effect("reports an unavailable Secret Service backend as a read failure", () => | |
| 126 | Effect.gen(function* () { | |
| 127 | const error = yield* readLinuxSecret("chrome").pipe(Effect.flip); | |
| 128 | expect(error).toBeInstanceOf(ChromiumKeyError); | |
| 129 | expect(error.reason).toBe("keychainUnavailable"); | |
| 130 | }).pipe( | |
| 131 | Effect.provide( | |
| 132 | helperLayer({ stderr: "Cannot autolaunch D-Bus without X11 $DISPLAY", exitCode: 1 }), | |
| 133 | ), | |
| 134 | ), | |
| 135 | ); | |
| 136 | ||
| 137 | it.effect("preserves trailing whitespace in the stored secret", () => | |
| 138 | Effect.gen(function* () { | |
| 139 | const secret = yield* readLinuxSecret("chrome"); | |
| 140 | expect(secret).toBe("linux-secret \t\n"); | |
| 141 | }).pipe(Effect.provide(helperLayer({ stdout: "linux-secret \t\n" }))), | |
| 142 | ); | |
| 143 | ||
| 144 | it.effect("drains stdout and stderr concurrently", () => | |
| 145 | Effect.gen(function* () { | |
| 146 | const stderrDrainStarted = yield* Deferred.make<void>(); | |
| 147 | const stdout = Stream.fromEffect(Deferred.await(stderrDrainStarted)).pipe( | |
| 148 | Stream.flatMap(() => Stream.encodeText(Stream.make("linux-secret"))), | |
| 149 | ); | |
| 150 | const stderr = Stream.fromEffect(Deferred.succeed(stderrDrainStarted, undefined)).pipe( | |
| 151 | Stream.drain, | |
| 152 | ); | |
| 153 | ||
| 154 | const secret = yield* readLinuxSecret("chrome").pipe( | |
| 155 | Effect.provide(helperLayer({ stdoutStream: stdout, stderrStream: stderr })), | |
| 156 | ); | |
| 157 | ||
| 158 | expect(secret).toBe("linux-secret"); | |
| 159 | }), | |
| 160 | ); | |
| 161 | ||
| 162 | it.effect( | |
| 163 | "preserves the desktop environment and identifies denial without parsing stderr", | |
| 164 | () => { | |
| 165 | let captured: CapturedCommand | undefined; | |
| 166 | return Effect.gen(function* () { | |
| 167 | const error = yield* readLinuxSecret("brave").pipe(Effect.flip); | |
| 168 | expect(error).toBeInstanceOf(ChromiumKeyError); | |
| 169 | expect(error.reason).toBe("needsKeychainApproval"); | |
| 170 | expect(captured?.options.env?.LC_ALL).toBe("localized"); | |
| 171 | expect(captured?.options.env?.PATH).toBe("/synthetic/bin"); | |
| 172 | expect(captured?.options.env?.SESSION_MARKER).toBe("kept"); | |
| 173 | }).pipe( | |
| 174 | Effect.provide( | |
| 175 | helperLayer({ | |
| 176 | stderr: "Zugriff verweigert", | |
| 177 | exitCode: 3, | |
| 178 | capture: (value) => (captured = value), | |
| 179 | }), | |
| 180 | ), | |
| 181 | Effect.provideService(HostProcessEnvironment, { | |
| 182 | PATH: "/synthetic/bin", | |
| 183 | SESSION_MARKER: "kept", | |
| 184 | LC_ALL: "localized", | |
| 185 | }), | |
| 186 | ); | |
| 187 | }, | |
| 188 | ); | |
| 189 | ||
| 190 | it.effect("does not discard a denied unlock prompt while resolving keys", () => | |
| 191 | Effect.gen(function* () { | |
| 192 | const error = yield* resolveChromiumKeys({ | |
| 193 | platform: "linux", | |
| 194 | keychainService: undefined, | |
| 195 | keychainAccount: undefined, | |
| 196 | linuxSecretApplication: "brave", | |
| 197 | }).pipe(Effect.flip); | |
| 198 | expect(error.reason).toBe("needsKeychainApproval"); | |
| 199 | }).pipe(Effect.provide(helperLayer({ stderr: "Keyring is locked", exitCode: 3 }))), | |
| 200 | ); | |
| 201 | ||
| 202 | it.effect("keeps the v10 fallback when the Secret Service backend is unavailable", () => | |
| 203 | Effect.gen(function* () { | |
| 204 | const keys = yield* resolveChromiumKeys({ | |
| 205 | platform: "linux", | |
| 206 | keychainService: undefined, | |
| 207 | keychainAccount: undefined, | |
| 208 | linuxSecretApplication: "chrome", | |
| 209 | }); | |
| 210 | expect(keys.cbcV10).toHaveLength(16); | |
| 211 | expect(keys.cbcV11).toBeUndefined(); | |
| 212 | }).pipe( | |
| 213 | Effect.provide( | |
| 214 | helperLayer({ | |
| 215 | stderr: "Cannot autolaunch D-Bus without X11 $DISPLAY", | |
| 216 | exitCode: 1, | |
| 217 | }), | |
| 218 | ), | |
| 219 | ), | |
| 220 | ); | |
| 221 | ||
| 222 | it.effect("keeps the v10 fallback when no matching v11 secret exists", () => | |
| 223 | Effect.gen(function* () { | |
| 224 | const keys = yield* resolveChromiumKeys({ | |
| 225 | platform: "linux", | |
| 226 | keychainService: undefined, | |
| 227 | keychainAccount: undefined, | |
| 228 | linuxSecretApplication: "vivaldi", | |
| 229 | }); | |
| 230 | expect(keys.cbcV10).toHaveLength(16); | |
| 231 | expect(keys.cbcV11).toBeUndefined(); | |
| 232 | }).pipe(Effect.provide(helperLayer({ exitCode: 2 }))), | |
| 233 | ); | |
| 234 | }); | |
| 235 | ||
| 236 | describe("Windows Chromium secrets", () => { | |
| 237 | it.effect("accepts only DPAPI-wrapped non-app-bound keys", () => | |
| 238 | Effect.gen(function* () { | |
| 239 | const wrapped = Buffer.from("wrapped-key"); | |
| 240 | const encoded = Buffer.concat([Buffer.from("DPAPI"), wrapped]).toString("base64"); | |
| 241 | const localState = `{"os_crypt":{"encrypted_key":"${encoded}"}}`; | |
| 242 | expect(yield* decodeWindowsWrappedKey(localState)).toEqual(wrapped); | |
| 243 | ||
| 244 | const appBound = yield* decodeWindowsWrappedKey( | |
| 245 | `{"os_crypt":{"encrypted_key":"${encoded}","app_bound_encrypted_key":"present"}}`, | |
| 246 | ).pipe(Effect.flip); | |
| 247 | expect(appBound.reason).toBe("unsupportedPlatform"); | |
| 248 | ||
| 249 | const malformed = yield* decodeWindowsWrappedKey( | |
| 250 | `{"os_crypt":{"encrypted_key":"${wrapped.toString("base64")}"}}`, | |
| 251 | ).pipe(Effect.flip); | |
| 252 | expect(malformed.reason).toBe("readFailed"); | |
| 253 | }), | |
| 254 | ); | |
| 255 | ||
| 256 | it.effect("unwraps the binary key through PowerShell without placing it in argv", () => { | |
| 257 | let captured: CapturedCommand | undefined; | |
| 258 | const wrapped = Buffer.from("wrapped-key"); | |
| 259 | const key = Buffer.from("0123456789abcdef0123456789abcdef"); | |
| 260 | return Effect.gen(function* () { | |
| 261 | expect(yield* unwrapWindowsDpapiKey(wrapped)).toEqual(key); | |
| 262 | expect(captured?.command).toBe( | |
| 263 | "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", | |
| 264 | ); | |
| 265 | expect(captured?.args).toContain("-NonInteractive"); | |
| 266 | expect(captured?.args.join(" ")).not.toContain(wrapped.toString("base64")); | |
| 267 | }).pipe( | |
| 268 | Effect.provide( | |
| 269 | helperLayer({ stdout: key.toString("base64"), capture: (value) => (captured = value) }), | |
| 270 | ), | |
| 271 | Effect.provideService(HostProcessEnvironment, { SystemRoot: "C:\\Windows" }), | |
| 272 | ); | |
| 273 | }); | |
| 274 | }); |
apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts created+333| ... | ... | @@ -0,0 +1,333 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off - `node:crypto` implements the | |
| 2 | // OSCrypt key derivation Chromium uses; Effect has no equivalent. | |
| 3 | /** | |
| 4 | * Chromium cookie-encryption keys, per platform. | |
| 5 | * | |
| 6 | * Chromium calls this OSCrypt, and it works differently on each OS: | |
| 7 | * | |
| 8 | * - **macOS** keeps one key in the login keychain. Reading it prompts the | |
| 9 | * user, which is the consent this feature is built around. | |
| 10 | * - **Linux** may keep a key in libsecret/kwallet (`v11` records), or use a | |
| 11 | * hardcoded `peanuts` passphrase when no keyring is available (`v10`). Both | |
| 12 | * can appear in the same database, so both are derived up front and chosen | |
| 13 | * per record. | |
| 14 | * | |
| 15 | * - **Windows** legacy Chromium stores protect a random AES key with DPAPI. | |
| 16 | * App-Bound Encryption remains deliberately unsupported. | |
| 17 | * | |
| 18 | * @module ChromiumKeys | |
| 19 | */ | |
| 20 | import * as Keyring from "@napi-rs/keyring"; | |
| 21 | import * as NodeCrypto from "node:crypto"; | |
| 22 | ||
| 23 | import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; | |
| 24 | ||
| 25 | import * as Effect from "effect/Effect"; | |
| 26 | import * as Encoding from "effect/Encoding"; | |
| 27 | import * as FileSystem from "effect/FileSystem"; | |
| 28 | import * as Schema from "effect/Schema"; | |
| 29 | import * as Stream from "effect/Stream"; | |
| 30 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | |
| 31 | import { LinuxBrowserSecretPath } from "./LinuxBrowserSecret.ts"; | |
| 32 | ||
| 33 | const KEY_SALT = "saltysalt"; | |
| 34 | const KEY_LENGTH = 16; | |
| 35 | /** macOS stretches the keychain secret; Linux uses a single iteration. */ | |
| 36 | const MAC_KEY_ITERATIONS = 1003; | |
| 37 | const LINUX_KEY_ITERATIONS = 1; | |
| 38 | /** Chromium's documented fallback passphrase when no Linux keyring is present. */ | |
| 39 | const LINUX_FALLBACK_PASSPHRASE = "peanuts"; | |
| 40 | ||
| 41 | export const ChromiumKeyFailure = Schema.Literals([ | |
| 42 | "needsKeychainApproval", | |
| 43 | "keychainItemMissing", | |
| 44 | "keychainUnavailable", | |
| 45 | "unsupportedPlatform", | |
| 46 | /** The key store itself could not be read, as opposed to holding no key. */ | |
| 47 | "readFailed", | |
| 48 | ]); | |
| 49 | export type ChromiumKeyFailure = typeof ChromiumKeyFailure.Type; | |
| 50 | ||
| 51 | export class ChromiumKeyError extends Schema.TaggedErrorClass<ChromiumKeyError>()( | |
| 52 | "ChromiumKeyError", | |
| 53 | { | |
| 54 | reason: ChromiumKeyFailure, | |
| 55 | /** Kept for the log; never surfaced to the user. */ | |
| 56 | cause: Schema.optional(Schema.Defect()), | |
| 57 | }, | |
| 58 | ) { | |
| 59 | override get message(): string { | |
| 60 | return `Could not obtain the Chromium cookie key: ${this.reason}.`; | |
| 61 | } | |
| 62 | } | |
| 63 | ||
| 64 | /** | |
| 65 | * Keys to try, indexed by the record prefix they decrypt. A database can hold | |
| 66 | * records written under more than one scheme, so a missing entry means those | |
| 67 | * records are skipped rather than the whole import failing. | |
| 68 | */ | |
| 69 | export interface ChromiumKeyMaterial { | |
| 70 | /** AES-128-CBC on macOS, and the keyring-free Linux fallback. */ | |
| 71 | readonly cbcV10?: Buffer; | |
| 72 | /** AES-128-CBC, Linux keyring-derived. */ | |
| 73 | readonly cbcV11?: Buffer; | |
| 74 | /** Retained so an import that needs this key can report why it is missing. */ | |
| 75 | readonly cbcV11Error?: ChromiumKeyError; | |
| 76 | /** | |
| 77 | * AES-128-CBC from an empty passphrase. Some Linux clients wrote records | |
| 78 | * with it (crbug.com/1195256), so Chromium — and this import — retry with it | |
| 79 | * after a record's own key fails. | |
| 80 | */ | |
| 81 | readonly cbcEmpty?: Buffer; | |
| 82 | /** AES-256-GCM key used by pre-App-Bound Chromium on Windows. */ | |
| 83 | readonly gcmV10?: Buffer; | |
| 84 | } | |
| 85 | ||
| 86 | const derive = (passphrase: string, iterations: number) => | |
| 87 | NodeCrypto.pbkdf2Sync(passphrase, KEY_SALT, iterations, KEY_LENGTH, "sha1"); | |
| 88 | ||
| 89 | /** | |
| 90 | * Reads the macOS OSCrypt secret from the login keychain. | |
| 91 | * | |
| 92 | * Uses the in-process Keychain API rather than shelling out to | |
| 93 | * `/usr/bin/security`, because macOS attributes both the consent prompt and the | |
| 94 | * resulting ACL entry to the binary that asks. Via the CLI the prompt says | |
| 95 | * "security" and "Always Allow" grants trust to a tool every process on the | |
| 96 | * machine can invoke; in-process it names this app and the grant belongs to it. | |
| 97 | * (In an unsigned dev build the name is the dev binary, not the shipped app | |
| 98 | * identity.) | |
| 99 | * | |
| 100 | * Deliberately untimed: macOS answers this with a modal, and a timeout racing | |
| 101 | * the user means the prompt can be approved while nothing is left listening, | |
| 102 | * which reads as "approving did nothing". | |
| 103 | */ | |
| 104 | const readKeychainSecret = Effect.fn("ChromiumKeys.readKeychainSecret")(function* ( | |
| 105 | service: string, | |
| 106 | account: string, | |
| 107 | ) { | |
| 108 | const secret = yield* Effect.try({ | |
| 109 | try: () => new Keyring.Entry(service, account).getPassword(), | |
| 110 | catch: (cause) => { | |
| 111 | const message = String((cause as { message?: unknown } | undefined)?.message ?? ""); | |
| 112 | // Distinguish the causes rather than reporting "approve the prompt" for | |
| 113 | // a failure approving cannot fix. | |
| 114 | const missing = /no (matching )?entry|not found/i.test(message); | |
| 115 | return new ChromiumKeyError({ | |
| 116 | reason: missing ? "keychainItemMissing" : "needsKeychainApproval", | |
| 117 | cause, | |
| 118 | }); | |
| 119 | }, | |
| 120 | }); | |
| 121 | if (secret === null || secret === "") { | |
| 122 | return yield* new ChromiumKeyError({ reason: "keychainItemMissing" }); | |
| 123 | } | |
| 124 | return secret; | |
| 125 | }); | |
| 126 | ||
| 127 | /** | |
| 128 | * The bundled helper searches Chromium's libsecret schema and application | |
| 129 | * attribute, retaining the desktop's normal unlock prompt. Its exit codes | |
| 130 | * distinguish a missing key, denied access, and an unavailable keyring without | |
| 131 | * parsing localized error messages. Stdout is the unmodified secret. | |
| 132 | */ | |
| 133 | export const readLinuxSecret = Effect.fn("ChromiumKeys.readLinuxSecret")(function* ( | |
| 134 | application: string, | |
| 135 | ) { | |
| 136 | return yield* Effect.scoped( | |
| 137 | Effect.gen(function* () { | |
| 138 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 139 | const environment = yield* HostProcessEnvironment; | |
| 140 | const helper = yield* LinuxBrowserSecretPath; | |
| 141 | if (helper === undefined) { | |
| 142 | return yield* new ChromiumKeyError({ reason: "keychainUnavailable" }); | |
| 143 | } | |
| 144 | const handle = yield* spawner | |
| 145 | .spawn(ChildProcess.make(helper, [application], { stdin: "ignore", env: environment })) | |
| 146 | .pipe( | |
| 147 | Effect.mapError( | |
| 148 | (cause) => new ChromiumKeyError({ reason: "keychainUnavailable", cause }), | |
| 149 | ), | |
| 150 | ); | |
| 151 | const [secret, , exitCode] = yield* Effect.all( | |
| 152 | [ | |
| 153 | handle.stdout.pipe(Stream.decodeText(), Stream.mkString), | |
| 154 | handle.stderr.pipe(Stream.runDrain), | |
| 155 | handle.exitCode, | |
| 156 | ], | |
| 157 | { concurrency: "unbounded" }, | |
| 158 | ).pipe( | |
| 159 | Effect.mapError((cause) => new ChromiumKeyError({ reason: "keychainUnavailable", cause })), | |
| 160 | ); | |
| 161 | if (Number(exitCode) !== 0) { | |
| 162 | return yield* new ChromiumKeyError({ | |
| 163 | reason: | |
| 164 | Number(exitCode) === 2 | |
| 165 | ? "keychainItemMissing" | |
| 166 | : Number(exitCode) === 3 | |
| 167 | ? "needsKeychainApproval" | |
| 168 | : "keychainUnavailable", | |
| 169 | }); | |
| 170 | } | |
| 171 | if (secret === "") { | |
| 172 | return yield* new ChromiumKeyError({ reason: "keychainItemMissing" }); | |
| 173 | } | |
| 174 | return secret; | |
| 175 | }), | |
| 176 | ); | |
| 177 | }); | |
| 178 | ||
| 179 | const WindowsLocalState = Schema.Struct({ | |
| 180 | os_crypt: Schema.Struct({ | |
| 181 | encrypted_key: Schema.String, | |
| 182 | app_bound_encrypted_key: Schema.optional(Schema.String), | |
| 183 | }), | |
| 184 | }); | |
| 185 | const decodeWindowsLocalState = Schema.decodeUnknownEffect( | |
| 186 | Schema.fromJsonString(WindowsLocalState), | |
| 187 | ); | |
| 188 | const DPAPI_PREFIX = Buffer.from("DPAPI"); | |
| 189 | const WINDOWS_KEY_LENGTH = 32; | |
| 190 | const WINDOWS_DPAPI_SCRIPT = | |
| 191 | "Add-Type -AssemblyName System.Security;" + | |
| 192 | "$value=[Console]::In.ReadToEnd();" + | |
| 193 | "$encrypted=[Convert]::FromBase64String($value);" + | |
| 194 | "$plain=[Security.Cryptography.ProtectedData]::Unprotect($encrypted,$null,[Security.Cryptography.DataProtectionScope]::CurrentUser);" + | |
| 195 | "[Console]::Out.Write([Convert]::ToBase64String($plain))"; | |
| 196 | ||
| 197 | export const decodeWindowsWrappedKey = Effect.fn("ChromiumKeys.decodeWindowsWrappedKey")(function* ( | |
| 198 | contents: string, | |
| 199 | ) { | |
| 200 | const state = yield* decodeWindowsLocalState(contents).pipe( | |
| 201 | Effect.mapError((cause) => new ChromiumKeyError({ reason: "readFailed", cause })), | |
| 202 | ); | |
| 203 | if (state.os_crypt.app_bound_encrypted_key !== undefined) { | |
| 204 | return yield* new ChromiumKeyError({ reason: "unsupportedPlatform" }); | |
| 205 | } | |
| 206 | const wrapped = yield* Effect.fromResult( | |
| 207 | Encoding.decodeBase64(state.os_crypt.encrypted_key), | |
| 208 | ).pipe(Effect.mapError((cause) => new ChromiumKeyError({ reason: "readFailed", cause }))); | |
| 209 | const wrappedBuffer = Buffer.from(wrapped); | |
| 210 | if (!wrappedBuffer.subarray(0, DPAPI_PREFIX.length).equals(DPAPI_PREFIX)) { | |
| 211 | return yield* new ChromiumKeyError({ reason: "readFailed" }); | |
| 212 | } | |
| 213 | return wrappedBuffer.subarray(DPAPI_PREFIX.length); | |
| 214 | }); | |
| 215 | ||
| 216 | /** Unwraps a key with the current Windows user's DPAPI identity. */ | |
| 217 | export const unwrapWindowsDpapiKey = Effect.fn("ChromiumKeys.unwrapWindowsDpapiKey")(function* ( | |
| 218 | wrapped: Buffer, | |
| 219 | ) { | |
| 220 | const environment = yield* HostProcessEnvironment; | |
| 221 | return yield* Effect.scoped( | |
| 222 | Effect.gen(function* () { | |
| 223 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 224 | const windowsRoot = environment.SystemRoot ?? environment.WINDIR; | |
| 225 | const powershell = windowsRoot | |
| 226 | ? `${windowsRoot}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe` | |
| 227 | : "powershell.exe"; | |
| 228 | const handle = yield* spawner | |
| 229 | .spawn( | |
| 230 | ChildProcess.make( | |
| 231 | powershell, | |
| 232 | [ | |
| 233 | "-NoLogo", | |
| 234 | "-NoProfile", | |
| 235 | "-NonInteractive", | |
| 236 | "-WindowStyle", | |
| 237 | "Hidden", | |
| 238 | "-Command", | |
| 239 | WINDOWS_DPAPI_SCRIPT, | |
| 240 | ], | |
| 241 | { | |
| 242 | env: environment, | |
| 243 | stdin: Stream.encodeText(Stream.make(wrapped.toString("base64"))), | |
| 244 | }, | |
| 245 | ), | |
| 246 | ) | |
| 247 | .pipe( | |
| 248 | Effect.mapError( | |
| 249 | (cause) => new ChromiumKeyError({ reason: "keychainUnavailable", cause }), | |
| 250 | ), | |
| 251 | ); | |
| 252 | const [plainEncoded, , exitCode] = yield* Effect.all( | |
| 253 | [ | |
| 254 | handle.stdout.pipe(Stream.decodeText(), Stream.mkString), | |
| 255 | handle.stderr.pipe(Stream.runDrain), | |
| 256 | handle.exitCode, | |
| 257 | ], | |
| 258 | { concurrency: "unbounded" }, | |
| 259 | ).pipe(Effect.mapError((cause) => new ChromiumKeyError({ reason: "readFailed", cause }))); | |
| 260 | if (Number(exitCode) !== 0) { | |
| 261 | return yield* new ChromiumKeyError({ reason: "readFailed" }); | |
| 262 | } | |
| 263 | const plain = yield* Effect.fromResult(Encoding.decodeBase64(plainEncoded)).pipe( | |
| 264 | Effect.mapError((cause) => new ChromiumKeyError({ reason: "readFailed", cause })), | |
| 265 | ); | |
| 266 | if (plain.length !== WINDOWS_KEY_LENGTH) { | |
| 267 | return yield* new ChromiumKeyError({ reason: "readFailed" }); | |
| 268 | } | |
| 269 | return Buffer.from(plain); | |
| 270 | }), | |
| 271 | ); | |
| 272 | }); | |
| 273 | ||
| 274 | /** Reads and unwraps a legacy Windows Chromium key without exposing it in argv. */ | |
| 275 | export const readWindowsKey = Effect.fn("ChromiumKeys.readWindowsKey")(function* ( | |
| 276 | localStatePath: string, | |
| 277 | ) { | |
| 278 | const fileSystem = yield* FileSystem.FileSystem; | |
| 279 | const contents = yield* fileSystem | |
| 280 | .readFileString(localStatePath) | |
| 281 | .pipe(Effect.mapError((cause) => new ChromiumKeyError({ reason: "readFailed", cause }))); | |
| 282 | return yield* unwrapWindowsDpapiKey(yield* decodeWindowsWrappedKey(contents)); | |
| 283 | }); | |
| 284 | ||
| 285 | export interface ChromiumKeyRequest { | |
| 286 | readonly platform: NodeJS.Platform; | |
| 287 | readonly keychainService: string | undefined; | |
| 288 | readonly keychainAccount: string | undefined; | |
| 289 | readonly linuxSecretApplication: string | undefined; | |
| 290 | } | |
| 291 | ||
| 292 | export const resolveChromiumKeys = Effect.fn("ChromiumKeys.resolveChromiumKeys")(function* ( | |
| 293 | request: ChromiumKeyRequest, | |
| 294 | ): Effect.fn.Return< | |
| 295 | ChromiumKeyMaterial, | |
| 296 | ChromiumKeyError, | |
| 297 | ChildProcessSpawner.ChildProcessSpawner | |
| 298 | > { | |
| 299 | if (request.platform === "darwin") { | |
| 300 | if (!request.keychainService || !request.keychainAccount) { | |
| 301 | return yield* new ChromiumKeyError({ reason: "unsupportedPlatform" }); | |
| 302 | } | |
| 303 | const secret = yield* readKeychainSecret(request.keychainService, request.keychainAccount); | |
| 304 | return { cbcV10: derive(secret, MAC_KEY_ITERATIONS) }; | |
| 305 | } | |
| 306 | ||
| 307 | if (request.platform === "linux") { | |
| 308 | // The fallback passphrase always applies to `v10` records; a keyring | |
| 309 | // secret, when one is reachable, additionally unlocks `v11`. Preserve its | |
| 310 | // failure until the reader knows whether any cookies needed that key. | |
| 311 | const keyringSecret = request.linuxSecretApplication | |
| 312 | ? yield* readLinuxSecret(request.linuxSecretApplication).pipe( | |
| 313 | // v10 remains importable when Secret Service is absent or does not | |
| 314 | // contain a key. An explicit denial/lock/cancel remains a consent | |
| 315 | // failure rather than being silently downgraded. | |
| 316 | Effect.catch((error) => | |
| 317 | error.reason === "needsKeychainApproval" ? Effect.fail(error) : Effect.succeed(error), | |
| 318 | ), | |
| 319 | ) | |
| 320 | : undefined; | |
| 321 | return { | |
| 322 | cbcV10: derive(LINUX_FALLBACK_PASSPHRASE, LINUX_KEY_ITERATIONS), | |
| 323 | ...(typeof keyringSecret === "string" | |
| 324 | ? { cbcV11: derive(keyringSecret, LINUX_KEY_ITERATIONS) } | |
| 325 | : keyringSecret | |
| 326 | ? { cbcV11Error: keyringSecret } | |
| 327 | : {}), | |
| 328 | cbcEmpty: derive("", LINUX_KEY_ITERATIONS), | |
| 329 | }; | |
| 330 | } | |
| 331 | ||
| 332 | return yield* new ChromiumKeyError({ reason: "unsupportedPlatform" }); | |
| 333 | }); |
apps/desktop/src/preview/BrowserImport/LinuxBrowserSecret.test.ts created+69| ... | ... | @@ -0,0 +1,69 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { assert, it } from "@effect/vitest"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as FileSystem from "effect/FileSystem"; | |
| 5 | import * as Layer from "effect/Layer"; | |
| 6 | import * as Path from "effect/Path"; | |
| 7 | ||
| 8 | import * as DesktopConfig from "../../app/DesktopConfig.ts"; | |
| 9 | import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts"; | |
| 10 | import * as LinuxBrowserSecret from "./LinuxBrowserSecret.ts"; | |
| 11 | ||
| 12 | it.layer(NodeServices.layer)("Linux browser secret path", (it) => { | |
| 13 | it.effect("finds development and packaged helpers without falling back outside the install", () => | |
| 14 | Effect.gen(function* () { | |
| 15 | const fileSystem = yield* FileSystem.FileSystem; | |
| 16 | const path = yield* Path.Path; | |
| 17 | const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-browser-secret-path-" }); | |
| 18 | const resourcesPath = path.join(root, "install", "resources"); | |
| 19 | const native = path.join( | |
| 20 | root, | |
| 21 | "native", | |
| 22 | "browser-secret", | |
| 23 | "build", | |
| 24 | "x64", | |
| 25 | "t3-browser-secret", | |
| 26 | ); | |
| 27 | const staged = path.join( | |
| 28 | root, | |
| 29 | "apps", | |
| 30 | "desktop", | |
| 31 | "prod-resources", | |
| 32 | "browser-secret", | |
| 33 | "t3-browser-secret", | |
| 34 | ); | |
| 35 | const packaged = path.join(resourcesPath, "browser-secret", "t3-browser-secret"); | |
| 36 | for (const filename of [native, staged, packaged]) { | |
| 37 | yield* fileSystem.makeDirectory(path.dirname(filename), { recursive: true }); | |
| 38 | yield* fileSystem.writeFileString(filename, "helper"); | |
| 39 | } | |
| 40 | const resolve = (isPackaged: boolean, platform: NodeJS.Platform = "linux") => { | |
| 41 | const environment = DesktopEnvironment.layer({ | |
| 42 | dirname: path.join(root, "apps", "desktop", "dist-electron"), | |
| 43 | homeDirectory: root, | |
| 44 | platform, | |
| 45 | processArch: "x64", | |
| 46 | appVersion: "0.0.1", | |
| 47 | appPath: path.join(resourcesPath, "app.asar"), | |
| 48 | isPackaged, | |
| 49 | resourcesPath, | |
| 50 | runningUnderArm64Translation: false, | |
| 51 | }).pipe(Layer.provide(DesktopConfig.layerTest({}))); | |
| 52 | return LinuxBrowserSecret.LinuxBrowserSecretPath.pipe( | |
| 53 | Effect.provide(LinuxBrowserSecret.layer.pipe(Layer.provide(environment))), | |
| 54 | ); | |
| 55 | }; | |
| 56 | ||
| 57 | assert.equal(yield* resolve(false), native); | |
| 58 | assert.equal(yield* resolve(true), packaged); | |
| 59 | yield* fileSystem.remove(native); | |
| 60 | assert.equal(yield* resolve(false), staged); | |
| 61 | yield* fileSystem.remove(packaged); | |
| 62 | assert.isUndefined(yield* resolve(true)); | |
| 63 | assert.isUndefined(yield* resolve(false, "darwin")); | |
| 64 | assert.isUndefined(yield* resolve(false, "win32")); | |
| 65 | yield* fileSystem.remove(staged); | |
| 66 | assert.isUndefined(yield* resolve(false)); | |
| 67 | }).pipe(Effect.scoped), | |
| 68 | ); | |
| 69 | }); |
apps/desktop/src/preview/BrowserImport/LinuxBrowserSecret.ts created+40| ... | ... | @@ -0,0 +1,40 @@ |
| 1 | import * as Context from "effect/Context"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import * as FileSystem from "effect/FileSystem"; | |
| 4 | import * as Layer from "effect/Layer"; | |
| 5 | ||
| 6 | import { DesktopEnvironment } from "../../app/DesktopEnvironment.ts"; | |
| 7 | ||
| 8 | /** Absolute path to the helper shipped with this desktop instance. */ | |
| 9 | export const LinuxBrowserSecretPath = Context.Reference<string | undefined>( | |
| 10 | "@t3tools/desktop/preview/BrowserImport/LinuxBrowserSecretPath", | |
| 11 | { defaultValue: () => undefined }, | |
| 12 | ); | |
| 13 | ||
| 14 | export const layer = Layer.effect( | |
| 15 | LinuxBrowserSecretPath, | |
| 16 | Effect.gen(function* () { | |
| 17 | const environment = yield* DesktopEnvironment; | |
| 18 | if (environment.platform !== "linux") return undefined; | |
| 19 | const fileSystem = yield* FileSystem.FileSystem; | |
| 20 | const relative = environment.path.join("browser-secret", "t3-browser-secret"); | |
| 21 | const candidates = environment.isPackaged | |
| 22 | ? [environment.path.join(environment.resourcesPath, relative)] | |
| 23 | : [ | |
| 24 | environment.path.join( | |
| 25 | environment.rootDir, | |
| 26 | "native", | |
| 27 | "browser-secret", | |
| 28 | "build", | |
| 29 | environment.processArch, | |
| 30 | "t3-browser-secret", | |
| 31 | ), | |
| 32 | ...environment.resolveResourcePathCandidates(relative), | |
| 33 | ]; | |
| 34 | for (const candidate of candidates) { | |
| 35 | if (yield* fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false))) | |
| 36 | return candidate; | |
| 37 | } | |
| 38 | return undefined; | |
| 39 | }), | |
| 40 | ); |
apps/desktop/src/preview/BrowserImport/Sources.test.ts+118| ... | ... | @@ -28,10 +28,32 @@ import { |
| 28 | 28 | posixLockIsHeld, |
| 29 | 29 | listSourceProfiles, |
| 30 | 30 | sourcePathContext, |
| 31 | windowsChromiumCookiesAreHeld, | |
| 31 | 32 | } from "./Sources.ts"; |
| 32 | 33 | |
| 33 | 34 | const helium = BROWSER_IMPORT_SOURCES.find((source) => source.id === "helium")!; |
| 34 | 35 | |
| 36 | describe("Linux Chromium secret applications", () => { | |
| 37 | it("pins the libsecret application attribute for each supported fork", () => { | |
| 38 | assert.deepEqual( | |
| 39 | Object.fromEntries( | |
| 40 | BROWSER_IMPORT_SOURCES.filter((source) => source.platforms.includes("linux")).map( | |
| 41 | (source) => [source.id, source.linuxSecretApplication], | |
| 42 | ), | |
| 43 | ), | |
| 44 | { | |
| 45 | chrome: "chrome", | |
| 46 | edge: "msedge", | |
| 47 | brave: "brave", | |
| 48 | vivaldi: "vivaldi", | |
| 49 | opera: "opera", | |
| 50 | helium: "chromium", | |
| 51 | firefox: undefined, | |
| 52 | }, | |
| 53 | ); | |
| 54 | }); | |
| 55 | }); | |
| 56 | ||
| 35 | 57 | /** A scratch home with the source's user-data directory already created. */ |
| 36 | 58 | const withSourceHome = Effect.fnUntraced(function* () { |
| 37 | 59 | const fileSystem = yield* FileSystem.FileSystem; |
| ... | ... | @@ -83,7 +105,103 @@ const writeFirefoxCookieDatabase = ( |
| 83 | 105 | database.close(); |
| 84 | 106 | }); |
| 85 | 107 | |
| 108 | describe("Helium on Linux", () => { | |
| 109 | it.effect("discovers its profiles and checks the user-data lock", () => | |
| 110 | run( | |
| 111 | Effect.gen(function* () { | |
| 112 | const fileSystem = yield* FileSystem.FileSystem; | |
| 113 | const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-helium-linux-" }); | |
| 114 | const context = yield* sourcePathContext.pipe( | |
| 115 | Effect.provideService(HostProcessEnvironment, { HOME: home }), | |
| 116 | Effect.provideService(HostProcessPlatform, "linux"), | |
| 117 | ); | |
| 118 | const root = `${home}/.config/net.imput.helium`; | |
| 119 | yield* fileSystem.makeDirectory(`${root}/Default`, { recursive: true }); | |
| 120 | yield* writeCookieDatabase(`${root}/Default/Cookies`, 3); | |
| 121 | yield* fileSystem.writeFileString( | |
| 122 | `${root}/Local State`, | |
| 123 | '{"profile":{"info_cache":{"Default":{"name":"Personal"}}}}', | |
| 124 | ); | |
| 125 | ||
| 126 | assert.include(helium.platforms, "linux"); | |
| 127 | assert.isTrue(yield* isSourceInstalled(helium, context)); | |
| 128 | assert.deepEqual(yield* listSourceProfiles(helium, context), [ | |
| 129 | { directory: "Default", name: "Personal", cookieCount: 3 }, | |
| 130 | ]); | |
| 131 | assert.isFalse(yield* isSourceRunning(helium, context)); | |
| 132 | yield* fileSystem.symlink("foreign-host-4242", `${root}/SingletonLock`); | |
| 133 | assert.isTrue(yield* isSourceRunning(helium, context)); | |
| 134 | }), | |
| 135 | ), | |
| 136 | ); | |
| 137 | }); | |
| 138 | ||
| 139 | describe("Helium on Windows", () => { | |
| 140 | it.effect("uses Helium's local app-data profile while other Chromium forks stay disabled", () => | |
| 141 | run( | |
| 142 | Effect.gen(function* () { | |
| 143 | const context = yield* sourcePathContext.pipe( | |
| 144 | Effect.provideService(HostProcessEnvironment, { | |
| 145 | USERPROFILE: "C:\\Users\\browser-user", | |
| 146 | LOCALAPPDATA: "C:\\Users\\browser-user\\AppData\\Local", | |
| 147 | }), | |
| 148 | Effect.provideService(HostProcessPlatform, "win32"), | |
| 149 | ); | |
| 150 | ||
| 151 | assert.include(helium.platforms, "win32"); | |
| 152 | assert.equal( | |
| 153 | helium.userDataDirectory(context), | |
| 154 | context.path.join( | |
| 155 | "C:\\Users\\browser-user\\AppData\\Local", | |
| 156 | "imput", | |
| 157 | "Helium", | |
| 158 | "User Data", | |
| 159 | ), | |
| 160 | ); | |
| 161 | for (const source of BROWSER_IMPORT_SOURCES) { | |
| 162 | if (source.engine === "chromium" && source.id !== "helium") { | |
| 163 | assert.notInclude(source.platforms, "win32"); | |
| 164 | } | |
| 165 | } | |
| 166 | }), | |
| 167 | ), | |
| 168 | ); | |
| 169 | }); | |
| 170 | ||
| 86 | 171 | describe("isSourceRunning", () => { |
| 172 | it.effect("uses the held cookie database as Chromium's Windows running signal", () => | |
| 173 | run( | |
| 174 | Effect.gen(function* () { | |
| 175 | const fileSystem = yield* FileSystem.FileSystem; | |
| 176 | const home = yield* fileSystem.makeTempDirectoryScoped({ | |
| 177 | prefix: "t3code-helium-windows-lock-", | |
| 178 | }); | |
| 179 | const context = yield* sourcePathContext.pipe( | |
| 180 | Effect.provideService(HostProcessEnvironment, { | |
| 181 | HOME: home, | |
| 182 | LOCALAPPDATA: home, | |
| 183 | }), | |
| 184 | Effect.provideService(HostProcessPlatform, "win32"), | |
| 185 | ); | |
| 186 | const profile = context.path.join(helium.userDataDirectory(context)!, "Default"); | |
| 187 | const database = context.path.join(profile, "Network", "Cookies"); | |
| 188 | yield* fileSystem.makeDirectory(context.path.join(profile, "Network"), { recursive: true }); | |
| 189 | yield* writeCookieDatabase(database, 1); | |
| 190 | ||
| 191 | const probed: string[] = []; | |
| 192 | assert.isTrue( | |
| 193 | yield* windowsChromiumCookiesAreHeld(helium, context, (path) => | |
| 194 | Effect.sync(() => { | |
| 195 | probed.push(path); | |
| 196 | return true; | |
| 197 | }), | |
| 198 | ), | |
| 199 | ); | |
| 200 | assert.deepEqual(probed, [database]); | |
| 201 | }), | |
| 202 | ), | |
| 203 | ); | |
| 204 | ||
| 87 | 205 | it.effect("reads Chromium's dangling SingletonLock symlink as a running browser", () => |
| 88 | 206 | run( |
| 89 | 207 | Effect.gen(function* () { |
apps/desktop/src/preview/BrowserImport/Sources.ts+60-12| ... | ... | @@ -6,11 +6,11 @@ |
| 6 | 6 | * keeps them in plain SQLite with no key at all, so it needs no keychain and |
| 7 | 7 | * works the same on every platform. |
| 8 | 8 | * |
| 9 | * Each entry pins its own paths and keychain coordinates rather than deriving | |
| 10 | * them, because the forks do not agree. Helium uses the keychain service | |
| 11 | * "Helium Storage Key" / account "Helium" where Chrome and its closer | |
| 12 | * relatives use "<Name> Safe Storage" / "<Name>", and the user-data directory | |
| 13 | * differs per fork and per platform. | |
| 9 | * Each entry pins its own paths and credential-store coordinates rather than | |
| 10 | * deriving them, because the forks do not agree. macOS uses service/account | |
| 11 | * pairs, while Linux Chromium uses a custom libsecret schema keyed by an | |
| 12 | * `application` attribute. The user-data directory also differs per fork and | |
| 13 | * per platform. | |
| 14 | 14 | * |
| 15 | 15 | * @module BrowserImportSources |
| 16 | 16 | */ |
| ... | ... | @@ -58,6 +58,8 @@ export interface BrowserImportSourceDefinition { |
| 58 | 58 | /** Chromium on macOS only: where the OSCrypt key lives in the keychain. */ |
| 59 | 59 | readonly keychainService?: string; |
| 60 | 60 | readonly keychainAccount?: string; |
| 61 | /** Chromium's `application` attribute in the Linux libsecret schema. */ | |
| 62 | readonly linuxSecretApplication?: string; | |
| 61 | 63 | } |
| 62 | 64 | |
| 63 | 65 | const macApplicationSupport = ( |
| ... | ... | @@ -66,10 +68,9 @@ const macApplicationSupport = ( |
| 66 | 68 | ) => context.path.join(context.home, "Library", "Application Support", ...segments); |
| 67 | 69 | |
| 68 | 70 | /** |
| 69 | * One Chromium fork on macOS and Linux. The leaves differ per fork; omitting a | |
| 70 | * platform's segments marks the fork as unavailable there. Windows is left out | |
| 71 | * on purpose: since Chrome 127 those cookies are encrypted to the browser's own | |
| 72 | * identity (App-Bound Encryption), so no other process can read them. | |
| 71 | * One Chromium fork. The leaves differ per fork; omitting a platform's | |
| 72 | * segments marks the fork as unavailable there. Most Windows Chromium builds | |
| 73 | * use App-Bound Encryption, but forks can retain the older DPAPI-backed store. | |
| 73 | 74 | */ |
| 74 | 75 | const chromiumSource = (input: { |
| 75 | 76 | readonly id: BrowserImportSourceId; |
| ... | ... | @@ -78,6 +79,8 @@ const chromiumSource = (input: { |
| 78 | 79 | readonly keychainAccount: string; |
| 79 | 80 | readonly macSegments: ReadonlyArray<string>; |
| 80 | 81 | readonly linuxSegments?: ReadonlyArray<string>; |
| 82 | readonly linuxSecretApplication?: string; | |
| 83 | readonly windowsSegments?: ReadonlyArray<string>; | |
| 81 | 84 | }): BrowserImportSourceDefinition => ({ |
| 82 | 85 | id: input.id, |
| 83 | 86 | name: input.name, |
| ... | ... | @@ -85,11 +88,20 @@ const chromiumSource = (input: { |
| 85 | 88 | platforms: [ |
| 86 | 89 | "darwin" as NodeJS.Platform, |
| 87 | 90 | ...(input.linuxSegments ? ["linux" as NodeJS.Platform] : []), |
| 91 | ...(input.windowsSegments ? ["win32" as NodeJS.Platform] : []), | |
| 88 | 92 | ], |
| 89 | 93 | keychainService: input.keychainService, |
| 90 | 94 | keychainAccount: input.keychainAccount, |
| 95 | ...(input.linuxSecretApplication === undefined | |
| 96 | ? {} | |
| 97 | : { linuxSecretApplication: input.linuxSecretApplication }), | |
| 91 | 98 | userDataDirectory: (context) => { |
| 92 | 99 | if (context.platform === "darwin") return macApplicationSupport(context, ...input.macSegments); |
| 100 | if (context.platform === "win32") { | |
| 101 | return input.windowsSegments && context.localAppData | |
| 102 | ? context.path.join(context.localAppData, ...input.windowsSegments) | |
| 103 | : undefined; | |
| 104 | } | |
| 93 | 105 | return input.linuxSegments |
| 94 | 106 | ? context.path.join(context.home, ".config", ...input.linuxSegments) |
| 95 | 107 | : undefined; |
| ... | ... | @@ -104,6 +116,7 @@ export const BROWSER_IMPORT_SOURCES: ReadonlyArray<BrowserImportSourceDefinition |
| 104 | 116 | keychainAccount: "Chrome", |
| 105 | 117 | macSegments: ["Google", "Chrome"], |
| 106 | 118 | linuxSegments: ["google-chrome"], |
| 119 | linuxSecretApplication: "chrome", | |
| 107 | 120 | }), |
| 108 | 121 | chromiumSource({ |
| 109 | 122 | id: "edge", |
| ... | ... | @@ -112,6 +125,7 @@ export const BROWSER_IMPORT_SOURCES: ReadonlyArray<BrowserImportSourceDefinition |
| 112 | 125 | keychainAccount: "Microsoft Edge", |
| 113 | 126 | macSegments: ["Microsoft Edge"], |
| 114 | 127 | linuxSegments: ["microsoft-edge"], |
| 128 | linuxSecretApplication: "msedge", | |
| 115 | 129 | }), |
| 116 | 130 | chromiumSource({ |
| 117 | 131 | id: "brave", |
| ... | ... | @@ -120,6 +134,7 @@ export const BROWSER_IMPORT_SOURCES: ReadonlyArray<BrowserImportSourceDefinition |
| 120 | 134 | keychainAccount: "Brave", |
| 121 | 135 | macSegments: ["BraveSoftware", "Brave-Browser"], |
| 122 | 136 | linuxSegments: ["BraveSoftware", "Brave-Browser"], |
| 137 | linuxSecretApplication: "brave", | |
| 123 | 138 | }), |
| 124 | 139 | chromiumSource({ |
| 125 | 140 | id: "vivaldi", |
| ... | ... | @@ -128,6 +143,7 @@ export const BROWSER_IMPORT_SOURCES: ReadonlyArray<BrowserImportSourceDefinition |
| 128 | 143 | keychainAccount: "Vivaldi", |
| 129 | 144 | macSegments: ["Vivaldi"], |
| 130 | 145 | linuxSegments: ["vivaldi"], |
| 146 | linuxSecretApplication: "vivaldi", | |
| 131 | 147 | }), |
| 132 | 148 | chromiumSource({ |
| 133 | 149 | id: "opera", |
| ... | ... | @@ -136,8 +152,9 @@ export const BROWSER_IMPORT_SOURCES: ReadonlyArray<BrowserImportSourceDefinition |
| 136 | 152 | keychainAccount: "Opera", |
| 137 | 153 | macSegments: ["com.operasoftware.Opera"], |
| 138 | 154 | linuxSegments: ["opera"], |
| 155 | linuxSecretApplication: "opera", | |
| 139 | 156 | }), |
| 140 | // Arc and Helium ship macOS-only builds. | |
| 157 | // Arc has no Linux build. | |
| 141 | 158 | chromiumSource({ |
| 142 | 159 | id: "arc", |
| 143 | 160 | name: "Arc", |
| ... | ... | @@ -151,6 +168,10 @@ export const BROWSER_IMPORT_SOURCES: ReadonlyArray<BrowserImportSourceDefinition |
| 151 | 168 | keychainService: "Helium Storage Key", |
| 152 | 169 | keychainAccount: "Helium", |
| 153 | 170 | macSegments: ["net.imput.helium"], |
| 171 | linuxSegments: ["net.imput.helium"], | |
| 172 | windowsSegments: ["imput", "Helium", "User Data"], | |
| 173 | // Helium retains Chromium's libsecret application name on Linux. | |
| 174 | linuxSecretApplication: "chromium", | |
| 154 | 175 | }), |
| 155 | 176 | { |
| 156 | 177 | id: "firefox", |
| ... | ... | @@ -544,6 +565,29 @@ const windowsLockIsHeld = Effect.fnUntraced(function* (lockPath: string) { |
| 544 | 565 | ); |
| 545 | 566 | }); |
| 546 | 567 | |
| 568 | type WindowsLockProbe = (path: string) => Effect.Effect<boolean, never, FileSystem.FileSystem>; | |
| 569 | ||
| 570 | /** | |
| 571 | * Chromium does not create its POSIX `SingletonLock` symlink on Windows. The | |
| 572 | * live cookie database is opened without sharing instead, so probing each | |
| 573 | * profile's current jar is the reliable running signal there. | |
| 574 | */ | |
| 575 | export const windowsChromiumCookiesAreHeld = Effect.fnUntraced(function* ( | |
| 576 | definition: BrowserImportSourceDefinition, | |
| 577 | context: BrowserImportPathContext, | |
| 578 | lockIsHeld: WindowsLockProbe = windowsLockIsHeld, | |
| 579 | ) { | |
| 580 | const profiles = yield* listSourceProfiles(definition, context); | |
| 581 | const held = yield* Effect.forEach(profiles, (profile) => | |
| 582 | resolveCookieDatabase(definition, context, profile.directory).pipe( | |
| 583 | Effect.flatMap((database) => | |
| 584 | database === undefined ? Effect.succeed(false) : lockIsHeld(database), | |
| 585 | ), | |
| 586 | ), | |
| 587 | ); | |
| 588 | return held.some(Boolean); | |
| 589 | }); | |
| 590 | ||
| 547 | 591 | /** |
| 548 | 592 | * Whether a Firefox `lock` symlink's `<ip>:[+]<pid>` target still names a |
| 549 | 593 | * live owner. Firefox writes this symlink beside the profile while it runs and |
| ... | ... | @@ -694,9 +738,13 @@ export const isSourceRunning = Effect.fn("BrowserImportSources.isSourceRunning") |
| 694 | 738 | const fileSystem = yield* FileSystem.FileSystem; |
| 695 | 739 | const root = definition.userDataDirectory(context); |
| 696 | 740 | if (root === undefined) return false; |
| 697 | // Both engines leave a lock file for as long as an instance holds a profile, | |
| 698 | // which is far cheaper and more targeted than scanning the process table. | |
| 741 | // Probe the source's own lock state rather than scanning the process table. | |
| 742 | // Chromium exposes that through the cookie jar on Windows and through its | |
| 743 | // user-data SingletonLock on POSIX. | |
| 699 | 744 | if (definition.engine !== "firefox") { |
| 745 | if (context.platform === "win32") { | |
| 746 | return yield* windowsChromiumCookiesAreHeld(definition, context); | |
| 747 | } | |
| 700 | 748 | const currentHost = yield* HostProcessHostname; |
| 701 | 749 | const lock = context.path.join(root, "SingletonLock"); |
| 702 | 750 | return yield* fileSystem.readLink(lock).pipe( |
apps/desktop/vite.config.ts+5-3| ... | ... | @@ -14,18 +14,20 @@ export default defineConfig({ |
| 14 | 14 | run: { |
| 15 | 15 | tasks: { |
| 16 | 16 | build: { |
| 17 | command: "node scripts/build-preview-annotation-css.mjs && vp pack", | |
| 17 | command: | |
| 18 | "node scripts/build-browser-secret.mjs && node scripts/build-preview-annotation-css.mjs && vp pack", | |
| 18 | 19 | dependsOn: ["t3#build"], |
| 19 | 20 | cache: false, |
| 20 | 21 | }, |
| 21 | 22 | dev: { |
| 22 | 23 | command: |
| 23 | "node scripts/build-preview-annotation-css.mjs && cross-env T3CODE_DESKTOP_DEV=1 vp pack --watch", | |
| 24 | "node scripts/build-browser-secret.mjs && node scripts/build-preview-annotation-css.mjs && cross-env T3CODE_DESKTOP_DEV=1 vp pack --watch", | |
| 24 | 25 | dependsOn: ["t3#build"], |
| 25 | 26 | cache: false, |
| 26 | 27 | }, |
| 27 | 28 | "dev:bundle": { |
| 28 | command: "node scripts/build-preview-annotation-css.mjs && vp pack --watch", | |
| 29 | command: | |
| 30 | "node scripts/build-browser-secret.mjs && node scripts/build-preview-annotation-css.mjs && vp pack --watch", | |
| 29 | 31 | cache: false, |
| 30 | 32 | }, |
| 31 | 33 | "dev:electron": { |
apps/web/src/components/settings/IntegrationsSettings.logic.test.ts+1| ... | ... | @@ -87,6 +87,7 @@ describe("importFailureReason", () => { |
| 87 | 87 | it("recovers the reason token from the flattened message", () => { |
| 88 | 88 | expect(importFailureReason(failure("browserRunning"))).toBe("browserRunning"); |
| 89 | 89 | expect(importFailureReason(failure("readFailed"))).toBe("readFailed"); |
| 90 | expect(importFailureReason(failure("keychainUnavailable"))).toBe("keychainUnavailable"); | |
| 90 | 91 | // A settings write that fails after the cookies landed is its own case, |
| 91 | 92 | // not a read failure over a database that was in fact read. |
| 92 | 93 | expect(importFailureReason(failure("profileNotSaved"))).toBe("profileNotSaved"); |
apps/web/src/components/settings/browserImportWizard.logic.test.ts+1| ... | ... | @@ -155,6 +155,7 @@ describe("refreshedSourceProfileDirectory", () => { |
| 155 | 155 | describe("isRetryableReason", () => { |
| 156 | 156 | it("offers a retry for failures a second attempt can clear", () => { |
| 157 | 157 | expect(isRetryableReason("needsKeychainApproval")).toBe(true); |
| 158 | expect(isRetryableReason("keychainUnavailable")).toBe(true); | |
| 158 | 159 | expect(isRetryableReason("readFailed")).toBe(true); |
| 159 | 160 | }); |
| 160 | 161 |
apps/web/src/components/settings/browserImportWizard.logic.ts+1| ... | ... | @@ -133,6 +133,7 @@ export function isRetryableReason(reason: BrowserImportFailureReason): boolean { |
| 133 | 133 | switch (reason) { |
| 134 | 134 | case "needsKeychainApproval": |
| 135 | 135 | case "keychainItemMissing": |
| 136 | case "keychainUnavailable": | |
| 136 | 137 | case "readFailed": |
| 137 | 138 | case "sessionUnavailable": |
| 138 | 139 | case "profileNotSaved": |
docs/internals/scripts.md+13-5| ... | ... | @@ -76,28 +76,36 @@ authenticated. |
| 76 | 76 | |
| 77 | 77 | ### Linux AppImage prerequisites |
| 78 | 78 | |
| 79 | Linux AppImage packaging compiles the Rust resource monitor. Install a Rust toolchain, the standard | |
| 80 | C/C++ build tools, and ImageMagick before running `vp run dist:desktop:linux`. | |
| 79 | Linux AppImage packaging compiles the Rust resource monitor and the libsecret browser import | |
| 80 | helper. Install a Rust toolchain, the standard C/C++ build tools, libsecret development headers, | |
| 81 | pkg-config, and ImageMagick before running `vp run dist:desktop:linux`. | |
| 81 | 82 | |
| 82 | 83 | Ubuntu and Debian: |
| 83 | 84 | |
| 84 | 85 | ```bash |
| 85 | 86 | sudo apt-get update |
| 86 | sudo apt-get install cargo rustc build-essential imagemagick | |
| 87 | sudo apt-get install cargo rustc build-essential libsecret-1-dev pkg-config imagemagick | |
| 87 | 88 | ``` |
| 88 | 89 | |
| 89 | 90 | Fedora: |
| 90 | 91 | |
| 91 | 92 | ```bash |
| 92 | sudo dnf install rust cargo gcc gcc-c++ make ImageMagick | |
| 93 | sudo dnf install rust cargo gcc gcc-c++ make libsecret-devel pkgconf-pkg-config ImageMagick | |
| 93 | 94 | ``` |
| 94 | 95 | |
| 95 | 96 | Arch Linux: |
| 96 | 97 | |
| 97 | 98 | ```bash |
| 98 | sudo pacman -S rust base-devel imagemagick | |
| 99 | sudo pacman -S rust base-devel libsecret pkgconf imagemagick | |
| 99 | 100 | ``` |
| 100 | 101 | |
| 102 | Linux desktop development also needs the C compiler, pkg-config, and libsecret headers. Its build | |
| 103 | and launch commands compile `native/browser-secret/main.c` into the gitignored native build | |
| 104 | directory. Releases place the executable in `resources/browser-secret`, outside `app.asar`. | |
| 105 | The helper performs a read-only Chromium schema lookup through libsecret and writes the exact | |
| 106 | secret bytes to its stdout pipe. Exit codes are 2 for a missing key, 3 for denied/locked access, | |
| 107 | and 4 for other keyring failures; the desktop importer preserves these distinctions. | |
| 108 | ||
| 101 | 109 | The artifact script checks these capabilities before starting the web and desktop builds. If |
| 102 | 110 | anything is unavailable, it reports every failed check together and prints the Ubuntu/Debian |
| 103 | 111 | installation command. The check compiles and links tiny temporary programs, so it also catches |
docs/user/browser-import.md+10-3| ... | ... | @@ -5,6 +5,13 @@ and choose a browser under **Import from**. The import copies cookies into a T3 |
| 5 | 5 | profile so you can use existing logins in the preview browser. Changes made afterward stay |
| 6 | 6 | separate from the source browser. |
| 7 | 7 | |
| 8 | On Linux, Firefox profiles are discovered from both native and Snap installations. A browser | |
| 9 | appears once it has a profile with a cookie database. Close the source browser before importing; | |
| 10 | the import wizard will prompt you if it is still running. | |
| 8 | Linux discovery includes Helium and both native and Snap installations of Firefox. Windows | |
| 9 | discovery includes Firefox and Helium builds that still use Windows' standard profile | |
| 10 | encryption. Other Chromium-based browsers on Windows use app-bound cookie encryption and cannot | |
| 11 | be imported. A browser appears once it has a profile with a cookie database. Close the source | |
| 12 | browser before importing; the import wizard will prompt you if it is still running. | |
| 13 | ||
| 14 | On Linux, Chromium-based browsers use your desktop keyring to protect their cookies. T3 Code | |
| 15 | includes the keyring reader; no separate command-line tool is needed. Allow the desktop unlock | |
| 16 | prompt if one appears. If the keyring cannot be accessed, T3 Code reports that failure when no | |
| 17 | cookies can be imported. Partitioned cookies are skipped. |
native/browser-secret/main.c created+57| ... | ... | @@ -0,0 +1,57 @@ |
| 1 | #include <libsecret/secret.h> | |
| 2 | #include <stdio.h> | |
| 3 | ||
| 4 | /* Exit codes are consumed by ChromiumKeys.ts. Stdout contains only the secret, | |
| 5 | * with no newline or other framing that could change the derived cookie key. */ | |
| 6 | enum { KEY_MISSING = 2, KEY_LOCKED = 3, READ_FAILED = 4 }; | |
| 7 | ||
| 8 | static const SecretSchema chromium_schema = { | |
| 9 | .name = "chrome_libsecret_os_crypt_password_v2", | |
| 10 | .flags = SECRET_SCHEMA_NONE, | |
| 11 | .attributes = {{"application", SECRET_SCHEMA_ATTRIBUTE_STRING}, {NULL, 0}}, | |
| 12 | }; | |
| 13 | ||
| 14 | int main(int argc, char **argv) { | |
| 15 | if (argc != 2 || argv[1][0] == '\0') return 64; | |
| 16 | g_set_application_name("T3 Code"); | |
| 17 | ||
| 18 | GHashTable *attributes = secret_attributes_build(&chromium_schema, "application", argv[1], NULL); | |
| 19 | GError *error = NULL; | |
| 20 | /* libsecret keeps the normal desktop unlock prompt. A cancelled unlock can | |
| 21 | * still return a locked item, so check the item before reading its value. */ | |
| 22 | GList *items = secret_service_search_sync( | |
| 23 | NULL, &chromium_schema, attributes, | |
| 24 | SECRET_SEARCH_UNLOCK | SECRET_SEARCH_LOAD_SECRETS, NULL, &error); | |
| 25 | g_hash_table_unref(attributes); | |
| 26 | ||
| 27 | int status = READ_FAILED; | |
| 28 | if (error != NULL) { | |
| 29 | if (g_error_matches(error, G_IO_ERROR, G_IO_ERROR_CANCELLED) || | |
| 30 | g_error_matches(error, G_IO_ERROR, G_IO_ERROR_PERMISSION_DENIED) || | |
| 31 | g_error_matches(error, G_DBUS_ERROR, G_DBUS_ERROR_ACCESS_DENIED) || | |
| 32 | g_error_matches(error, SECRET_ERROR, SECRET_ERROR_IS_LOCKED)) { | |
| 33 | status = KEY_LOCKED; | |
| 34 | } | |
| 35 | g_error_free(error); | |
| 36 | } else if (items == NULL) { | |
| 37 | status = KEY_MISSING; | |
| 38 | } else { | |
| 39 | SecretItem *item = SECRET_ITEM(items->data); | |
| 40 | if (secret_item_get_locked(item)) { | |
| 41 | status = KEY_LOCKED; | |
| 42 | } else { | |
| 43 | SecretValue *secret = secret_item_get_secret(item); | |
| 44 | if (secret != NULL) { | |
| 45 | gsize length = 0; | |
| 46 | const gchar *value = secret_value_get(secret, &length); | |
| 47 | status = length == 0 ? KEY_MISSING : READ_FAILED; | |
| 48 | if (length > 0 && fwrite(value, 1, length, stdout) == length && fflush(stdout) == 0) { | |
| 49 | status = 0; | |
| 50 | } | |
| 51 | secret_value_unref(secret); | |
| 52 | } | |
| 53 | } | |
| 54 | } | |
| 55 | g_list_free_full(items, g_object_unref); | |
| 56 | return status; | |
| 57 | } |
native/browser-secret/test.c created+45| ... | ... | @@ -0,0 +1,45 @@ |
| 1 | /* Replaces only the keyring calls at link time. The executable under test uses | |
| 2 | * the real libsecret schema/values and GLib ownership, without a desktop bus. */ | |
| 3 | #include <libsecret/secret.h> | |
| 4 | #include <string.h> | |
| 5 | ||
| 6 | static const char *scenario; | |
| 7 | ||
| 8 | GList *__wrap_secret_service_search_sync(SecretService *service, const SecretSchema *schema, | |
| 9 | GHashTable *attributes, SecretSearchFlags flags, | |
| 10 | GCancellable *cancellable, GError **error) { | |
| 11 | g_assert_null(service); | |
| 12 | g_assert_null(cancellable); | |
| 13 | g_assert_cmpstr(schema->name, ==, "chrome_libsecret_os_crypt_password_v2"); | |
| 14 | g_assert_cmpint(schema->flags, ==, SECRET_SCHEMA_NONE); | |
| 15 | g_assert_cmpint(flags, ==, SECRET_SEARCH_UNLOCK | SECRET_SEARCH_LOAD_SECRETS); | |
| 16 | g_assert_cmpuint(g_hash_table_size(attributes), ==, 1); | |
| 17 | scenario = g_intern_string(g_hash_table_lookup(attributes, "application")); | |
| 18 | if (strcmp(scenario, "missing") == 0) return NULL; | |
| 19 | if (strcmp(scenario, "cancelled") == 0) { | |
| 20 | g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_CANCELLED, "cancelled"); | |
| 21 | return NULL; | |
| 22 | } | |
| 23 | if (strcmp(scenario, "denied") == 0) { | |
| 24 | g_set_error_literal(error, G_DBUS_ERROR, G_DBUS_ERROR_ACCESS_DENIED, "denied"); | |
| 25 | return NULL; | |
| 26 | } | |
| 27 | if (strcmp(scenario, "unavailable") == 0) { | |
| 28 | g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_CONNECTION_CLOSED, "unavailable"); | |
| 29 | return NULL; | |
| 30 | } | |
| 31 | return g_list_append(NULL, g_object_new(SECRET_TYPE_ITEM, NULL)); | |
| 32 | } | |
| 33 | ||
| 34 | gboolean __wrap_secret_item_get_locked(SecretItem *item) { | |
| 35 | g_assert_true(SECRET_IS_ITEM(item)); | |
| 36 | return strcmp(scenario, "locked") == 0; | |
| 37 | } | |
| 38 | ||
| 39 | SecretValue *__wrap_secret_item_get_secret(SecretItem *item) { | |
| 40 | g_assert_true(SECRET_IS_ITEM(item)); | |
| 41 | if (strcmp(scenario, "unloaded") == 0) return NULL; | |
| 42 | if (strcmp(scenario, "empty") == 0) return secret_value_new("", 0, "text/plain"); | |
| 43 | const char value[] = "secret\0with whitespace \t\r\n"; | |
| 44 | return secret_value_new(value, sizeof(value) - 1, "application/octet-stream"); | |
| 45 | } |
packages/contracts/src/browserImport.ts+4| ... | ... | @@ -56,6 +56,8 @@ export type BrowserImportUnavailableReason = typeof BrowserImportUnavailableReas |
| 56 | 56 | */ |
| 57 | 57 | export const BrowserImportFailureReason = Schema.Literals([ |
| 58 | 58 | ...BrowserImportUnavailableReason.literals, |
| 59 | /** The operating system's keyring or its bundled reader is unavailable. */ | |
| 60 | "keychainUnavailable", | |
| 59 | 61 | /** No source registered under the requested id. */ |
| 60 | 62 | "unknownSource", |
| 61 | 63 | /** The requested profile directory is not one the source reported. */ |
| ... | ... | @@ -149,6 +151,8 @@ export const BROWSER_IMPORT_UNAVAILABLE_COPY: Readonly< |
| 149 | 151 | /** What to tell the user when an attempted import fails. */ |
| 150 | 152 | export const BROWSER_IMPORT_FAILURE_COPY: Readonly<Record<BrowserImportFailureReason, string>> = { |
| 151 | 153 | ...BROWSER_IMPORT_UNAVAILABLE_COPY, |
| 154 | keychainUnavailable: | |
| 155 | "The system keyring could not be accessed. Make sure your desktop keyring is running and unlocked, then retry.", | |
| 152 | 156 | unknownSource: "That browser is no longer available to import from.", |
| 153 | 157 | unknownSourceProfile: "That browser profile no longer exists.", |
| 154 | 158 | sessionUnavailable: "The target profile could not be opened.", |
scripts/build-desktop-artifact.test.ts+24-5| ... | ... | @@ -24,6 +24,7 @@ import { |
| 24 | 24 | DESKTOP_ELECTRON_LANGUAGES, |
| 25 | 25 | DESKTOP_FILE_EXCLUSIONS, |
| 26 | 26 | DESKTOP_EXTRA_RESOURCES, |
| 27 | LINUX_BROWSER_SECRET_EXTRA_RESOURCES, | |
| 27 | 28 | MAC_FILE_EXCLUSIONS, |
| 28 | 29 | InvalidMacPasskeyRpDomainError, |
| 29 | 30 | InvalidMacPasskeyPublishableKeyError, |
| ... | ... | @@ -533,12 +534,15 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 533 | 534 | |
| 534 | 535 | it("limits Electron locales and excludes separately packaged resources", () => { |
| 535 | 536 | assert.deepStrictEqual(DESKTOP_ELECTRON_LANGUAGES, ["en-US"]); |
| 536 | // Every WSL staging input is emitted once at resources/, so adding one | |
| 537 | // Every platform staging input is emitted once at resources/, so adding one | |
| 537 | 538 | // without its exclusion silently packs a second copy into app.asar. The |
| 538 | 539 | // snapshot below cannot catch that on its own: adding a resource and |
| 539 | 540 | // forgetting the exclusion leaves the exclusion list untouched, so it still |
| 540 | 541 | // matches. Assert the invariant first, where the failure names the culprit. |
| 541 | for (const resource of WSL_RUNTIME_EXTRA_RESOURCES) { | |
| 542 | for (const resource of [ | |
| 543 | ...WSL_RUNTIME_EXTRA_RESOURCES, | |
| 544 | ...LINUX_BROWSER_SECRET_EXTRA_RESOURCES, | |
| 545 | ]) { | |
| 542 | 546 | assert.include( |
| 543 | 547 | DESKTOP_FILE_EXCLUSIONS, |
| 544 | 548 | `!${resource.from}`, |
| ... | ... | @@ -548,6 +552,10 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 548 | 552 | |
| 549 | 553 | assert.deepStrictEqual(DESKTOP_FILE_EXCLUSIONS, [ |
| 550 | 554 | "!**/node_modules/@anthropic-ai/claude-agent-sdk-*/**/*", |
| 555 | "!apps/desktop/resources/browser-secret", | |
| 556 | "!apps/desktop/resources/browser-secret/**/*", | |
| 557 | "!apps/desktop/prod-resources/browser-secret", | |
| 558 | "!apps/desktop/prod-resources/browser-secret/**/*", | |
| 551 | 559 | "!apps/desktop/prod-resources/windows-server", |
| 552 | 560 | "!apps/desktop/prod-resources/windows-server/**/*", |
| 553 | 561 | "!apps/desktop/prod-resources/wsl-runtime.tar.gz", |
| ... | ... | @@ -610,6 +618,11 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 610 | 618 | assert.notProperty(mac, "asarUnpack"); |
| 611 | 619 | assert.notProperty(linux, "asarUnpack"); |
| 612 | 620 | assert.notProperty(win, "asarUnpack"); |
| 621 | assert.deepStrictEqual(mac.extraResources, DESKTOP_EXTRA_RESOURCES); | |
| 622 | assert.deepStrictEqual(linux.extraResources, [ | |
| 623 | ...DESKTOP_EXTRA_RESOURCES, | |
| 624 | { from: "apps/desktop/prod-resources/browser-secret", to: "browser-secret" }, | |
| 625 | ]); | |
| 613 | 626 | assert.deepStrictEqual(win.extraResources, [ |
| 614 | 627 | { |
| 615 | 628 | from: "apps/desktop/prod-resources/resource-monitor", |
| ... | ... | @@ -831,7 +844,10 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 831 | 844 | readonly args: ReadonlyArray<string>; |
| 832 | 845 | }; |
| 833 | 846 | commands.push(childProcess); |
| 834 | const fails = childProcess.command === "cargo" || childProcess.command === "rustc"; | |
| 847 | const fails = | |
| 848 | childProcess.command === "cargo" || | |
| 849 | childProcess.command === "rustc" || | |
| 850 | childProcess.command === "pkg-config"; | |
| 835 | 851 | return Effect.succeed(mockProcess(fails ? 1 : 0)); |
| 836 | 852 | }), |
| 837 | 853 | ); |
| ... | ... | @@ -842,10 +858,13 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 842 | 858 | ); |
| 843 | 859 | |
| 844 | 860 | assert.instanceOf(error, LinuxDesktopBuildPrerequisitesMissingError); |
| 845 | assert.deepStrictEqual(error.missing, ["cargo", "rust-target"]); | |
| 861 | assert.deepStrictEqual(error.missing, ["cargo", "rust-target", "libsecret"]); | |
| 846 | 862 | assert.include(error.message, "Rust compiler and Cargo (cargo, rustc)"); |
| 847 | 863 | assert.include(error.message, "Requested Rust standard library"); |
| 848 | assert.include(error.message, "sudo apt-get install cargo rustc"); | |
| 864 | assert.include( | |
| 865 | error.message, | |
| 866 | "sudo apt-get install cargo rustc libsecret-1-dev pkg-config", | |
| 867 | ); | |
| 849 | 868 | assert.include(error.message, "rustup target add aarch64-unknown-linux-gnu"); |
| 850 | 869 | assert.isTrue( |
| 851 | 870 | commands.some( |
scripts/build-desktop-artifact.ts+49| ... | ... | @@ -308,6 +308,11 @@ export const LINUX_DESKTOP_BUILD_PREREQUISITES = [ |
| 308 | 308 | { id: "rust-target", description: "Requested Rust standard library", packages: [] }, |
| 309 | 309 | { id: "cc", description: "C/C++ build toolchain", packages: ["build-essential"] }, |
| 310 | 310 | { id: "make", description: "Make", packages: ["build-essential"] }, |
| 311 | { | |
| 312 | id: "libsecret", | |
| 313 | description: "libsecret development headers and pkg-config", | |
| 314 | packages: ["libsecret-1-dev", "pkg-config"], | |
| 315 | }, | |
| 311 | 316 | { id: "imagemagick", description: "ImageMagick", packages: ["imagemagick"] }, |
| 312 | 317 | ] as const; |
| 313 | 318 | |
| ... | ... | @@ -944,6 +949,10 @@ export const DESKTOP_FILE_EXCLUSIONS = [ |
| 944 | 949 | // so the SDK's optional platform packages (each a ~200MB bundled executable) |
| 945 | 950 | // are dead weight. The trailing dash keeps the SDK's own JS package. |
| 946 | 951 | "!**/node_modules/@anthropic-ai/claude-agent-sdk-*/**/*", |
| 952 | "!apps/desktop/resources/browser-secret", | |
| 953 | "!apps/desktop/resources/browser-secret/**/*", | |
| 954 | "!apps/desktop/prod-resources/browser-secret", | |
| 955 | "!apps/desktop/prod-resources/browser-secret/**/*", | |
| 947 | 956 | // Windows stages the server sidecar below prod-resources so electron-builder |
| 948 | 957 | // can copy it using project-relative extraResources matchers. Keep those |
| 949 | 958 | // staging inputs out of app.asar; they are emitted once at resources/. |
| ... | ... | @@ -1074,6 +1083,9 @@ export const DESKTOP_EXTRA_RESOURCES = [ |
| 1074 | 1083 | to: "resource-monitor", |
| 1075 | 1084 | }, |
| 1076 | 1085 | ] as const; |
| 1086 | export const LINUX_BROWSER_SECRET_EXTRA_RESOURCES = [ | |
| 1087 | { from: "apps/desktop/prod-resources/browser-secret", to: "browser-secret" }, | |
| 1088 | ] as const; | |
| 1077 | 1089 | |
| 1078 | 1090 | export interface MacPasskeySigningConfiguration { |
| 1079 | 1091 | readonly appId: string; |
| ... | ... | @@ -1730,6 +1742,10 @@ export const preflightLinuxDesktopBuild = Effect.fn("preflightLinuxDesktopBuild" |
| 1730 | 1742 | : rustTargetIsInstalled(rustTarget), |
| 1731 | 1743 | cc: desktopBuildProbeSucceeds(ChildProcess.make("cc", ["--version"]), "cc"), |
| 1732 | 1744 | make: desktopBuildProbeSucceeds(ChildProcess.make("make", ["--version"]), "make"), |
| 1745 | libsecret: desktopBuildProbeSucceeds( | |
| 1746 | ChildProcess.make("pkg-config", ["--exists", "libsecret-1"]), | |
| 1747 | "libsecret", | |
| 1748 | ), | |
| 1733 | 1749 | imagemagick: Effect.all([ |
| 1734 | 1750 | desktopBuildProbeSucceeds(ChildProcess.make("magick", ["-version"]), "magick"), |
| 1735 | 1751 | desktopBuildProbeSucceeds(ChildProcess.make("convert", ["-version"]), "convert"), |
| ... | ... | @@ -2196,6 +2212,31 @@ export const stageResourceMonitor = Effect.fn("stageResourceMonitor")(function* |
| 2196 | 2212 | } |
| 2197 | 2213 | }); |
| 2198 | 2214 | |
| 2215 | export const stageBrowserSecret = Effect.fn("stageBrowserSecret")(function* (input: { | |
| 2216 | readonly repoRoot: string; | |
| 2217 | readonly stageResourcesDir: string; | |
| 2218 | readonly platform: typeof BuildPlatform.Type; | |
| 2219 | readonly arch: typeof BuildArch.Type; | |
| 2220 | readonly verbose: boolean; | |
| 2221 | }) { | |
| 2222 | if (input.platform !== "linux") return; | |
| 2223 | const path = yield* Path.Path; | |
| 2224 | yield* runCommand( | |
| 2225 | ChildProcess.make( | |
| 2226 | "node", | |
| 2227 | [ | |
| 2228 | path.join(input.repoRoot, "apps/desktop/scripts/build-browser-secret.mjs"), | |
| 2229 | "--arch", | |
| 2230 | input.arch, | |
| 2231 | "--output", | |
| 2232 | path.join(input.stageResourcesDir, "browser-secret", "t3-browser-secret"), | |
| 2233 | ], | |
| 2234 | { cwd: input.repoRoot }, | |
| 2235 | ), | |
| 2236 | { label: "build Linux browser secret helper", verbose: input.verbose }, | |
| 2237 | ); | |
| 2238 | }); | |
| 2239 | ||
| 2199 | 2240 | function generateMacIconSet( |
| 2200 | 2241 | sourcePng: string, |
| 2201 | 2242 | targetIcns: string, |
| ... | ... | @@ -2538,6 +2579,7 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( |
| 2538 | 2579 | // hand-packed server.asar sidecar (see WINDOWS_SERVER_ASAR_RESOURCE). |
| 2539 | 2580 | extraResources: [ |
| 2540 | 2581 | ...DESKTOP_EXTRA_RESOURCES, |
| 2582 | ...(platform === "linux" ? LINUX_BROWSER_SECRET_EXTRA_RESOURCES : []), | |
| 2541 | 2583 | ...(platform === "win" ? WINDOWS_SERVER_EXTRA_RESOURCES : []), |
| 2542 | 2584 | ...(platform === "win" && wslRuntimeBundled ? WSL_RUNTIME_EXTRA_RESOURCES : []), |
| 2543 | 2585 | ], |
| ... | ... | @@ -3496,6 +3538,13 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( |
| 3496 | 3538 | arch: options.arch, |
| 3497 | 3539 | verbose: options.verbose, |
| 3498 | 3540 | }); |
| 3541 | yield* stageBrowserSecret({ | |
| 3542 | repoRoot, | |
| 3543 | stageResourcesDir, | |
| 3544 | platform: options.platform, | |
| 3545 | arch: options.arch, | |
| 3546 | verbose: options.verbose, | |
| 3547 | }); | |
| 3499 | 3548 | |
| 3500 | 3549 | yield* assertPlatformBuildResources( |
| 3501 | 3550 | options.platform, |