authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-03 16:30:21-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-03 16:30:21-07:00
log498ab9c399d5e8c3097a286be14d03238e071ac1
tree69b851eb6d1ff0ed322f73c969032c000c1c84d4
parentff5843410d44796676b06627788d94354849c4d7
signature Signed by PGP key B5690EEEBB952194

feat(desktop): resolve Chromium cookie keys on Linux (#7261)

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,6 +51,9 @@ jobs:
51 - name: Typecheck51 - name: Typecheck
52 run: vpr typecheck52 run: vpr typecheck
5353
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 - name: Build desktop pipeline57 - name: Build desktop pipeline
55 run: vp run build:desktop58 run: vp run build:desktop
5659
...@@ -85,6 +88,9 @@ jobs:...@@ -85,6 +88,9 @@ jobs:
85 - name: Ensure Electron runtime is installed88 - name: Ensure Electron runtime is installed
86 run: vp run --filter @t3tools/desktop ensure:electron89 run: vp run --filter @t3tools/desktop ensure:electron
8790
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 - name: Test94 - name: Test
89 run: vp run --parallel --concurrency-limit 4 --filter '!t3' --filter '!@t3tools/monorepo' test95 run: vp run --parallel --concurrency-limit 4 --filter '!t3' --filter '!@t3tools/monorepo' test
9096
.github/workflows/release.yml+6-2
...@@ -216,6 +216,9 @@ jobs:...@@ -216,6 +216,9 @@ jobs:
216 - name: Typecheck216 - name: Typecheck
217 run: vp run typecheck217 run: vp run typecheck
218218
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 - name: Test222 - name: Test
220 run: vp run test223 run: vp run test
221224
...@@ -524,12 +527,13 @@ jobs:...@@ -524,12 +527,13 @@ jobs:
524 exit $code527 exit $code
525 }528 }
526529
527 - name: Install ImageMagick530 - name: Install Linux desktop build libraries
528 if: matrix.platform == 'linux'531 if: matrix.platform == 'linux'
529 shell: bash532 shell: bash
530 run: |533 run: |
534 sudo apt-get update
535 sudo apt-get install -y libsecret-1-dev pkg-config
531 if ! command -v magick >/dev/null 2>&1 && ! command -v convert >/dev/null 2>&1; then536 if ! command -v magick >/dev/null 2>&1 && ! command -v convert >/dev/null 2>&1; then
532 sudo apt-get update
533 sudo apt-get install -y imagemagick537 sudo apt-get install -y imagemagick
534 fi538 fi
535539
apps/desktop/scripts/browser-secret-native.test.mjs created+103
...@@ -0,0 +1,103 @@
1import * as NodeChildProcess from "node:child_process";
2import * as NodeFS from "node:fs";
3import * as NodeOS from "node:os";
4import * as NodePath from "node:path";
5import * as NodeURL from "node:url";
6import { 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.
9const hostArch = process.arch;
10// oxlint-disable-next-line t3code/no-global-process-runtime -- Native compilation only runs on the actual Linux host.
11const hostPlatform = process.platform;
12
13describe.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 @@
1import * as NodeChildProcess from "node:child_process";
2import * as NodeFS from "node:fs";
3import * as NodePath from "node:path";
4import * as NodeURL from "node:url";
5import * 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.
8const hostArch = process.arch;
9// oxlint-disable-next-line t3code/no-global-process-runtime -- Native compilation only runs on the actual Linux host.
10const hostPlatform = process.platform;
11
12const { values } = NodeUtil.parseArgs({
13 options: { output: { type: "string" }, arch: { type: "string", default: hostArch } },
14});
15
16if (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,6 +37,12 @@ const remoteDebuggingPort = process.env.T3CODE_DESKTOP_REMOTE_DEBUGGING_PORT?.tr
37// oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone dev script has no Effect runtime.37// oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone dev script has no Effect runtime.
38const hostPlatform = NodeOS.platform();38const hostPlatform = NodeOS.platform();
3939
40NodeChildProcess.execFileSync(
41 process.execPath,
42 [NodePath.join(desktopDir, "scripts/build-browser-secret.mjs")],
43 { stdio: "inherit" },
44);
45
40await waitForResources({46await waitForResources({
41 baseDir: desktopDir,47 baseDir: desktopDir,
42 files: requiredFiles,48 files: requiredFiles,
apps/desktop/scripts/start-electron.mjs+7
...@@ -1,7 +1,14 @@...@@ -1,7 +1,14 @@
1import * as NodeChildProcess from "node:child_process";1import * as NodeChildProcess from "node:child_process";
2import * as NodePath from "node:path";
23
3import { desktopDir, resolveElectronLaunchCommand } from "./electron-launcher.mjs";4import { desktopDir, resolveElectronLaunchCommand } from "./electron-launcher.mjs";
45
6NodeChildProcess.execFileSync(
7 process.execPath,
8 [NodePath.join(desktopDir, "scripts/build-browser-secret.mjs")],
9 { stdio: "inherit" },
10);
11
5const childEnv = { ...process.env };12const childEnv = { ...process.env };
6delete childEnv.ELECTRON_RUN_AS_NODE;13delete childEnv.ELECTRON_RUN_AS_NODE;
714
apps/desktop/src/main.ts+2-1
...@@ -59,6 +59,7 @@ import * as DesktopState from "./app/DesktopState.ts";...@@ -59,6 +59,7 @@ import * as DesktopState from "./app/DesktopState.ts";
59import * as DesktopTelemetryPublisher from "./telemetry/DesktopTelemetryPublisher.ts";59import * as DesktopTelemetryPublisher from "./telemetry/DesktopTelemetryPublisher.ts";
60import * as DesktopUpdates from "./updates/DesktopUpdates.ts";60import * as DesktopUpdates from "./updates/DesktopUpdates.ts";
61import * as BrowserImport from "./preview/BrowserImport/BrowserImport.ts";61import * as BrowserImport from "./preview/BrowserImport/BrowserImport.ts";
62import * as LinuxBrowserSecret from "./preview/BrowserImport/LinuxBrowserSecret.ts";
62import * as BrowserSession from "./preview/BrowserSession.ts";63import * as BrowserSession from "./preview/BrowserSession.ts";
63import * as PreviewManager from "./preview/Manager.ts";64import * as PreviewManager from "./preview/Manager.ts";
64import * as DesktopWindow from "./window/DesktopWindow.ts";65import * as DesktopWindow from "./window/DesktopWindow.ts";
...@@ -152,7 +153,7 @@ const desktopServerExposureLayer = DesktopServerExposure.layer.pipe(...@@ -152,7 +153,7 @@ const desktopServerExposureLayer = DesktopServerExposure.layer.pipe(
152const desktopPreviewLayer = PreviewManager.layer.pipe(153const desktopPreviewLayer = PreviewManager.layer.pipe(
153 // Merged rather than provided so the IPC handlers can reach the import154 // Merged rather than provided so the IPC handlers can reach the import
154 // service alongside the manager; both sit on the same BrowserSession.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 Layer.provideMerge(BrowserSession.layer),157 Layer.provideMerge(BrowserSession.layer),
157 Layer.provideMerge(desktopFoundationLayer),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,11 +90,6 @@ const unavailableReason = Effect.fn("BrowserImport.unavailableReason")(function*
90 FileSystem.FileSystem | ChildProcessSpawner.ChildProcessSpawner90 FileSystem.FileSystem | ChildProcessSpawner.ChildProcessSpawner
91> {91> {
92 if (!definition.platforms.includes(context.platform)) return "unsupportedPlatform";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 if (!(yield* isSourceInstalled(definition, context))) return "notInstalled";93 if (!(yield* isSourceInstalled(definition, context))) return "notInstalled";
99 if (yield* isSourceRunning(definition, context)) return "browserRunning";94 if (yield* isSourceRunning(definition, context)) return "browserRunning";
100 return undefined;95 return undefined;
...@@ -259,10 +254,11 @@ export const make = Effect.gen(function* BrowserImportMake() {...@@ -259,10 +254,11 @@ export const make = Effect.gen(function* BrowserImportMake() {
259 // identifiable and each tag is handled on its own below. The success side254 // identifiable and each tag is handled on its own below. The success side
260 // is normalized to one shape too, so the skipped tally survives either255 // is normalized to one shape too, so the skipped tally survives either
261 // engine — Firefox stores plaintext, so nothing there is ever unreadable.256 // engine — Firefox stores plaintext, so nothing there is ever unreadable.
257 const userDataDirectory = definition.userDataDirectory(pathContext);
262 const read: Effect.Effect<258 const read: Effect.Effect<
263 CookieReadResult,259 CookieReadResult,
264 ChromiumCookieReadError | FirefoxCookieReadError,260 ChromiumCookieReadError | FirefoxCookieReadError,
265 FileSystem.FileSystem | Path.Path | Scope.Scope261 FileSystem.FileSystem | Path.Path | Scope.Scope | ChildProcessSpawner.ChildProcessSpawner
266 > =262 > =
267 definition.engine === "firefox"263 definition.engine === "firefox"
268 ? readFirefoxCookies(databasePath).pipe(264 ? readFirefoxCookies(databasePath).pipe(
...@@ -270,10 +266,14 @@ export const make = Effect.gen(function* BrowserImportMake() {...@@ -270,10 +266,14 @@ export const make = Effect.gen(function* BrowserImportMake() {
270 )266 )
271 : readChromiumCookies({267 : readChromiumCookies({
272 cookieDatabasePath: databasePath,268 cookieDatabasePath: databasePath,
273 // Only reached on macOS: `unavailableReason` rejects Chromium269 keychainService: definition.keychainService,
274 // elsewhere until those key stores are implemented.270 keychainAccount: definition.keychainAccount,
275 keychainService: definition.keychainService ?? "",271 linuxSecretApplication: definition.linuxSecretApplication,
276 keychainAccount: definition.keychainAccount ?? "",272 ...(platform === "win32" && userDataDirectory !== undefined
273 ? {
274 windowsLocalStatePath: pathContext.path.join(userDataDirectory, "Local State"),
275 }
276 : {}),
277 platform,277 platform,
278 });278 });
279279
apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts+216-15
...@@ -8,12 +8,32 @@ import * as Effect from "effect/Effect";...@@ -8,12 +8,32 @@ import * as Effect from "effect/Effect";
8import * as FileSystem from "effect/FileSystem";8import * as FileSystem from "effect/FileSystem";
9import * as SqlClient from "effect/unstable/sql/SqlClient";9import * as SqlClient from "effect/unstable/sql/SqlClient";
1010
11import { readChromiumCookieDatabase } from "./ChromiumCookies.ts";11import {
12 decryptChromiumValue,
13 readChromiumCookieDatabase,
14 readChromiumCookies,
15} from "./ChromiumCookies.ts";
16import { ChromiumKeyError } from "./ChromiumKeys.ts";
17import { LinuxBrowserSecretPath } from "./LinuxBrowserSecret.ts";
12import { cookieScope } from "./CookieDatabase.ts";18import { cookieScope } from "./CookieDatabase.ts";
1319
14const encryptV10 = (value: string | Buffer, key: Buffer): Uint8Array => {20const encryptChromium = (
21 prefix: "v10" | "v11",
22 value: string | Buffer,
23 key: Buffer,
24): Uint8Array => {
15 const cipher = NodeCrypto.createCipheriv("aes-128-cbc", key, Buffer.alloc(16, 0x20));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
29const encryptV10 = (value: string | Buffer, key: Buffer): Uint8Array =>
30 encryptChromium("v10", value, key);
31
32const 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};
1838
19describe("cookieScope", () => {39describe("cookieScope", () => {
...@@ -51,6 +71,87 @@ describe("cookieScope", () => {...@@ -51,6 +71,87 @@ describe("cookieScope", () => {
51});71});
5272
53describe("readChromiumCookieDatabase", () => {73describe("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 it.effect("reads plaintext, encrypted, and genuinely empty cookie values", () =>155 it.effect("reads plaintext, encrypted, and genuinely empty cookie values", () =>
55 Effect.gen(function* () {156 Effect.gen(function* () {
56 const fileSystem = yield* FileSystem.FileSystem;157 const fileSystem = yield* FileSystem.FileSystem;
...@@ -92,7 +193,7 @@ describe("readChromiumCookieDatabase", () => {...@@ -92,7 +193,7 @@ describe("readChromiumCookieDatabase", () => {
92 `;193 `;
93 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename })));194 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename })));
94195
95 const result = yield* readChromiumCookieDatabase(filename, key, "darwin");196 const result = yield* readChromiumCookieDatabase(filename, { cbcV10: key }, "darwin");
96197
97 expect(result.undecryptable).toBe(0);198 expect(result.undecryptable).toBe(0);
98 expect(result.cookies.map(({ name, value }) => ({ name, value }))).toEqual([199 expect(result.cookies.map(({ name, value }) => ({ name, value }))).toEqual([
...@@ -134,7 +235,7 @@ describe("readChromiumCookieDatabase", () => {...@@ -134,7 +235,7 @@ describe("readChromiumCookieDatabase", () => {
134 ('short.example', 'short', '', ${encryptV10("short value", key)}, '/', 0, 1, 0, 0)`;235 ('short.example', 'short', '', ${encryptV10("short value", key)}, '/', 0, 1, 0, 0)`;
135 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename })));236 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename })));
136237
137 const result = yield* readChromiumCookieDatabase(filename, key, "darwin");238 const result = yield* readChromiumCookieDatabase(filename, { cbcV10: key }, "darwin");
138239
139 expect(result.cookies.map(({ name, value }) => ({ name, value }))).toEqual([240 expect(result.cookies.map(({ name, value }) => ({ name, value }))).toEqual([
140 { name: "valid", value: "kept" },241 { name: "valid", value: "kept" },
...@@ -144,6 +245,104 @@ describe("readChromiumCookieDatabase", () => {...@@ -144,6 +245,104 @@ describe("readChromiumCookieDatabase", () => {
144 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),245 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
145 );246 );
146247
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 it.effect("preserves arbitrary long encrypted values from pre-24 schemas", () =>346 it.effect("preserves arbitrary long encrypted values from pre-24 schemas", () =>
148 Effect.gen(function* () {347 Effect.gen(function* () {
149 const fileSystem = yield* FileSystem.FileSystem;348 const fileSystem = yield* FileSystem.FileSystem;
...@@ -170,7 +369,7 @@ describe("readChromiumCookieDatabase", () => {...@@ -170,7 +369,7 @@ describe("readChromiumCookieDatabase", () => {
170 ('legacy.example', 'legacy', '', ${encryptV10(value, key)}, '/', 0, 0, 0, 0)`;369 ('legacy.example', 'legacy', '', ${encryptV10(value, key)}, '/', 0, 0, 0, 0)`;
171 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename })));370 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename })));
172371
173 const result = yield* readChromiumCookieDatabase(filename, key, "darwin");372 const result = yield* readChromiumCookieDatabase(filename, { cbcV10: key }, "darwin");
174 expect(result.cookies[0]?.value).toBe(value);373 expect(result.cookies[0]?.value).toBe(value);
175 expect(result.undecryptable).toBe(0);374 expect(result.undecryptable).toBe(0);
176 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),375 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
...@@ -192,7 +391,7 @@ describe("readChromiumCookieDatabase", () => {...@@ -192,7 +391,7 @@ describe("readChromiumCookieDatabase", () => {
192391
193 const error = yield* readChromiumCookieDatabase(392 const error = yield* readChromiumCookieDatabase(
194 filename,393 filename,
195 Buffer.from("0123456789abcdef"),394 { cbcV10: Buffer.from("0123456789abcdef") },
196 "darwin",395 "darwin",
197 ).pipe(Effect.flip);396 ).pipe(Effect.flip);
198397
...@@ -200,7 +399,7 @@ describe("readChromiumCookieDatabase", () => {...@@ -200,7 +399,7 @@ describe("readChromiumCookieDatabase", () => {
200 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),399 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
201 );400 );
202401
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 Effect.gen(function* () {403 Effect.gen(function* () {
205 const fileSystem = yield* FileSystem.FileSystem;404 const fileSystem = yield* FileSystem.FileSystem;
206 const directory = yield* fileSystem.makeTempDirectoryScoped({405 const directory = yield* fileSystem.makeTempDirectoryScoped({
...@@ -225,14 +424,16 @@ describe("readChromiumCookieDatabase", () => {...@@ -225,14 +424,16 @@ describe("readChromiumCookieDatabase", () => {
225 ('legacy.example', 'legacy', '', ${Buffer.from("legacy cleartext")}, '/', 0, 0, 0, 0)`;424 ('legacy.example', 'legacy', '', ${Buffer.from("legacy cleartext")}, '/', 0, 0, 0, 0)`;
226 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename })));425 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename })));
227426
228 const mac = yield* readChromiumCookieDatabase(filename, key, "darwin");427 // Chromium's OSCrypt returns unprefixed data as-is on both platforms
229 const linux = yield* readChromiumCookieDatabase(filename, key, "linux");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");
230432
231 expect(mac.cookies[0]?.value).toBe("legacy cleartext");433 expect(mac.cookies[0]?.value).toBe("legacy cleartext");
232 expect(mac.undecryptable).toBe(0);434 expect(mac.undecryptable).toBe(0);
233 expect(linux.cookies).toEqual([]);435 expect(linux.cookies[0]?.value).toBe("legacy cleartext");
234 expect(linux.undecryptable).toBe(1);436 expect(linux.undecryptable).toBe(0);
235 expect(linux.undecryptableHosts).toEqual(["legacy.example"]);
236 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),437 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
237 );438 );
238439
...@@ -279,8 +480,8 @@ describe("readChromiumCookieDatabase", () => {...@@ -279,8 +480,8 @@ describe("readChromiumCookieDatabase", () => {
279 ('partitioned.example', 'partitioned', 'must skip', ${new Uint8Array()}, '/', 0, 1, 0, 0, 'https://top.example')`;480 ('partitioned.example', 'partitioned', 'must skip', ${new Uint8Array()}, '/', 0, 1, 0, 0, 'https://top.example')`;
280 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: chipsFilename })));481 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: chipsFilename })));
281482
282 const legacy = yield* readChromiumCookieDatabase(legacyFilename, key, "darwin");483 const legacy = yield* readChromiumCookieDatabase(legacyFilename, { cbcV10: key }, "darwin");
283 const chips = yield* readChromiumCookieDatabase(chipsFilename, key, "darwin");484 const chips = yield* readChromiumCookieDatabase(chipsFilename, { cbcV10: key }, "darwin");
284485
285 expect(legacy.cookies.map(({ name }) => name)).toEqual(["legacy"]);486 expect(legacy.cookies.map(({ name }) => name)).toEqual(["legacy"]);
286 expect(legacy.undecryptable).toBe(0);487 expect(legacy.undecryptable).toBe(0);
apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts+181-144
...@@ -3,25 +3,34 @@...@@ -3,25 +3,34 @@
3/**3/**
4 * Chromium cookie extraction.4 * Chromium cookie extraction.
5 *5 *
6 * Reads a Chromium-family browser's cookie database and decrypts it with the6 * Reads a Chromium-family browser's cookie database and decrypts each record
7 * key the OS keychain hands us, which is the mechanism the browser itself7 * with the key its prefix calls for. Key acquisition — and the consent it
8 * uses. macOS mediates that with a per-app consent prompt, so the user8 * needs — lives in `ChromiumKeys`.
9 * explicitly approves T3 Code reading it.
10 *9 *
11 * Deliberately no fallback when the keychain says no: the alternative10 * Records whose scheme we hold no key for are skipped rather than failing the
12 * techniques exist to defeat that consent, and this feature is not worth11 * whole import: a Linux database can mix `v10` and `v11`. A partial result
13 * shipping them.12 * reported honestly is more useful than an all-or-nothing error.
14 *13 *
15 * @module ChromiumCookies14 * @module ChromiumCookies
16 */15 */
17import * as Keyring from "@napi-rs/keyring";
18import * as NodeCrypto from "node:crypto";16import * as NodeCrypto from "node:crypto";
1917
20import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";18import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
21import * as Effect from "effect/Effect";19import * as Effect from "effect/Effect";
20import * as FileSystem from "effect/FileSystem";
21import * as Path from "effect/Path";
22import * as Schema from "effect/Schema";22import * as Schema from "effect/Schema";
23import * as Scope from "effect/Scope";
23import * as SqlClient from "effect/unstable/sql/SqlClient";24import * as SqlClient from "effect/unstable/sql/SqlClient";
25import { ChildProcessSpawner } from "effect/unstable/process";
2426
27import {
28 ChromiumKeyError,
29 ChromiumKeyFailure,
30 readWindowsKey,
31 resolveChromiumKeys,
32 type ChromiumKeyMaterial,
33} from "./ChromiumKeys.ts";
25import {34import {
26 bareHost,35 bareHost,
27 cookieScope,36 cookieScope,
...@@ -30,20 +39,20 @@ import {...@@ -30,20 +39,20 @@ import {
30 type ImportedCookie,39 type ImportedCookie,
31} from "./CookieDatabase.ts";40} from "./CookieDatabase.ts";
3241
33/** macOS OSCrypt parameters. Chromium has used these since the feature landed. */42/** OSCrypt's CBC mode uses a fixed IV of 16 spaces rather than a per-record one. */
34const MAC_KEY_ITERATIONS = 1003;43const AES_CBC_IV = Buffer.alloc(16, 0x20);
35const MAC_KEY_SALT = "saltysalt";44const AES_GCM_NONCE_LENGTH = 12;
36const MAC_KEY_LENGTH = 16;45const AES_GCM_TAG_LENGTH = 16;
37/** OSCrypt uses a fixed IV of 16 spaces rather than a per-record one. */46const isChromiumKeyError = Schema.is(ChromiumKeyError);
38const AES_IV = Buffer.alloc(16, 0x20);
39const V10_PREFIX = "v10";
4047
48/**
49 * Every way the read can fail: the key failures, plus the ones this module
50 * raises itself.
51 */
41export const ChromiumCookieReadReason = Schema.Literals([52export const ChromiumCookieReadReason = Schema.Literals([
42 "needsKeychainApproval",53 // `readFailed` already comes from the key failures, so it is not repeated.
43 "keychainItemMissing",54 ...ChromiumKeyFailure.literals,
44 "browserRunning",55 "browserRunning",
45 "unsupportedPlatform",
46 "readFailed",
47]);56]);
48export type ChromiumCookieReadReason = typeof ChromiumCookieReadReason.Type;57export type ChromiumCookieReadReason = typeof ChromiumCookieReadReason.Type;
4958
...@@ -79,7 +88,6 @@ const CookieRow = Schema.Struct({...@@ -79,7 +88,6 @@ const CookieRow = Schema.Struct({
79 samesite: Schema.Number,88 samesite: Schema.Number,
80 top_frame_site_key: Schema.String,89 top_frame_site_key: Schema.String,
81});90});
82
83const decodeCookieRows = Schema.decodeUnknownEffect(Schema.Array(CookieRow));91const decodeCookieRows = Schema.decodeUnknownEffect(Schema.Array(CookieRow));
84const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0));92const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0));
85const SchemaVersion = Schema.Union([93const SchemaVersion = Schema.Union([
...@@ -106,11 +114,9 @@ const sameSiteFromColumn = (value: number): ImportedCookie["sameSite"] => {...@@ -106,11 +114,9 @@ const sameSiteFromColumn = (value: number): ImportedCookie["sameSite"] => {
106114
107/**115/**
108 * Chromium timestamps count microseconds from 1601-01-01; Electron wants116 * Chromium timestamps count microseconds from 1601-01-01; Electron wants
109 * seconds from the UNIX epoch.117 * seconds from the UNIX epoch. The microsecond value overflows JavaScript's
110 *118 * safe integer range and `node:sqlite` refuses to narrow it, so the division
111 * The microsecond value overflows JavaScript's safe integer range, and119 * happens in SQL and this only ever sees seconds.
112 * `node:sqlite` refuses to narrow it, so the division happens in SQL and this
113 * only ever sees seconds.
114 */120 */
115const WEBKIT_EPOCH_OFFSET_SECONDS = 11_644_473_600;121const WEBKIT_EPOCH_OFFSET_SECONDS = 11_644_473_600;
116const toUnixSeconds = (webkitSeconds: number): number | undefined => {122const toUnixSeconds = (webkitSeconds: number): number | undefined => {
...@@ -119,136 +125,150 @@ const toUnixSeconds = (webkitSeconds: number): number | undefined => {...@@ -119,136 +125,150 @@ const toUnixSeconds = (webkitSeconds: number): number | undefined => {
119};125};
120126
121/**127/**
122 * Reads the OSCrypt key from the login keychain.128 * Chromium >= 127 prefixes the plaintext with SHA-256 of the host key, binding
123 *129 * a cookie to its domain. Strip it when present.
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".
135 */130 */
136const readMacKeychainPassword = Effect.fn("ChromiumCookies.readMacKeychainPassword")(function* (131const stripDomainBinding = (
137 service: string,132 plaintext: Buffer,
138 account: string,133 domain: string,
139 cookieDatabasePath: string,134 schemaVersion: number,
140) {135): Buffer | null => {
141 const password = yield* Effect.try({136 if (schemaVersion < 24) return plaintext;
142 try: () => new Keyring.Entry(service, account).getPassword(),137 const domainHash = NodeCrypto.createHash("sha256").update(domain).digest();
143 catch: (cause) => {138 return plaintext.length >= 32 && plaintext.subarray(0, 32).equals(domainHash)
144 const message = String((cause as { message?: unknown } | undefined)?.message ?? "");139 ? plaintext.subarray(32)
145 // Distinguish the causes rather than telling the user to approve a140 : null;
146 // prompt when approving cannot fix the failure.141};
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});
163142
164const decryptValue = (143const decryptCbc = (
165 encrypted: Uint8Array,144 payload: Buffer,
166 key: Buffer,145 key: Buffer,
167 domain: string,146 domain: string,
168 schemaVersion: number,147 schemaVersion: number,
169 platform: NodeJS.Platform,
170): string | null => {148): string | null => {
171 const buffer = Buffer.from(encrypted);149 try {
172 const version = buffer.subarray(0, 3).toString("latin1");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};
173158
159const 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 try {166 try {
175 let plaintext: Buffer;167 const nonce = payload.subarray(0, AES_GCM_NONCE_LENGTH);
176 if (version === V10_PREFIX) {168 const ciphertext = payload.subarray(AES_GCM_NONCE_LENGTH, -AES_GCM_TAG_LENGTH);
177 const decipher = NodeCrypto.createDecipheriv("aes-128-cbc", key, AES_IV);169 const tag = payload.subarray(-AES_GCM_TAG_LENGTH);
178 decipher.setAutoPadding(true);170 const decipher = NodeCrypto.createDecipheriv("aes-256-gcm", key, nonce);
179 plaintext = Buffer.concat([decipher.update(buffer.subarray(3)), decipher.final()]);171 decipher.setAuthTag(tag);
180 } else if (platform === "darwin") {172 const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
181 // macOS OSCrypt explicitly treats an unversioned encrypted_value as173 return stripDomainBinding(plaintext, domain, schemaVersion)?.toString("utf8") ?? null;
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");
200 } catch {174 } catch {
201 return null;175 return null;
202 }176 }
203};177};
204178
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 */
184export 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/** Reads and decodes one snapshotted Chromium cookie database. */233/** Reads and decodes one snapshotted Chromium cookie database. */
206export const readChromiumCookieDatabase = Effect.fn("ChromiumCookies.readChromiumCookieDatabase")(234export const readChromiumCookieDatabase = Effect.fn("ChromiumCookies.readChromiumCookieDatabase")(
207 function* (snapshotPath: string, key: Buffer, platform: NodeJS.Platform) {235 function* (snapshotPath: string, keys: ChromiumKeyMaterial, platform: NodeJS.Platform) {
208 const rows = yield* Effect.gen(function* () {236 const result = yield* Effect.gen(function* () {
209 const sql = yield* SqlClient.SqlClient;237 const sql = yield* SqlClient.SqlClient;
210 const schemaVersion = yield* sql`select value from meta where key = 'version' limit 1`.pipe(238 const schemaVersion = yield* sql`select value from meta where key = 'version' limit 1`.pipe(
211 Effect.flatMap(decodeSchemaVersion),239 Effect.flatMap(decodeSchemaVersion),
212 Effect.map(([row]) => row.value),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 const raw =242 const raw =
217 schemaVersion >= 15243 schemaVersion >= 15
218 ? yield* sql`244 ? yield* sql`select host_key, name, value, encrypted_value, path,
219 select host_key, name, value, encrypted_value, path,245 expires_utc / 1000000 as expires_seconds, is_secure, is_httponly,
220 expires_utc / 1000000 as expires_seconds,246 samesite, top_frame_site_key from cookies`
221 is_secure, is_httponly, samesite, top_frame_site_key247 : yield* sql`select host_key, name, value, encrypted_value, path,
222 from cookies248 expires_utc / 1000000 as expires_seconds, is_secure, is_httponly,
223 `249 samesite, '' as top_frame_site_key from cookies`;
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 `;
230 return { rows: yield* decodeCookieRows(raw), schemaVersion };250 return { rows: yield* decodeCookieRows(raw), schemaVersion };
231 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: snapshotPath, readonly: true })));251 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: snapshotPath, readonly: true })));
232252
233 const cookies: ImportedCookie[] = [];253 const cookies: ImportedCookie[] = [];
234 let undecryptable = 0;254 let undecryptable = 0;
235 const undecryptableHosts = new Set<string>();255 const undecryptableHosts = new Set<string>();
236 for (const row of rows.rows) {256 for (const row of result.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.
240 if (row.top_frame_site_key !== "") {257 if (row.top_frame_site_key !== "") {
241 undecryptable += 1;258 undecryptable += 1;
242 undecryptableHosts.add(bareHost(row.host_key));259 undecryptableHosts.add(bareHost(row.host_key));
243 continue;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 const value =262 const value =
249 row.encrypted_value.length === 0263 row.encrypted_value.length === 0
250 ? row.value264 ? 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 if (value === null) {272 if (value === null) {
253 undecryptable += 1;273 undecryptable += 1;
254 undecryptableHosts.add(bareHost(row.host_key));274 undecryptableHosts.add(bareHost(row.host_key));
...@@ -268,6 +288,19 @@ export const readChromiumCookieDatabase = Effect.fn("ChromiumCookies.readChromiu...@@ -268,6 +288,19 @@ export const readChromiumCookieDatabase = Effect.fn("ChromiumCookies.readChromiu
268 sameSite: sameSiteFromColumn(row.samesite),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 return {304 return {
272 cookies,305 cookies,
273 undecryptable,306 undecryptable,
...@@ -278,35 +311,39 @@ export const readChromiumCookieDatabase = Effect.fn("ChromiumCookies.readChromiu...@@ -278,35 +311,39 @@ export const readChromiumCookieDatabase = Effect.fn("ChromiumCookies.readChromiu
278311
279export interface ChromiumCookieSource {312export interface ChromiumCookieSource {
280 readonly cookieDatabasePath: string;313 readonly cookieDatabasePath: string;
281 readonly keychainService: string;314 readonly keychainService: string | undefined;
282 readonly keychainAccount: string;315 readonly keychainAccount: string | undefined;
316 readonly linuxSecretApplication: string | undefined;
317 readonly windowsLocalStatePath?: string;
283 /** Supplied by the caller from `HostProcessPlatform` rather than read here. */318 /** Supplied by the caller from `HostProcessPlatform` rather than read here. */
284 readonly platform: NodeJS.Platform;319 readonly platform: NodeJS.Platform;
285}320}
286321
287export const readChromiumCookies = Effect.fn("ChromiumCookies.readChromiumCookies")(function* (322export const readChromiumCookies = Effect.fn("ChromiumCookies.readChromiumCookies")(function* (
288 source: ChromiumCookieSource,323 source: ChromiumCookieSource,
289) {324): Effect.fn.Return<
290 if (source.platform !== "darwin") {325 CookieReadResult,
291 // Linux (libsecret) and Windows (DPAPI, and App-Bound Encryption on326 ChromiumCookieReadError,
292 // current Chrome) each need their own key path; only macOS is implemented.327 FileSystem.FileSystem | Path.Path | Scope.Scope | ChildProcessSpawner.ChildProcessSpawner
293 return yield* new ChromiumCookieReadError({328> {
294 reason: "unsupportedPlatform",329 const keys = yield* (
295 cookieDatabasePath: source.cookieDatabasePath,330 source.platform === "win32" && source.windowsLocalStatePath
296 });331 ? readWindowsKey(source.windowsLocalStatePath).pipe(Effect.map((gcmV10) => ({ gcmV10 })))
297 }332 : resolveChromiumKeys({
298333 platform: source.platform,
299 const password = yield* readMacKeychainPassword(334 keychainService: source.keychainService,
300 source.keychainService,335 keychainAccount: source.keychainAccount,
301 source.keychainAccount,336 linuxSecretApplication: source.linuxSecretApplication,
302 source.cookieDatabasePath,337 })
303 );338 ).pipe(
304 const key = NodeCrypto.pbkdf2Sync(339 Effect.mapError(
305 password,340 (cause: ChromiumKeyError) =>
306 MAC_KEY_SALT,341 new ChromiumCookieReadError({
307 MAC_KEY_ITERATIONS,342 reason: cause.reason,
308 MAC_KEY_LENGTH,343 cookieDatabasePath: source.cookieDatabasePath,
309 "sha1",344 cause,
345 }),
346 ),
310 );347 );
311348
312 const snapshotPath = yield* snapshotCookieDatabase(source.cookieDatabasePath).pipe(349 const snapshotPath = yield* snapshotCookieDatabase(source.cookieDatabasePath).pipe(
...@@ -320,11 +357,11 @@ export const readChromiumCookies = Effect.fn("ChromiumCookies.readChromiumCookie...@@ -320,11 +357,11 @@ export const readChromiumCookies = Effect.fn("ChromiumCookies.readChromiumCookie
320 ),357 ),
321 );358 );
322359
323 return yield* readChromiumCookieDatabase(snapshotPath, key, source.platform).pipe(360 return yield* readChromiumCookieDatabase(snapshotPath, keys, source.platform).pipe(
324 Effect.mapError(361 Effect.mapError(
325 (cause) =>362 (cause) =>
326 new ChromiumCookieReadError({363 new ChromiumCookieReadError({
327 reason: "readFailed",364 reason: isChromiumKeyError(cause) ? cause.reason : "readFailed",
328 cookieDatabasePath: source.cookieDatabasePath,365 cookieDatabasePath: source.cookieDatabasePath,
329 cause,366 cause,
330 }),367 }),
apps/desktop/src/preview/BrowserImport/ChromiumKeys.test.ts created+274
...@@ -0,0 +1,274 @@
1import { describe, expect, it } from "@effect/vitest";
2import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
3import * as Deferred from "effect/Deferred";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Sink from "effect/Sink";
7import * as Stream from "effect/Stream";
8import * as PlatformError from "effect/PlatformError";
9import { ChildProcessSpawner } from "effect/unstable/process";
10
11import {
12 ChromiumKeyError,
13 decodeWindowsWrappedKey,
14 readLinuxSecret,
15 resolveChromiumKeys,
16 unwrapWindowsDpapiKey,
17} from "./ChromiumKeys.ts";
18import { LinuxBrowserSecretPath } from "./LinuxBrowserSecret.ts";
19
20type 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
29const 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
66describe("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
236describe("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 */
20import * as Keyring from "@napi-rs/keyring";
21import * as NodeCrypto from "node:crypto";
22
23import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
24
25import * as Effect from "effect/Effect";
26import * as Encoding from "effect/Encoding";
27import * as FileSystem from "effect/FileSystem";
28import * as Schema from "effect/Schema";
29import * as Stream from "effect/Stream";
30import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
31import { LinuxBrowserSecretPath } from "./LinuxBrowserSecret.ts";
32
33const KEY_SALT = "saltysalt";
34const KEY_LENGTH = 16;
35/** macOS stretches the keychain secret; Linux uses a single iteration. */
36const MAC_KEY_ITERATIONS = 1003;
37const LINUX_KEY_ITERATIONS = 1;
38/** Chromium's documented fallback passphrase when no Linux keyring is present. */
39const LINUX_FALLBACK_PASSPHRASE = "peanuts";
40
41export 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]);
49export type ChromiumKeyFailure = typeof ChromiumKeyFailure.Type;
50
51export 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 */
69export 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
86const 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 */
104const 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 */
133export 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
179const WindowsLocalState = Schema.Struct({
180 os_crypt: Schema.Struct({
181 encrypted_key: Schema.String,
182 app_bound_encrypted_key: Schema.optional(Schema.String),
183 }),
184});
185const decodeWindowsLocalState = Schema.decodeUnknownEffect(
186 Schema.fromJsonString(WindowsLocalState),
187);
188const DPAPI_PREFIX = Buffer.from("DPAPI");
189const WINDOWS_KEY_LENGTH = 32;
190const 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
197export 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. */
217export 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. */
275export 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
285export interface ChromiumKeyRequest {
286 readonly platform: NodeJS.Platform;
287 readonly keychainService: string | undefined;
288 readonly keychainAccount: string | undefined;
289 readonly linuxSecretApplication: string | undefined;
290}
291
292export 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 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Layer from "effect/Layer";
6import * as Path from "effect/Path";
7
8import * as DesktopConfig from "../../app/DesktopConfig.ts";
9import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts";
10import * as LinuxBrowserSecret from "./LinuxBrowserSecret.ts";
11
12it.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 @@
1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";
4import * as Layer from "effect/Layer";
5
6import { DesktopEnvironment } from "../../app/DesktopEnvironment.ts";
7
8/** Absolute path to the helper shipped with this desktop instance. */
9export const LinuxBrowserSecretPath = Context.Reference<string | undefined>(
10 "@t3tools/desktop/preview/BrowserImport/LinuxBrowserSecretPath",
11 { defaultValue: () => undefined },
12);
13
14export 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,10 +28,32 @@ import {
28 posixLockIsHeld,28 posixLockIsHeld,
29 listSourceProfiles,29 listSourceProfiles,
30 sourcePathContext,30 sourcePathContext,
31 windowsChromiumCookiesAreHeld,
31} from "./Sources.ts";32} from "./Sources.ts";
3233
33const helium = BROWSER_IMPORT_SOURCES.find((source) => source.id === "helium")!;34const helium = BROWSER_IMPORT_SOURCES.find((source) => source.id === "helium")!;
3435
36describe("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/** A scratch home with the source's user-data directory already created. */57/** A scratch home with the source's user-data directory already created. */
36const withSourceHome = Effect.fnUntraced(function* () {58const withSourceHome = Effect.fnUntraced(function* () {
37 const fileSystem = yield* FileSystem.FileSystem;59 const fileSystem = yield* FileSystem.FileSystem;
...@@ -83,7 +105,103 @@ const writeFirefoxCookieDatabase = (...@@ -83,7 +105,103 @@ const writeFirefoxCookieDatabase = (
83 database.close();105 database.close();
84 });106 });
85107
108describe("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
139describe("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
86describe("isSourceRunning", () => {171describe("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 it.effect("reads Chromium's dangling SingletonLock symlink as a running browser", () =>205 it.effect("reads Chromium's dangling SingletonLock symlink as a running browser", () =>
88 run(206 run(
89 Effect.gen(function* () {207 Effect.gen(function* () {
apps/desktop/src/preview/BrowserImport/Sources.ts+60-12
...@@ -6,11 +6,11 @@...@@ -6,11 +6,11 @@
6 * keeps them in plain SQLite with no key at all, so it needs no keychain and6 * keeps them in plain SQLite with no key at all, so it needs no keychain and
7 * works the same on every platform.7 * works the same on every platform.
8 *8 *
9 * Each entry pins its own paths and keychain coordinates rather than deriving9 * Each entry pins its own paths and credential-store coordinates rather than
10 * them, because the forks do not agree. Helium uses the keychain service10 * deriving them, because the forks do not agree. macOS uses service/account
11 * "Helium Storage Key" / account "Helium" where Chrome and its closer11 * pairs, while Linux Chromium uses a custom libsecret schema keyed by an
12 * relatives use "<Name> Safe Storage" / "<Name>", and the user-data directory12 * `application` attribute. The user-data directory also differs per fork and
13 * differs per fork and per platform.13 * per platform.
14 *14 *
15 * @module BrowserImportSources15 * @module BrowserImportSources
16 */16 */
...@@ -58,6 +58,8 @@ export interface BrowserImportSourceDefinition {...@@ -58,6 +58,8 @@ export interface BrowserImportSourceDefinition {
58 /** Chromium on macOS only: where the OSCrypt key lives in the keychain. */58 /** Chromium on macOS only: where the OSCrypt key lives in the keychain. */
59 readonly keychainService?: string;59 readonly keychainService?: string;
60 readonly keychainAccount?: string;60 readonly keychainAccount?: string;
61 /** Chromium's `application` attribute in the Linux libsecret schema. */
62 readonly linuxSecretApplication?: string;
61}63}
6264
63const macApplicationSupport = (65const macApplicationSupport = (
...@@ -66,10 +68,9 @@ const macApplicationSupport = (...@@ -66,10 +68,9 @@ const macApplicationSupport = (
66) => context.path.join(context.home, "Library", "Application Support", ...segments);68) => context.path.join(context.home, "Library", "Application Support", ...segments);
6769
68/**70/**
69 * One Chromium fork on macOS and Linux. The leaves differ per fork; omitting a71 * One Chromium fork. The leaves differ per fork; omitting a platform's
70 * platform's segments marks the fork as unavailable there. Windows is left out72 * segments marks the fork as unavailable there. Most Windows Chromium builds
71 * on purpose: since Chrome 127 those cookies are encrypted to the browser's own73 * use App-Bound Encryption, but forks can retain the older DPAPI-backed store.
72 * identity (App-Bound Encryption), so no other process can read them.
73 */74 */
74const chromiumSource = (input: {75const chromiumSource = (input: {
75 readonly id: BrowserImportSourceId;76 readonly id: BrowserImportSourceId;
...@@ -78,6 +79,8 @@ const chromiumSource = (input: {...@@ -78,6 +79,8 @@ const chromiumSource = (input: {
78 readonly keychainAccount: string;79 readonly keychainAccount: string;
79 readonly macSegments: ReadonlyArray<string>;80 readonly macSegments: ReadonlyArray<string>;
80 readonly linuxSegments?: ReadonlyArray<string>;81 readonly linuxSegments?: ReadonlyArray<string>;
82 readonly linuxSecretApplication?: string;
83 readonly windowsSegments?: ReadonlyArray<string>;
81}): BrowserImportSourceDefinition => ({84}): BrowserImportSourceDefinition => ({
82 id: input.id,85 id: input.id,
83 name: input.name,86 name: input.name,
...@@ -85,11 +88,20 @@ const chromiumSource = (input: {...@@ -85,11 +88,20 @@ const chromiumSource = (input: {
85 platforms: [88 platforms: [
86 "darwin" as NodeJS.Platform,89 "darwin" as NodeJS.Platform,
87 ...(input.linuxSegments ? ["linux" as NodeJS.Platform] : []),90 ...(input.linuxSegments ? ["linux" as NodeJS.Platform] : []),
91 ...(input.windowsSegments ? ["win32" as NodeJS.Platform] : []),
88 ],92 ],
89 keychainService: input.keychainService,93 keychainService: input.keychainService,
90 keychainAccount: input.keychainAccount,94 keychainAccount: input.keychainAccount,
95 ...(input.linuxSecretApplication === undefined
96 ? {}
97 : { linuxSecretApplication: input.linuxSecretApplication }),
91 userDataDirectory: (context) => {98 userDataDirectory: (context) => {
92 if (context.platform === "darwin") return macApplicationSupport(context, ...input.macSegments);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 return input.linuxSegments105 return input.linuxSegments
94 ? context.path.join(context.home, ".config", ...input.linuxSegments)106 ? context.path.join(context.home, ".config", ...input.linuxSegments)
95 : undefined;107 : undefined;
...@@ -104,6 +116,7 @@ export const BROWSER_IMPORT_SOURCES: ReadonlyArray<BrowserImportSourceDefinition...@@ -104,6 +116,7 @@ export const BROWSER_IMPORT_SOURCES: ReadonlyArray<BrowserImportSourceDefinition
104 keychainAccount: "Chrome",116 keychainAccount: "Chrome",
105 macSegments: ["Google", "Chrome"],117 macSegments: ["Google", "Chrome"],
106 linuxSegments: ["google-chrome"],118 linuxSegments: ["google-chrome"],
119 linuxSecretApplication: "chrome",
107 }),120 }),
108 chromiumSource({121 chromiumSource({
109 id: "edge",122 id: "edge",
...@@ -112,6 +125,7 @@ export const BROWSER_IMPORT_SOURCES: ReadonlyArray<BrowserImportSourceDefinition...@@ -112,6 +125,7 @@ export const BROWSER_IMPORT_SOURCES: ReadonlyArray<BrowserImportSourceDefinition
112 keychainAccount: "Microsoft Edge",125 keychainAccount: "Microsoft Edge",
113 macSegments: ["Microsoft Edge"],126 macSegments: ["Microsoft Edge"],
114 linuxSegments: ["microsoft-edge"],127 linuxSegments: ["microsoft-edge"],
128 linuxSecretApplication: "msedge",
115 }),129 }),
116 chromiumSource({130 chromiumSource({
117 id: "brave",131 id: "brave",
...@@ -120,6 +134,7 @@ export const BROWSER_IMPORT_SOURCES: ReadonlyArray<BrowserImportSourceDefinition...@@ -120,6 +134,7 @@ export const BROWSER_IMPORT_SOURCES: ReadonlyArray<BrowserImportSourceDefinition
120 keychainAccount: "Brave",134 keychainAccount: "Brave",
121 macSegments: ["BraveSoftware", "Brave-Browser"],135 macSegments: ["BraveSoftware", "Brave-Browser"],
122 linuxSegments: ["BraveSoftware", "Brave-Browser"],136 linuxSegments: ["BraveSoftware", "Brave-Browser"],
137 linuxSecretApplication: "brave",
123 }),138 }),
124 chromiumSource({139 chromiumSource({
125 id: "vivaldi",140 id: "vivaldi",
...@@ -128,6 +143,7 @@ export const BROWSER_IMPORT_SOURCES: ReadonlyArray<BrowserImportSourceDefinition...@@ -128,6 +143,7 @@ export const BROWSER_IMPORT_SOURCES: ReadonlyArray<BrowserImportSourceDefinition
128 keychainAccount: "Vivaldi",143 keychainAccount: "Vivaldi",
129 macSegments: ["Vivaldi"],144 macSegments: ["Vivaldi"],
130 linuxSegments: ["vivaldi"],145 linuxSegments: ["vivaldi"],
146 linuxSecretApplication: "vivaldi",
131 }),147 }),
132 chromiumSource({148 chromiumSource({
133 id: "opera",149 id: "opera",
...@@ -136,8 +152,9 @@ export const BROWSER_IMPORT_SOURCES: ReadonlyArray<BrowserImportSourceDefinition...@@ -136,8 +152,9 @@ export const BROWSER_IMPORT_SOURCES: ReadonlyArray<BrowserImportSourceDefinition
136 keychainAccount: "Opera",152 keychainAccount: "Opera",
137 macSegments: ["com.operasoftware.Opera"],153 macSegments: ["com.operasoftware.Opera"],
138 linuxSegments: ["opera"],154 linuxSegments: ["opera"],
155 linuxSecretApplication: "opera",
139 }),156 }),
140 // Arc and Helium ship macOS-only builds.157 // Arc has no Linux build.
141 chromiumSource({158 chromiumSource({
142 id: "arc",159 id: "arc",
143 name: "Arc",160 name: "Arc",
...@@ -151,6 +168,10 @@ export const BROWSER_IMPORT_SOURCES: ReadonlyArray<BrowserImportSourceDefinition...@@ -151,6 +168,10 @@ export const BROWSER_IMPORT_SOURCES: ReadonlyArray<BrowserImportSourceDefinition
151 keychainService: "Helium Storage Key",168 keychainService: "Helium Storage Key",
152 keychainAccount: "Helium",169 keychainAccount: "Helium",
153 macSegments: ["net.imput.helium"],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 id: "firefox",177 id: "firefox",
...@@ -544,6 +565,29 @@ const windowsLockIsHeld = Effect.fnUntraced(function* (lockPath: string) {...@@ -544,6 +565,29 @@ const windowsLockIsHeld = Effect.fnUntraced(function* (lockPath: string) {
544 );565 );
545});566});
546567
568type 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 */
575export 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 * Whether a Firefox `lock` symlink's `<ip>:[+]<pid>` target still names a592 * Whether a Firefox `lock` symlink's `<ip>:[+]<pid>` target still names a
549 * live owner. Firefox writes this symlink beside the profile while it runs and593 * 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,9 +738,13 @@ export const isSourceRunning = Effect.fn("BrowserImportSources.isSourceRunning")
694 const fileSystem = yield* FileSystem.FileSystem;738 const fileSystem = yield* FileSystem.FileSystem;
695 const root = definition.userDataDirectory(context);739 const root = definition.userDataDirectory(context);
696 if (root === undefined) return false;740 if (root === undefined) return false;
697 // Both engines leave a lock file for as long as an instance holds a profile,741 // Probe the source's own lock state rather than scanning the process table.
698 // which is far cheaper and more targeted 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 if (definition.engine !== "firefox") {744 if (definition.engine !== "firefox") {
745 if (context.platform === "win32") {
746 return yield* windowsChromiumCookiesAreHeld(definition, context);
747 }
700 const currentHost = yield* HostProcessHostname;748 const currentHost = yield* HostProcessHostname;
701 const lock = context.path.join(root, "SingletonLock");749 const lock = context.path.join(root, "SingletonLock");
702 return yield* fileSystem.readLink(lock).pipe(750 return yield* fileSystem.readLink(lock).pipe(
apps/desktop/vite.config.ts+5-3
...@@ -14,18 +14,20 @@ export default defineConfig({...@@ -14,18 +14,20 @@ export default defineConfig({
14 run: {14 run: {
15 tasks: {15 tasks: {
16 build: {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 dependsOn: ["t3#build"],19 dependsOn: ["t3#build"],
19 cache: false,20 cache: false,
20 },21 },
21 dev: {22 dev: {
22 command: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 dependsOn: ["t3#build"],25 dependsOn: ["t3#build"],
25 cache: false,26 cache: false,
26 },27 },
27 "dev:bundle": {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 cache: false,31 cache: false,
30 },32 },
31 "dev:electron": {33 "dev:electron": {
apps/web/src/components/settings/IntegrationsSettings.logic.test.ts+1
...@@ -87,6 +87,7 @@ describe("importFailureReason", () => {...@@ -87,6 +87,7 @@ describe("importFailureReason", () => {
87 it("recovers the reason token from the flattened message", () => {87 it("recovers the reason token from the flattened message", () => {
88 expect(importFailureReason(failure("browserRunning"))).toBe("browserRunning");88 expect(importFailureReason(failure("browserRunning"))).toBe("browserRunning");
89 expect(importFailureReason(failure("readFailed"))).toBe("readFailed");89 expect(importFailureReason(failure("readFailed"))).toBe("readFailed");
90 expect(importFailureReason(failure("keychainUnavailable"))).toBe("keychainUnavailable");
90 // A settings write that fails after the cookies landed is its own case,91 // A settings write that fails after the cookies landed is its own case,
91 // not a read failure over a database that was in fact read.92 // not a read failure over a database that was in fact read.
92 expect(importFailureReason(failure("profileNotSaved"))).toBe("profileNotSaved");93 expect(importFailureReason(failure("profileNotSaved"))).toBe("profileNotSaved");
apps/web/src/components/settings/browserImportWizard.logic.test.ts+1
...@@ -155,6 +155,7 @@ describe("refreshedSourceProfileDirectory", () => {...@@ -155,6 +155,7 @@ describe("refreshedSourceProfileDirectory", () => {
155describe("isRetryableReason", () => {155describe("isRetryableReason", () => {
156 it("offers a retry for failures a second attempt can clear", () => {156 it("offers a retry for failures a second attempt can clear", () => {
157 expect(isRetryableReason("needsKeychainApproval")).toBe(true);157 expect(isRetryableReason("needsKeychainApproval")).toBe(true);
158 expect(isRetryableReason("keychainUnavailable")).toBe(true);
158 expect(isRetryableReason("readFailed")).toBe(true);159 expect(isRetryableReason("readFailed")).toBe(true);
159 });160 });
160161
apps/web/src/components/settings/browserImportWizard.logic.ts+1
...@@ -133,6 +133,7 @@ export function isRetryableReason(reason: BrowserImportFailureReason): boolean {...@@ -133,6 +133,7 @@ export function isRetryableReason(reason: BrowserImportFailureReason): boolean {
133 switch (reason) {133 switch (reason) {
134 case "needsKeychainApproval":134 case "needsKeychainApproval":
135 case "keychainItemMissing":135 case "keychainItemMissing":
136 case "keychainUnavailable":
136 case "readFailed":137 case "readFailed":
137 case "sessionUnavailable":138 case "sessionUnavailable":
138 case "profileNotSaved":139 case "profileNotSaved":
docs/internals/scripts.md+13-5
...@@ -76,28 +76,36 @@ authenticated....@@ -76,28 +76,36 @@ authenticated.
7676
77### Linux AppImage prerequisites77### Linux AppImage prerequisites
7878
79Linux AppImage packaging compiles the Rust resource monitor. Install a Rust toolchain, the standard79Linux AppImage packaging compiles the Rust resource monitor and the libsecret browser import
80C/C++ build tools, and ImageMagick before running `vp run dist:desktop:linux`.80helper. Install a Rust toolchain, the standard C/C++ build tools, libsecret development headers,
81pkg-config, and ImageMagick before running `vp run dist:desktop:linux`.
8182
82Ubuntu and Debian:83Ubuntu and Debian:
8384
84```bash85```bash
85sudo apt-get update86sudo apt-get update
86sudo apt-get install cargo rustc build-essential imagemagick87sudo apt-get install cargo rustc build-essential libsecret-1-dev pkg-config imagemagick
87```88```
8889
89Fedora:90Fedora:
9091
91```bash92```bash
92sudo dnf install rust cargo gcc gcc-c++ make ImageMagick93sudo dnf install rust cargo gcc gcc-c++ make libsecret-devel pkgconf-pkg-config ImageMagick
93```94```
9495
95Arch Linux:96Arch Linux:
9697
97```bash98```bash
98sudo pacman -S rust base-devel imagemagick99sudo pacman -S rust base-devel libsecret pkgconf imagemagick
99```100```
100101
102Linux desktop development also needs the C compiler, pkg-config, and libsecret headers. Its build
103and launch commands compile `native/browser-secret/main.c` into the gitignored native build
104directory. Releases place the executable in `resources/browser-secret`, outside `app.asar`.
105The helper performs a read-only Chromium schema lookup through libsecret and writes the exact
106secret bytes to its stdout pipe. Exit codes are 2 for a missing key, 3 for denied/locked access,
107and 4 for other keyring failures; the desktop importer preserves these distinctions.
108
101The artifact script checks these capabilities before starting the web and desktop builds. If109The artifact script checks these capabilities before starting the web and desktop builds. If
102anything is unavailable, it reports every failed check together and prints the Ubuntu/Debian110anything is unavailable, it reports every failed check together and prints the Ubuntu/Debian
103installation command. The check compiles and links tiny temporary programs, so it also catches111installation 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,6 +5,13 @@ and choose a browser under **Import from**. The import copies cookies into a T3
5profile so you can use existing logins in the preview browser. Changes made afterward stay5profile so you can use existing logins in the preview browser. Changes made afterward stay
6separate from the source browser.6separate from the source browser.
77
8On Linux, Firefox profiles are discovered from both native and Snap installations. A browser8Linux discovery includes Helium and both native and Snap installations of Firefox. Windows
9appears once it has a profile with a cookie database. Close the source browser before importing;9discovery includes Firefox and Helium builds that still use Windows' standard profile
10the import wizard will prompt you if it is still running.10encryption. Other Chromium-based browsers on Windows use app-bound cookie encryption and cannot
11be imported. A browser appears once it has a profile with a cookie database. Close the source
12browser before importing; the import wizard will prompt you if it is still running.
13
14On Linux, Chromium-based browsers use your desktop keyring to protect their cookies. T3 Code
15includes the keyring reader; no separate command-line tool is needed. Allow the desktop unlock
16prompt if one appears. If the keyring cannot be accessed, T3 Code reports that failure when no
17cookies 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. */
6enum { KEY_MISSING = 2, KEY_LOCKED = 3, READ_FAILED = 4 };
7
8static 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
14int 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
6static const char *scenario;
7
8GList *__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
34gboolean __wrap_secret_item_get_locked(SecretItem *item) {
35 g_assert_true(SECRET_IS_ITEM(item));
36 return strcmp(scenario, "locked") == 0;
37}
38
39SecretValue *__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,6 +56,8 @@ export type BrowserImportUnavailableReason = typeof BrowserImportUnavailableReas
56 */56 */
57export const BrowserImportFailureReason = Schema.Literals([57export const BrowserImportFailureReason = Schema.Literals([
58 ...BrowserImportUnavailableReason.literals,58 ...BrowserImportUnavailableReason.literals,
59 /** The operating system's keyring or its bundled reader is unavailable. */
60 "keychainUnavailable",
59 /** No source registered under the requested id. */61 /** No source registered under the requested id. */
60 "unknownSource",62 "unknownSource",
61 /** The requested profile directory is not one the source reported. */63 /** The requested profile directory is not one the source reported. */
...@@ -149,6 +151,8 @@ export const BROWSER_IMPORT_UNAVAILABLE_COPY: Readonly<...@@ -149,6 +151,8 @@ export const BROWSER_IMPORT_UNAVAILABLE_COPY: Readonly<
149/** What to tell the user when an attempted import fails. */151/** What to tell the user when an attempted import fails. */
150export const BROWSER_IMPORT_FAILURE_COPY: Readonly<Record<BrowserImportFailureReason, string>> = {152export const BROWSER_IMPORT_FAILURE_COPY: Readonly<Record<BrowserImportFailureReason, string>> = {
151 ...BROWSER_IMPORT_UNAVAILABLE_COPY,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 unknownSource: "That browser is no longer available to import from.",156 unknownSource: "That browser is no longer available to import from.",
153 unknownSourceProfile: "That browser profile no longer exists.",157 unknownSourceProfile: "That browser profile no longer exists.",
154 sessionUnavailable: "The target profile could not be opened.",158 sessionUnavailable: "The target profile could not be opened.",
scripts/build-desktop-artifact.test.ts+24-5
...@@ -24,6 +24,7 @@ import {...@@ -24,6 +24,7 @@ import {
24 DESKTOP_ELECTRON_LANGUAGES,24 DESKTOP_ELECTRON_LANGUAGES,
25 DESKTOP_FILE_EXCLUSIONS,25 DESKTOP_FILE_EXCLUSIONS,
26 DESKTOP_EXTRA_RESOURCES,26 DESKTOP_EXTRA_RESOURCES,
27 LINUX_BROWSER_SECRET_EXTRA_RESOURCES,
27 MAC_FILE_EXCLUSIONS,28 MAC_FILE_EXCLUSIONS,
28 InvalidMacPasskeyRpDomainError,29 InvalidMacPasskeyRpDomainError,
29 InvalidMacPasskeyPublishableKeyError,30 InvalidMacPasskeyPublishableKeyError,
...@@ -533,12 +534,15 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {...@@ -533,12 +534,15 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
533534
534 it("limits Electron locales and excludes separately packaged resources", () => {535 it("limits Electron locales and excludes separately packaged resources", () => {
535 assert.deepStrictEqual(DESKTOP_ELECTRON_LANGUAGES, ["en-US"]);536 assert.deepStrictEqual(DESKTOP_ELECTRON_LANGUAGES, ["en-US"]);
536 // Every WSL staging input is emitted once at resources/, so adding one537 // Every platform staging input is emitted once at resources/, so adding one
537 // without its exclusion silently packs a second copy into app.asar. The538 // without its exclusion silently packs a second copy into app.asar. The
538 // snapshot below cannot catch that on its own: adding a resource and539 // snapshot below cannot catch that on its own: adding a resource and
539 // forgetting the exclusion leaves the exclusion list untouched, so it still540 // forgetting the exclusion leaves the exclusion list untouched, so it still
540 // matches. Assert the invariant first, where the failure names the culprit.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 assert.include(546 assert.include(
543 DESKTOP_FILE_EXCLUSIONS,547 DESKTOP_FILE_EXCLUSIONS,
544 `!${resource.from}`,548 `!${resource.from}`,
...@@ -548,6 +552,10 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {...@@ -548,6 +552,10 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
548552
549 assert.deepStrictEqual(DESKTOP_FILE_EXCLUSIONS, [553 assert.deepStrictEqual(DESKTOP_FILE_EXCLUSIONS, [
550 "!**/node_modules/@anthropic-ai/claude-agent-sdk-*/**/*",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 "!apps/desktop/prod-resources/windows-server",559 "!apps/desktop/prod-resources/windows-server",
552 "!apps/desktop/prod-resources/windows-server/**/*",560 "!apps/desktop/prod-resources/windows-server/**/*",
553 "!apps/desktop/prod-resources/wsl-runtime.tar.gz",561 "!apps/desktop/prod-resources/wsl-runtime.tar.gz",
...@@ -610,6 +618,11 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {...@@ -610,6 +618,11 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
610 assert.notProperty(mac, "asarUnpack");618 assert.notProperty(mac, "asarUnpack");
611 assert.notProperty(linux, "asarUnpack");619 assert.notProperty(linux, "asarUnpack");
612 assert.notProperty(win, "asarUnpack");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 assert.deepStrictEqual(win.extraResources, [626 assert.deepStrictEqual(win.extraResources, [
614 {627 {
615 from: "apps/desktop/prod-resources/resource-monitor",628 from: "apps/desktop/prod-resources/resource-monitor",
...@@ -831,7 +844,10 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {...@@ -831,7 +844,10 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
831 readonly args: ReadonlyArray<string>;844 readonly args: ReadonlyArray<string>;
832 };845 };
833 commands.push(childProcess);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 return Effect.succeed(mockProcess(fails ? 1 : 0));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,10 +858,13 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
842 );858 );
843859
844 assert.instanceOf(error, LinuxDesktopBuildPrerequisitesMissingError);860 assert.instanceOf(error, LinuxDesktopBuildPrerequisitesMissingError);
845 assert.deepStrictEqual(error.missing, ["cargo", "rust-target"]);861 assert.deepStrictEqual(error.missing, ["cargo", "rust-target", "libsecret"]);
846 assert.include(error.message, "Rust compiler and Cargo (cargo, rustc)");862 assert.include(error.message, "Rust compiler and Cargo (cargo, rustc)");
847 assert.include(error.message, "Requested Rust standard library");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 assert.include(error.message, "rustup target add aarch64-unknown-linux-gnu");868 assert.include(error.message, "rustup target add aarch64-unknown-linux-gnu");
850 assert.isTrue(869 assert.isTrue(
851 commands.some(870 commands.some(
scripts/build-desktop-artifact.ts+49
...@@ -308,6 +308,11 @@ export const LINUX_DESKTOP_BUILD_PREREQUISITES = [...@@ -308,6 +308,11 @@ export const LINUX_DESKTOP_BUILD_PREREQUISITES = [
308 { id: "rust-target", description: "Requested Rust standard library", packages: [] },308 { id: "rust-target", description: "Requested Rust standard library", packages: [] },
309 { id: "cc", description: "C/C++ build toolchain", packages: ["build-essential"] },309 { id: "cc", description: "C/C++ build toolchain", packages: ["build-essential"] },
310 { id: "make", description: "Make", packages: ["build-essential"] },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 { id: "imagemagick", description: "ImageMagick", packages: ["imagemagick"] },316 { id: "imagemagick", description: "ImageMagick", packages: ["imagemagick"] },
312] as const;317] as const;
313318
...@@ -944,6 +949,10 @@ export const DESKTOP_FILE_EXCLUSIONS = [...@@ -944,6 +949,10 @@ export const DESKTOP_FILE_EXCLUSIONS = [
944 // so the SDK's optional platform packages (each a ~200MB bundled executable)949 // so the SDK's optional platform packages (each a ~200MB bundled executable)
945 // are dead weight. The trailing dash keeps the SDK's own JS package.950 // are dead weight. The trailing dash keeps the SDK's own JS package.
946 "!**/node_modules/@anthropic-ai/claude-agent-sdk-*/**/*",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 // Windows stages the server sidecar below prod-resources so electron-builder956 // Windows stages the server sidecar below prod-resources so electron-builder
948 // can copy it using project-relative extraResources matchers. Keep those957 // can copy it using project-relative extraResources matchers. Keep those
949 // staging inputs out of app.asar; they are emitted once at resources/.958 // staging inputs out of app.asar; they are emitted once at resources/.
...@@ -1074,6 +1083,9 @@ export const DESKTOP_EXTRA_RESOURCES = [...@@ -1074,6 +1083,9 @@ export const DESKTOP_EXTRA_RESOURCES = [
1074 to: "resource-monitor",1083 to: "resource-monitor",
1075 },1084 },
1076] as const;1085] as const;
1086export const LINUX_BROWSER_SECRET_EXTRA_RESOURCES = [
1087 { from: "apps/desktop/prod-resources/browser-secret", to: "browser-secret" },
1088] as const;
10771089
1078export interface MacPasskeySigningConfiguration {1090export interface MacPasskeySigningConfiguration {
1079 readonly appId: string;1091 readonly appId: string;
...@@ -1730,6 +1742,10 @@ export const preflightLinuxDesktopBuild = Effect.fn("preflightLinuxDesktopBuild"...@@ -1730,6 +1742,10 @@ export const preflightLinuxDesktopBuild = Effect.fn("preflightLinuxDesktopBuild"
1730 : rustTargetIsInstalled(rustTarget),1742 : rustTargetIsInstalled(rustTarget),
1731 cc: desktopBuildProbeSucceeds(ChildProcess.make("cc", ["--version"]), "cc"),1743 cc: desktopBuildProbeSucceeds(ChildProcess.make("cc", ["--version"]), "cc"),
1732 make: desktopBuildProbeSucceeds(ChildProcess.make("make", ["--version"]), "make"),1744 make: desktopBuildProbeSucceeds(ChildProcess.make("make", ["--version"]), "make"),
1745 libsecret: desktopBuildProbeSucceeds(
1746 ChildProcess.make("pkg-config", ["--exists", "libsecret-1"]),
1747 "libsecret",
1748 ),
1733 imagemagick: Effect.all([1749 imagemagick: Effect.all([
1734 desktopBuildProbeSucceeds(ChildProcess.make("magick", ["-version"]), "magick"),1750 desktopBuildProbeSucceeds(ChildProcess.make("magick", ["-version"]), "magick"),
1735 desktopBuildProbeSucceeds(ChildProcess.make("convert", ["-version"]), "convert"),1751 desktopBuildProbeSucceeds(ChildProcess.make("convert", ["-version"]), "convert"),
...@@ -2196,6 +2212,31 @@ export const stageResourceMonitor = Effect.fn("stageResourceMonitor")(function*...@@ -2196,6 +2212,31 @@ export const stageResourceMonitor = Effect.fn("stageResourceMonitor")(function*
2196 }2212 }
2197});2213});
21982214
2215export 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
2199function generateMacIconSet(2240function generateMacIconSet(
2200 sourcePng: string,2241 sourcePng: string,
2201 targetIcns: string,2242 targetIcns: string,
...@@ -2538,6 +2579,7 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* (...@@ -2538,6 +2579,7 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* (
2538 // hand-packed server.asar sidecar (see WINDOWS_SERVER_ASAR_RESOURCE).2579 // hand-packed server.asar sidecar (see WINDOWS_SERVER_ASAR_RESOURCE).
2539 extraResources: [2580 extraResources: [
2540 ...DESKTOP_EXTRA_RESOURCES,2581 ...DESKTOP_EXTRA_RESOURCES,
2582 ...(platform === "linux" ? LINUX_BROWSER_SECRET_EXTRA_RESOURCES : []),
2541 ...(platform === "win" ? WINDOWS_SERVER_EXTRA_RESOURCES : []),2583 ...(platform === "win" ? WINDOWS_SERVER_EXTRA_RESOURCES : []),
2542 ...(platform === "win" && wslRuntimeBundled ? WSL_RUNTIME_EXTRA_RESOURCES : []),2584 ...(platform === "win" && wslRuntimeBundled ? WSL_RUNTIME_EXTRA_RESOURCES : []),
2543 ],2585 ],
...@@ -3496,6 +3538,13 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (...@@ -3496,6 +3538,13 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
3496 arch: options.arch,3538 arch: options.arch,
3497 verbose: options.verbose,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 });
34993548
3500 yield* assertPlatformBuildResources(3549 yield* assertPlatformBuildResources(
3501 options.platform,3550 options.platform,