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:
5151 - name: Typecheck
5252 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
5457 - name: Build desktop pipeline
5558 run: vp run build:desktop
5659
......@@ -85,6 +88,9 @@ jobs:
8588 - name: Ensure Electron runtime is installed
8689 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
8894 - name: Test
8995 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:
216216 - name: Typecheck
217217 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
219222 - name: Test
220223 run: vp run test
221224
......@@ -524,12 +527,13 @@ jobs:
524527 exit $code
525528 }
526529
527 - name: Install ImageMagick
530 - name: Install Linux desktop build libraries
528531 if: matrix.platform == 'linux'
529532 shell: bash
530533 run: |
534 sudo apt-get update
535 sudo apt-get install -y libsecret-1-dev pkg-config
531536 if ! command -v magick >/dev/null 2>&1 && ! command -v convert >/dev/null 2>&1; then
532 sudo apt-get update
533537 sudo apt-get install -y imagemagick
534538 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
3737// oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone dev script has no Effect runtime.
3838const hostPlatform = NodeOS.platform();
3939
40NodeChildProcess.execFileSync(
41 process.execPath,
42 [NodePath.join(desktopDir, "scripts/build-browser-secret.mjs")],
43 { stdio: "inherit" },
44);
45
4046await waitForResources({
4147 baseDir: desktopDir,
4248 files: requiredFiles,
apps/desktop/scripts/start-electron.mjs+7
......@@ -1,7 +1,14 @@
11import * as NodeChildProcess from "node:child_process";
2import * as NodePath from "node:path";
23
34import { 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
512const childEnv = { ...process.env };
613delete childEnv.ELECTRON_RUN_AS_NODE;
714
apps/desktop/src/main.ts+2-1
......@@ -59,6 +59,7 @@ import * as DesktopState from "./app/DesktopState.ts";
5959import * as DesktopTelemetryPublisher from "./telemetry/DesktopTelemetryPublisher.ts";
6060import * as DesktopUpdates from "./updates/DesktopUpdates.ts";
6161import * as BrowserImport from "./preview/BrowserImport/BrowserImport.ts";
62import * as LinuxBrowserSecret from "./preview/BrowserImport/LinuxBrowserSecret.ts";
6263import * as BrowserSession from "./preview/BrowserSession.ts";
6364import * as PreviewManager from "./preview/Manager.ts";
6465import * as DesktopWindow from "./window/DesktopWindow.ts";
......@@ -152,7 +153,7 @@ const desktopServerExposureLayer = DesktopServerExposure.layer.pipe(
152153const desktopPreviewLayer = PreviewManager.layer.pipe(
153154 // Merged rather than provided so the IPC handlers can reach the import
154155 // 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))),
156157 Layer.provideMerge(BrowserSession.layer),
157158 Layer.provideMerge(desktopFoundationLayer),
158159);
apps/desktop/src/preview/BrowserImport/BrowserImport.ts+10-10
......@@ -90,11 +90,6 @@ const unavailableReason = Effect.fn("BrowserImport.unavailableReason")(function*
9090 FileSystem.FileSystem | ChildProcessSpawner.ChildProcessSpawner
9191> {
9292 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 }
9893 if (!(yield* isSourceInstalled(definition, context))) return "notInstalled";
9994 if (yield* isSourceRunning(definition, context)) return "browserRunning";
10095 return undefined;
......@@ -259,10 +254,11 @@ export const make = Effect.gen(function* BrowserImportMake() {
259254 // identifiable and each tag is handled on its own below. The success side
260255 // is normalized to one shape too, so the skipped tally survives either
261256 // engine — Firefox stores plaintext, so nothing there is ever unreadable.
257 const userDataDirectory = definition.userDataDirectory(pathContext);
262258 const read: Effect.Effect<
263259 CookieReadResult,
264260 ChromiumCookieReadError | FirefoxCookieReadError,
265 FileSystem.FileSystem | Path.Path | Scope.Scope
261 FileSystem.FileSystem | Path.Path | Scope.Scope | ChildProcessSpawner.ChildProcessSpawner
266262 > =
267263 definition.engine === "firefox"
268264 ? readFirefoxCookies(databasePath).pipe(
......@@ -270,10 +266,14 @@ export const make = Effect.gen(function* BrowserImportMake() {
270266 )
271267 : readChromiumCookies({
272268 cookieDatabasePath: databasePath,
273 // Only reached on macOS: `unavailableReason` rejects Chromium
274 // elsewhere until those key stores are implemented.
275 keychainService: definition.keychainService ?? "",
276 keychainAccount: definition.keychainAccount ?? "",
269 keychainService: definition.keychainService,
270 keychainAccount: definition.keychainAccount,
271 linuxSecretApplication: definition.linuxSecretApplication,
272 ...(platform === "win32" && userDataDirectory !== undefined
273 ? {
274 windowsLocalStatePath: pathContext.path.join(userDataDirectory, "Local State"),
275 }
276 : {}),
277277 platform,
278278 });
279279
apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts+216-15
......@@ -8,12 +8,32 @@ import * as Effect from "effect/Effect";
88import * as FileSystem from "effect/FileSystem";
99import * 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";
1218import { 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 => {
1525 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()]);
1737};
1838
1939describe("cookieScope", () => {
......@@ -51,6 +71,87 @@ describe("cookieScope", () => {
5171});
5272
5373describe("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
54155 it.effect("reads plaintext, encrypted, and genuinely empty cookie values", () =>
55156 Effect.gen(function* () {
56157 const fileSystem = yield* FileSystem.FileSystem;
......@@ -92,7 +193,7 @@ describe("readChromiumCookieDatabase", () => {
92193 `;
93194 }).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
97198 expect(result.undecryptable).toBe(0);
98199 expect(result.cookies.map(({ name, value }) => ({ name, value }))).toEqual([
......@@ -134,7 +235,7 @@ describe("readChromiumCookieDatabase", () => {
134235 ('short.example', 'short', '', ${encryptV10("short value", key)}, '/', 0, 1, 0, 0)`;
135236 }).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
139240 expect(result.cookies.map(({ name, value }) => ({ name, value }))).toEqual([
140241 { name: "valid", value: "kept" },
......@@ -144,6 +245,104 @@ describe("readChromiumCookieDatabase", () => {
144245 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
145246 );
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
147346 it.effect("preserves arbitrary long encrypted values from pre-24 schemas", () =>
148347 Effect.gen(function* () {
149348 const fileSystem = yield* FileSystem.FileSystem;
......@@ -170,7 +369,7 @@ describe("readChromiumCookieDatabase", () => {
170369 ('legacy.example', 'legacy', '', ${encryptV10(value, key)}, '/', 0, 0, 0, 0)`;
171370 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename })));
172371
173 const result = yield* readChromiumCookieDatabase(filename, key, "darwin");
372 const result = yield* readChromiumCookieDatabase(filename, { cbcV10: key }, "darwin");
174373 expect(result.cookies[0]?.value).toBe(value);
175374 expect(result.undecryptable).toBe(0);
176375 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
......@@ -192,7 +391,7 @@ describe("readChromiumCookieDatabase", () => {
192391
193392 const error = yield* readChromiumCookieDatabase(
194393 filename,
195 Buffer.from("0123456789abcdef"),
394 { cbcV10: Buffer.from("0123456789abcdef") },
196395 "darwin",
197396 ).pipe(Effect.flip);
198397
......@@ -200,7 +399,7 @@ describe("readChromiumCookieDatabase", () => {
200399 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
201400 );
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", () =>
204403 Effect.gen(function* () {
205404 const fileSystem = yield* FileSystem.FileSystem;
206405 const directory = yield* fileSystem.makeTempDirectoryScoped({
......@@ -225,14 +424,16 @@ describe("readChromiumCookieDatabase", () => {
225424 ('legacy.example', 'legacy', '', ${Buffer.from("legacy cleartext")}, '/', 0, 0, 0, 0)`;
226425 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename })));
227426
228 const mac = yield* readChromiumCookieDatabase(filename, key, "darwin");
229 const linux = yield* readChromiumCookieDatabase(filename, key, "linux");
427 // Chromium's OSCrypt returns unprefixed data as-is on both platforms
428 // (os_crypt_mac.mm and os_crypt_linux.cc: "old data saved as clear
429 // text"), so neither counts it as undecryptable.
430 const mac = yield* readChromiumCookieDatabase(filename, { cbcV10: key }, "darwin");
431 const linux = yield* readChromiumCookieDatabase(filename, { cbcV10: key }, "linux");
230432
231433 expect(mac.cookies[0]?.value).toBe("legacy cleartext");
232434 expect(mac.undecryptable).toBe(0);
233 expect(linux.cookies).toEqual([]);
234 expect(linux.undecryptable).toBe(1);
235 expect(linux.undecryptableHosts).toEqual(["legacy.example"]);
435 expect(linux.cookies[0]?.value).toBe("legacy cleartext");
436 expect(linux.undecryptable).toBe(0);
236437 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
237438 );
238439
......@@ -279,8 +480,8 @@ describe("readChromiumCookieDatabase", () => {
279480 ('partitioned.example', 'partitioned', 'must skip', ${new Uint8Array()}, '/', 0, 1, 0, 0, 'https://top.example')`;
280481 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: chipsFilename })));
281482
282 const legacy = yield* readChromiumCookieDatabase(legacyFilename, key, "darwin");
283 const chips = yield* readChromiumCookieDatabase(chipsFilename, key, "darwin");
483 const legacy = yield* readChromiumCookieDatabase(legacyFilename, { cbcV10: key }, "darwin");
484 const chips = yield* readChromiumCookieDatabase(chipsFilename, { cbcV10: key }, "darwin");
284485
285486 expect(legacy.cookies.map(({ name }) => name)).toEqual(["legacy"]);
286487 expect(legacy.undecryptable).toBe(0);
apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts+181-144
......@@ -3,25 +3,34 @@
33/**
44 * Chromium cookie extraction.
55 *
6 * Reads a Chromium-family browser's cookie database and decrypts it with the
7 * key the OS keychain hands us, which is the mechanism the browser itself
8 * uses. macOS mediates that with a per-app consent prompt, so the user
9 * explicitly approves T3 Code reading it.
6 * Reads a Chromium-family browser's cookie database and decrypts each record
7 * with the key its prefix calls for. Key acquisition — and the consent it
8 * needs — lives in `ChromiumKeys`.
109 *
11 * Deliberately no fallback when the keychain says no: the alternative
12 * techniques exist to defeat that consent, and this feature is not worth
13 * shipping them.
10 * Records whose scheme we hold no key for are skipped rather than failing the
11 * whole import: a Linux database can mix `v10` and `v11`. A partial result
12 * reported honestly is more useful than an all-or-nothing error.
1413 *
1514 * @module ChromiumCookies
1615 */
17import * as Keyring from "@napi-rs/keyring";
1816import * as NodeCrypto from "node:crypto";
1917
2018import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
2119import * as Effect from "effect/Effect";
20import * as FileSystem from "effect/FileSystem";
21import * as Path from "effect/Path";
2222import * as Schema from "effect/Schema";
23import * as Scope from "effect/Scope";
2324import * 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";
2534import {
2635 bareHost,
2736 cookieScope,
......@@ -30,20 +39,20 @@ import {
3039 type ImportedCookie,
3140} from "./CookieDatabase.ts";
3241
33/** macOS OSCrypt parameters. Chromium has used these since the feature landed. */
34const MAC_KEY_ITERATIONS = 1003;
35const MAC_KEY_SALT = "saltysalt";
36const MAC_KEY_LENGTH = 16;
37/** OSCrypt uses a fixed IV of 16 spaces rather than a per-record one. */
38const AES_IV = Buffer.alloc(16, 0x20);
39const V10_PREFIX = "v10";
42/** OSCrypt's CBC mode uses a fixed IV of 16 spaces rather than a per-record one. */
43const AES_CBC_IV = Buffer.alloc(16, 0x20);
44const AES_GCM_NONCE_LENGTH = 12;
45const AES_GCM_TAG_LENGTH = 16;
46const isChromiumKeyError = Schema.is(ChromiumKeyError);
4047
48/**
49 * Every way the read can fail: the key failures, plus the ones this module
50 * raises itself.
51 */
4152export const ChromiumCookieReadReason = Schema.Literals([
42 "needsKeychainApproval",
43 "keychainItemMissing",
53 // `readFailed` already comes from the key failures, so it is not repeated.
54 ...ChromiumKeyFailure.literals,
4455 "browserRunning",
45 "unsupportedPlatform",
46 "readFailed",
4756]);
4857export type ChromiumCookieReadReason = typeof ChromiumCookieReadReason.Type;
4958
......@@ -79,7 +88,6 @@ const CookieRow = Schema.Struct({
7988 samesite: Schema.Number,
8089 top_frame_site_key: Schema.String,
8190});
82
8391const decodeCookieRows = Schema.decodeUnknownEffect(Schema.Array(CookieRow));
8492const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0));
8593const SchemaVersion = Schema.Union([
......@@ -106,11 +114,9 @@ const sameSiteFromColumn = (value: number): ImportedCookie["sameSite"] => {
106114
107115/**
108116 * Chromium timestamps count microseconds from 1601-01-01; Electron wants
109 * seconds from the UNIX epoch.
110 *
111 * The microsecond value overflows JavaScript's safe integer range, and
112 * `node:sqlite` refuses to narrow it, so the division happens in SQL and this
113 * only ever sees seconds.
117 * seconds from the UNIX epoch. The microsecond value overflows JavaScript's
118 * safe integer range and `node:sqlite` refuses to narrow it, so the division
119 * happens in SQL and this only ever sees seconds.
114120 */
115121const WEBKIT_EPOCH_OFFSET_SECONDS = 11_644_473_600;
116122const toUnixSeconds = (webkitSeconds: number): number | undefined => {
......@@ -119,136 +125,150 @@ const toUnixSeconds = (webkitSeconds: number): number | undefined => {
119125};
120126
121127/**
122 * Reads the OSCrypt key from the login keychain.
123 *
124 * Uses the in-process Keychain API rather than shelling out to
125 * `/usr/bin/security`, because the keychain attributes both the consent prompt
126 * and the resulting ACL entry to the binary that asks. Via the CLI the prompt
127 * says "security" and "Always Allow" grants trust to a tool every process on
128 * the machine can invoke; in-process it names this app and the grant belongs
129 * to it. (In an unsigned dev build the name is the dev Electron binary rather
130 * than the shipped app identity.)
131 *
132 * Deliberately untimed: macOS answers this with a modal, and a timeout racing
133 * the user means the prompt can be approved while nothing is left listening —
134 * which reads as "approving did nothing".
128 * Chromium >= 127 prefixes the plaintext with SHA-256 of the host key, binding
129 * a cookie to its domain. Strip it when present.
135130 */
136const readMacKeychainPassword = Effect.fn("ChromiumCookies.readMacKeychainPassword")(function* (
137 service: string,
138 account: string,
139 cookieDatabasePath: string,
140) {
141 const password = yield* Effect.try({
142 try: () => new Keyring.Entry(service, account).getPassword(),
143 catch: (cause) => {
144 const message = String((cause as { message?: unknown } | undefined)?.message ?? "");
145 // Distinguish the causes rather than telling the user to approve a
146 // prompt when approving cannot fix the failure.
147 const missing = /no (matching )?entry|not found/i.test(message);
148 return new ChromiumCookieReadError({
149 reason: missing ? "keychainItemMissing" : "needsKeychainApproval",
150 cookieDatabasePath,
151 cause,
152 });
153 },
154 });
155 if (password === null || password === "") {
156 return yield* new ChromiumCookieReadError({
157 reason: "keychainItemMissing",
158 cookieDatabasePath,
159 });
160 }
161 return password;
162});
131const stripDomainBinding = (
132 plaintext: Buffer,
133 domain: string,
134 schemaVersion: number,
135): Buffer | null => {
136 if (schemaVersion < 24) return plaintext;
137 const domainHash = NodeCrypto.createHash("sha256").update(domain).digest();
138 return plaintext.length >= 32 && plaintext.subarray(0, 32).equals(domainHash)
139 ? plaintext.subarray(32)
140 : null;
141};
163142
164const decryptValue = (
165 encrypted: Uint8Array,
143const decryptCbc = (
144 payload: Buffer,
166145 key: Buffer,
167146 domain: string,
168147 schemaVersion: number,
169 platform: NodeJS.Platform,
170148): string | null => {
171 const buffer = Buffer.from(encrypted);
172 const version = buffer.subarray(0, 3).toString("latin1");
149 try {
150 const decipher = NodeCrypto.createDecipheriv("aes-128-cbc", key, AES_CBC_IV);
151 decipher.setAutoPadding(true);
152 const plaintext = Buffer.concat([decipher.update(payload), decipher.final()]);
153 return stripDomainBinding(plaintext, domain, schemaVersion)?.toString("utf8") ?? null;
154 } catch {
155 return null;
156 }
157};
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;
174166 try {
175 let plaintext: Buffer;
176 if (version === V10_PREFIX) {
177 const decipher = NodeCrypto.createDecipheriv("aes-128-cbc", key, AES_IV);
178 decipher.setAutoPadding(true);
179 plaintext = Buffer.concat([decipher.update(buffer.subarray(3)), decipher.final()]);
180 } else if (platform === "darwin") {
181 // macOS OSCrypt explicitly treats an unversioned encrypted_value as
182 // legacy cleartext. This is platform-specific: Linux uses other version
183 // prefixes, which must not be widened into plaintext cookies.
184 plaintext = buffer;
185 } else {
186 return null;
187 }
188 // Cookie schema 24 requires SHA-256(host_key) at the front of every
189 // encrypted value. Treat a missing or mismatched binding as undecryptable;
190 // older schemas stored arbitrary plaintext here, including long values
191 // whose first 32 bytes must not be interpreted as a hash.
192 if (schemaVersion >= 24) {
193 const domainHash = NodeCrypto.createHash("sha256").update(domain).digest();
194 if (plaintext.length < domainHash.length || !plaintext.subarray(0, 32).equals(domainHash)) {
195 return null;
196 }
197 plaintext = plaintext.subarray(32);
198 }
199 return plaintext.toString("utf8");
167 const nonce = payload.subarray(0, AES_GCM_NONCE_LENGTH);
168 const ciphertext = payload.subarray(AES_GCM_NONCE_LENGTH, -AES_GCM_TAG_LENGTH);
169 const tag = payload.subarray(-AES_GCM_TAG_LENGTH);
170 const decipher = NodeCrypto.createDecipheriv("aes-256-gcm", key, nonce);
171 decipher.setAuthTag(tag);
172 const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
173 return stripDomainBinding(plaintext, domain, schemaVersion)?.toString("utf8") ?? null;
200174 } catch {
201175 return null;
202176 }
203177};
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
205233/** Reads and decodes one snapshotted Chromium cookie database. */
206234export const readChromiumCookieDatabase = Effect.fn("ChromiumCookies.readChromiumCookieDatabase")(
207 function* (snapshotPath: string, key: Buffer, platform: NodeJS.Platform) {
208 const rows = yield* Effect.gen(function* () {
235 function* (snapshotPath: string, keys: ChromiumKeyMaterial, platform: NodeJS.Platform) {
236 const result = yield* Effect.gen(function* () {
209237 const sql = yield* SqlClient.SqlClient;
210238 const schemaVersion = yield* sql`select value from meta where key = 'version' limit 1`.pipe(
211239 Effect.flatMap(decodeSchemaVersion),
212240 Effect.map(([row]) => row.value),
213241 );
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.
216242 const raw =
217243 schemaVersion >= 15
218 ? yield* sql`
219 select host_key, name, value, encrypted_value, path,
220 expires_utc / 1000000 as expires_seconds,
221 is_secure, is_httponly, samesite, top_frame_site_key
222 from cookies
223 `
224 : yield* sql`
225 select host_key, name, value, encrypted_value, path,
226 expires_utc / 1000000 as expires_seconds,
227 is_secure, is_httponly, samesite, '' as top_frame_site_key
228 from cookies
229 `;
244 ? yield* sql`select host_key, name, value, encrypted_value, path,
245 expires_utc / 1000000 as expires_seconds, is_secure, is_httponly,
246 samesite, top_frame_site_key from cookies`
247 : yield* sql`select host_key, name, value, encrypted_value, path,
248 expires_utc / 1000000 as expires_seconds, is_secure, is_httponly,
249 samesite, '' as top_frame_site_key from cookies`;
230250 return { rows: yield* decodeCookieRows(raw), schemaVersion };
231251 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: snapshotPath, readonly: true })));
232252
233253 const cookies: ImportedCookie[] = [];
234254 let undecryptable = 0;
235255 const undecryptableHosts = new Set<string>();
236 for (const row of rows.rows) {
237 // Electron's cookies.set contract cannot represent a CHIPS partition
238 // key. Importing this row without one would widen it into an ordinary
239 // unpartitioned cookie, so count it as skipped instead.
256 for (const row of result.rows) {
240257 if (row.top_frame_site_key !== "") {
241258 undecryptable += 1;
242259 undecryptableHosts.add(bareHost(row.host_key));
243260 continue;
244261 }
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.
248262 const value =
249263 row.encrypted_value.length === 0
250264 ? 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 );
252272 if (value === null) {
253273 undecryptable += 1;
254274 undecryptableHosts.add(bareHost(row.host_key));
......@@ -268,6 +288,19 @@ export const readChromiumCookieDatabase = Effect.fn("ChromiumCookies.readChromiu
268288 sameSite: sameSiteFromColumn(row.samesite),
269289 });
270290 }
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 }
271304 return {
272305 cookies,
273306 undecryptable,
......@@ -278,35 +311,39 @@ export const readChromiumCookieDatabase = Effect.fn("ChromiumCookies.readChromiu
278311
279312export interface ChromiumCookieSource {
280313 readonly cookieDatabasePath: string;
281 readonly keychainService: string;
282 readonly keychainAccount: string;
314 readonly keychainService: string | undefined;
315 readonly keychainAccount: string | undefined;
316 readonly linuxSecretApplication: string | undefined;
317 readonly windowsLocalStatePath?: string;
283318 /** Supplied by the caller from `HostProcessPlatform` rather than read here. */
284319 readonly platform: NodeJS.Platform;
285320}
286321
287322export const readChromiumCookies = Effect.fn("ChromiumCookies.readChromiumCookies")(function* (
288323 source: ChromiumCookieSource,
289) {
290 if (source.platform !== "darwin") {
291 // Linux (libsecret) and Windows (DPAPI, and App-Bound Encryption on
292 // current Chrome) each need their own key path; only macOS is implemented.
293 return yield* new ChromiumCookieReadError({
294 reason: "unsupportedPlatform",
295 cookieDatabasePath: source.cookieDatabasePath,
296 });
297 }
298
299 const password = yield* readMacKeychainPassword(
300 source.keychainService,
301 source.keychainAccount,
302 source.cookieDatabasePath,
303 );
304 const key = NodeCrypto.pbkdf2Sync(
305 password,
306 MAC_KEY_SALT,
307 MAC_KEY_ITERATIONS,
308 MAC_KEY_LENGTH,
309 "sha1",
324): Effect.fn.Return<
325 CookieReadResult,
326 ChromiumCookieReadError,
327 FileSystem.FileSystem | Path.Path | Scope.Scope | ChildProcessSpawner.ChildProcessSpawner
328> {
329 const keys = yield* (
330 source.platform === "win32" && source.windowsLocalStatePath
331 ? readWindowsKey(source.windowsLocalStatePath).pipe(Effect.map((gcmV10) => ({ gcmV10 })))
332 : resolveChromiumKeys({
333 platform: source.platform,
334 keychainService: source.keychainService,
335 keychainAccount: source.keychainAccount,
336 linuxSecretApplication: source.linuxSecretApplication,
337 })
338 ).pipe(
339 Effect.mapError(
340 (cause: ChromiumKeyError) =>
341 new ChromiumCookieReadError({
342 reason: cause.reason,
343 cookieDatabasePath: source.cookieDatabasePath,
344 cause,
345 }),
346 ),
310347 );
311348
312349 const snapshotPath = yield* snapshotCookieDatabase(source.cookieDatabasePath).pipe(
......@@ -320,11 +357,11 @@ export const readChromiumCookies = Effect.fn("ChromiumCookies.readChromiumCookie
320357 ),
321358 );
322359
323 return yield* readChromiumCookieDatabase(snapshotPath, key, source.platform).pipe(
360 return yield* readChromiumCookieDatabase(snapshotPath, keys, source.platform).pipe(
324361 Effect.mapError(
325362 (cause) =>
326363 new ChromiumCookieReadError({
327 reason: "readFailed",
364 reason: isChromiumKeyError(cause) ? cause.reason : "readFailed",
328365 cookieDatabasePath: source.cookieDatabasePath,
329366 cause,
330367 }),
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 {
2828 posixLockIsHeld,
2929 listSourceProfiles,
3030 sourcePathContext,
31 windowsChromiumCookiesAreHeld,
3132} from "./Sources.ts";
3233
3334const 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
3557/** A scratch home with the source's user-data directory already created. */
3658const withSourceHome = Effect.fnUntraced(function* () {
3759 const fileSystem = yield* FileSystem.FileSystem;
......@@ -83,7 +105,103 @@ const writeFirefoxCookieDatabase = (
83105 database.close();
84106 });
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
86171describe("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
87205 it.effect("reads Chromium's dangling SingletonLock symlink as a running browser", () =>
88206 run(
89207 Effect.gen(function* () {
apps/desktop/src/preview/BrowserImport/Sources.ts+60-12
......@@ -6,11 +6,11 @@
66 * keeps them in plain SQLite with no key at all, so it needs no keychain and
77 * works the same on every platform.
88 *
9 * Each entry pins its own paths and keychain coordinates rather than deriving
10 * them, because the forks do not agree. Helium uses the keychain service
11 * "Helium Storage Key" / account "Helium" where Chrome and its closer
12 * relatives use "<Name> Safe Storage" / "<Name>", and the user-data directory
13 * differs per fork and per platform.
9 * Each entry pins its own paths and credential-store coordinates rather than
10 * deriving them, because the forks do not agree. macOS uses service/account
11 * pairs, while Linux Chromium uses a custom libsecret schema keyed by an
12 * `application` attribute. The user-data directory also differs per fork and
13 * per platform.
1414 *
1515 * @module BrowserImportSources
1616 */
......@@ -58,6 +58,8 @@ export interface BrowserImportSourceDefinition {
5858 /** Chromium on macOS only: where the OSCrypt key lives in the keychain. */
5959 readonly keychainService?: string;
6060 readonly keychainAccount?: string;
61 /** Chromium's `application` attribute in the Linux libsecret schema. */
62 readonly linuxSecretApplication?: string;
6163}
6264
6365const macApplicationSupport = (
......@@ -66,10 +68,9 @@ const macApplicationSupport = (
6668) => context.path.join(context.home, "Library", "Application Support", ...segments);
6769
6870/**
69 * One Chromium fork on macOS and Linux. The leaves differ per fork; omitting a
70 * platform's segments marks the fork as unavailable there. Windows is left out
71 * on purpose: since Chrome 127 those cookies are encrypted to the browser's own
72 * identity (App-Bound Encryption), so no other process can read them.
71 * One Chromium fork. The leaves differ per fork; omitting a platform's
72 * segments marks the fork as unavailable there. Most Windows Chromium builds
73 * use App-Bound Encryption, but forks can retain the older DPAPI-backed store.
7374 */
7475const chromiumSource = (input: {
7576 readonly id: BrowserImportSourceId;
......@@ -78,6 +79,8 @@ const chromiumSource = (input: {
7879 readonly keychainAccount: string;
7980 readonly macSegments: ReadonlyArray<string>;
8081 readonly linuxSegments?: ReadonlyArray<string>;
82 readonly linuxSecretApplication?: string;
83 readonly windowsSegments?: ReadonlyArray<string>;
8184}): BrowserImportSourceDefinition => ({
8285 id: input.id,
8386 name: input.name,
......@@ -85,11 +88,20 @@ const chromiumSource = (input: {
8588 platforms: [
8689 "darwin" as NodeJS.Platform,
8790 ...(input.linuxSegments ? ["linux" as NodeJS.Platform] : []),
91 ...(input.windowsSegments ? ["win32" as NodeJS.Platform] : []),
8892 ],
8993 keychainService: input.keychainService,
9094 keychainAccount: input.keychainAccount,
95 ...(input.linuxSecretApplication === undefined
96 ? {}
97 : { linuxSecretApplication: input.linuxSecretApplication }),
9198 userDataDirectory: (context) => {
9299 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 }
93105 return input.linuxSegments
94106 ? context.path.join(context.home, ".config", ...input.linuxSegments)
95107 : undefined;
......@@ -104,6 +116,7 @@ export const BROWSER_IMPORT_SOURCES: ReadonlyArray<BrowserImportSourceDefinition
104116 keychainAccount: "Chrome",
105117 macSegments: ["Google", "Chrome"],
106118 linuxSegments: ["google-chrome"],
119 linuxSecretApplication: "chrome",
107120 }),
108121 chromiumSource({
109122 id: "edge",
......@@ -112,6 +125,7 @@ export const BROWSER_IMPORT_SOURCES: ReadonlyArray<BrowserImportSourceDefinition
112125 keychainAccount: "Microsoft Edge",
113126 macSegments: ["Microsoft Edge"],
114127 linuxSegments: ["microsoft-edge"],
128 linuxSecretApplication: "msedge",
115129 }),
116130 chromiumSource({
117131 id: "brave",
......@@ -120,6 +134,7 @@ export const BROWSER_IMPORT_SOURCES: ReadonlyArray<BrowserImportSourceDefinition
120134 keychainAccount: "Brave",
121135 macSegments: ["BraveSoftware", "Brave-Browser"],
122136 linuxSegments: ["BraveSoftware", "Brave-Browser"],
137 linuxSecretApplication: "brave",
123138 }),
124139 chromiumSource({
125140 id: "vivaldi",
......@@ -128,6 +143,7 @@ export const BROWSER_IMPORT_SOURCES: ReadonlyArray<BrowserImportSourceDefinition
128143 keychainAccount: "Vivaldi",
129144 macSegments: ["Vivaldi"],
130145 linuxSegments: ["vivaldi"],
146 linuxSecretApplication: "vivaldi",
131147 }),
132148 chromiumSource({
133149 id: "opera",
......@@ -136,8 +152,9 @@ export const BROWSER_IMPORT_SOURCES: ReadonlyArray<BrowserImportSourceDefinition
136152 keychainAccount: "Opera",
137153 macSegments: ["com.operasoftware.Opera"],
138154 linuxSegments: ["opera"],
155 linuxSecretApplication: "opera",
139156 }),
140 // Arc and Helium ship macOS-only builds.
157 // Arc has no Linux build.
141158 chromiumSource({
142159 id: "arc",
143160 name: "Arc",
......@@ -151,6 +168,10 @@ export const BROWSER_IMPORT_SOURCES: ReadonlyArray<BrowserImportSourceDefinition
151168 keychainService: "Helium Storage Key",
152169 keychainAccount: "Helium",
153170 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",
154175 }),
155176 {
156177 id: "firefox",
......@@ -544,6 +565,29 @@ const windowsLockIsHeld = Effect.fnUntraced(function* (lockPath: string) {
544565 );
545566});
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
547591/**
548592 * Whether a Firefox `lock` symlink's `<ip>:[+]<pid>` target still names a
549593 * live owner. Firefox writes this symlink beside the profile while it runs and
......@@ -694,9 +738,13 @@ export const isSourceRunning = Effect.fn("BrowserImportSources.isSourceRunning")
694738 const fileSystem = yield* FileSystem.FileSystem;
695739 const root = definition.userDataDirectory(context);
696740 if (root === undefined) return false;
697 // Both engines leave a lock file for as long as an instance holds a profile,
698 // which is far cheaper and more targeted than scanning the process table.
741 // Probe the source's own lock state rather than scanning the process table.
742 // Chromium exposes that through the cookie jar on Windows and through its
743 // user-data SingletonLock on POSIX.
699744 if (definition.engine !== "firefox") {
745 if (context.platform === "win32") {
746 return yield* windowsChromiumCookiesAreHeld(definition, context);
747 }
700748 const currentHost = yield* HostProcessHostname;
701749 const lock = context.path.join(root, "SingletonLock");
702750 return yield* fileSystem.readLink(lock).pipe(
apps/desktop/vite.config.ts+5-3
......@@ -14,18 +14,20 @@ export default defineConfig({
1414 run: {
1515 tasks: {
1616 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",
1819 dependsOn: ["t3#build"],
1920 cache: false,
2021 },
2122 dev: {
2223 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",
2425 dependsOn: ["t3#build"],
2526 cache: false,
2627 },
2728 "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",
2931 cache: false,
3032 },
3133 "dev:electron": {
apps/web/src/components/settings/IntegrationsSettings.logic.test.ts+1
......@@ -87,6 +87,7 @@ describe("importFailureReason", () => {
8787 it("recovers the reason token from the flattened message", () => {
8888 expect(importFailureReason(failure("browserRunning"))).toBe("browserRunning");
8989 expect(importFailureReason(failure("readFailed"))).toBe("readFailed");
90 expect(importFailureReason(failure("keychainUnavailable"))).toBe("keychainUnavailable");
9091 // A settings write that fails after the cookies landed is its own case,
9192 // not a read failure over a database that was in fact read.
9293 expect(importFailureReason(failure("profileNotSaved"))).toBe("profileNotSaved");
apps/web/src/components/settings/browserImportWizard.logic.test.ts+1
......@@ -155,6 +155,7 @@ describe("refreshedSourceProfileDirectory", () => {
155155describe("isRetryableReason", () => {
156156 it("offers a retry for failures a second attempt can clear", () => {
157157 expect(isRetryableReason("needsKeychainApproval")).toBe(true);
158 expect(isRetryableReason("keychainUnavailable")).toBe(true);
158159 expect(isRetryableReason("readFailed")).toBe(true);
159160 });
160161
apps/web/src/components/settings/browserImportWizard.logic.ts+1
......@@ -133,6 +133,7 @@ export function isRetryableReason(reason: BrowserImportFailureReason): boolean {
133133 switch (reason) {
134134 case "needsKeychainApproval":
135135 case "keychainItemMissing":
136 case "keychainUnavailable":
136137 case "readFailed":
137138 case "sessionUnavailable":
138139 case "profileNotSaved":
docs/internals/scripts.md+13-5
......@@ -76,28 +76,36 @@ authenticated.
7676
7777### Linux AppImage prerequisites
7878
79Linux AppImage packaging compiles the Rust resource monitor. Install a Rust toolchain, the standard
80C/C++ build tools, and ImageMagick before running `vp run dist:desktop:linux`.
79Linux AppImage packaging compiles the Rust resource monitor and the libsecret browser import
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
8283Ubuntu and Debian:
8384
8485```bash
8586sudo apt-get update
86sudo apt-get install cargo rustc build-essential imagemagick
87sudo apt-get install cargo rustc build-essential libsecret-1-dev pkg-config imagemagick
8788```
8889
8990Fedora:
9091
9192```bash
92sudo dnf install rust cargo gcc gcc-c++ make ImageMagick
93sudo dnf install rust cargo gcc gcc-c++ make libsecret-devel pkgconf-pkg-config ImageMagick
9394```
9495
9596Arch Linux:
9697
9798```bash
98sudo pacman -S rust base-devel imagemagick
99sudo pacman -S rust base-devel libsecret pkgconf imagemagick
99100```
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
101109The artifact script checks these capabilities before starting the web and desktop builds. If
102110anything is unavailable, it reports every failed check together and prints the Ubuntu/Debian
103111installation 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
55profile so you can use existing logins in the preview browser. Changes made afterward stay
66separate from the source browser.
77
8On Linux, Firefox profiles are discovered from both native and Snap installations. A browser
9appears once it has a profile with a cookie database. Close the source browser before importing;
10the import wizard will prompt you if it is still running.
8Linux discovery includes Helium and both native and Snap installations of Firefox. Windows
9discovery includes Firefox and Helium builds that still use Windows' standard profile
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
5656 */
5757export const BrowserImportFailureReason = Schema.Literals([
5858 ...BrowserImportUnavailableReason.literals,
59 /** The operating system's keyring or its bundled reader is unavailable. */
60 "keychainUnavailable",
5961 /** No source registered under the requested id. */
6062 "unknownSource",
6163 /** The requested profile directory is not one the source reported. */
......@@ -149,6 +151,8 @@ export const BROWSER_IMPORT_UNAVAILABLE_COPY: Readonly<
149151/** What to tell the user when an attempted import fails. */
150152export const BROWSER_IMPORT_FAILURE_COPY: Readonly<Record<BrowserImportFailureReason, string>> = {
151153 ...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.",
152156 unknownSource: "That browser is no longer available to import from.",
153157 unknownSourceProfile: "That browser profile no longer exists.",
154158 sessionUnavailable: "The target profile could not be opened.",
scripts/build-desktop-artifact.test.ts+24-5
......@@ -24,6 +24,7 @@ import {
2424 DESKTOP_ELECTRON_LANGUAGES,
2525 DESKTOP_FILE_EXCLUSIONS,
2626 DESKTOP_EXTRA_RESOURCES,
27 LINUX_BROWSER_SECRET_EXTRA_RESOURCES,
2728 MAC_FILE_EXCLUSIONS,
2829 InvalidMacPasskeyRpDomainError,
2930 InvalidMacPasskeyPublishableKeyError,
......@@ -533,12 +534,15 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
533534
534535 it("limits Electron locales and excludes separately packaged resources", () => {
535536 assert.deepStrictEqual(DESKTOP_ELECTRON_LANGUAGES, ["en-US"]);
536 // Every WSL staging input is emitted once at resources/, so adding one
537 // Every platform staging input is emitted once at resources/, so adding one
537538 // without its exclusion silently packs a second copy into app.asar. The
538539 // snapshot below cannot catch that on its own: adding a resource and
539540 // forgetting the exclusion leaves the exclusion list untouched, so it still
540541 // 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 ]) {
542546 assert.include(
543547 DESKTOP_FILE_EXCLUSIONS,
544548 `!${resource.from}`,
......@@ -548,6 +552,10 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
548552
549553 assert.deepStrictEqual(DESKTOP_FILE_EXCLUSIONS, [
550554 "!**/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/**/*",
551559 "!apps/desktop/prod-resources/windows-server",
552560 "!apps/desktop/prod-resources/windows-server/**/*",
553561 "!apps/desktop/prod-resources/wsl-runtime.tar.gz",
......@@ -610,6 +618,11 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
610618 assert.notProperty(mac, "asarUnpack");
611619 assert.notProperty(linux, "asarUnpack");
612620 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 ]);
613626 assert.deepStrictEqual(win.extraResources, [
614627 {
615628 from: "apps/desktop/prod-resources/resource-monitor",
......@@ -831,7 +844,10 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
831844 readonly args: ReadonlyArray<string>;
832845 };
833846 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";
835851 return Effect.succeed(mockProcess(fails ? 1 : 0));
836852 }),
837853 );
......@@ -842,10 +858,13 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
842858 );
843859
844860 assert.instanceOf(error, LinuxDesktopBuildPrerequisitesMissingError);
845 assert.deepStrictEqual(error.missing, ["cargo", "rust-target"]);
861 assert.deepStrictEqual(error.missing, ["cargo", "rust-target", "libsecret"]);
846862 assert.include(error.message, "Rust compiler and Cargo (cargo, rustc)");
847863 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 );
849868 assert.include(error.message, "rustup target add aarch64-unknown-linux-gnu");
850869 assert.isTrue(
851870 commands.some(
scripts/build-desktop-artifact.ts+49
......@@ -308,6 +308,11 @@ export const LINUX_DESKTOP_BUILD_PREREQUISITES = [
308308 { id: "rust-target", description: "Requested Rust standard library", packages: [] },
309309 { id: "cc", description: "C/C++ build toolchain", packages: ["build-essential"] },
310310 { 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 },
311316 { id: "imagemagick", description: "ImageMagick", packages: ["imagemagick"] },
312317] as const;
313318
......@@ -944,6 +949,10 @@ export const DESKTOP_FILE_EXCLUSIONS = [
944949 // so the SDK's optional platform packages (each a ~200MB bundled executable)
945950 // are dead weight. The trailing dash keeps the SDK's own JS package.
946951 "!**/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/**/*",
947956 // Windows stages the server sidecar below prod-resources so electron-builder
948957 // can copy it using project-relative extraResources matchers. Keep those
949958 // staging inputs out of app.asar; they are emitted once at resources/.
......@@ -1074,6 +1083,9 @@ export const DESKTOP_EXTRA_RESOURCES = [
10741083 to: "resource-monitor",
10751084 },
10761085] as const;
1086export const LINUX_BROWSER_SECRET_EXTRA_RESOURCES = [
1087 { from: "apps/desktop/prod-resources/browser-secret", to: "browser-secret" },
1088] as const;
10771089
10781090export interface MacPasskeySigningConfiguration {
10791091 readonly appId: string;
......@@ -1730,6 +1742,10 @@ export const preflightLinuxDesktopBuild = Effect.fn("preflightLinuxDesktopBuild"
17301742 : rustTargetIsInstalled(rustTarget),
17311743 cc: desktopBuildProbeSucceeds(ChildProcess.make("cc", ["--version"]), "cc"),
17321744 make: desktopBuildProbeSucceeds(ChildProcess.make("make", ["--version"]), "make"),
1745 libsecret: desktopBuildProbeSucceeds(
1746 ChildProcess.make("pkg-config", ["--exists", "libsecret-1"]),
1747 "libsecret",
1748 ),
17331749 imagemagick: Effect.all([
17341750 desktopBuildProbeSucceeds(ChildProcess.make("magick", ["-version"]), "magick"),
17351751 desktopBuildProbeSucceeds(ChildProcess.make("convert", ["-version"]), "convert"),
......@@ -2196,6 +2212,31 @@ export const stageResourceMonitor = Effect.fn("stageResourceMonitor")(function*
21962212 }
21972213});
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
21992240function generateMacIconSet(
22002241 sourcePng: string,
22012242 targetIcns: string,
......@@ -2538,6 +2579,7 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* (
25382579 // hand-packed server.asar sidecar (see WINDOWS_SERVER_ASAR_RESOURCE).
25392580 extraResources: [
25402581 ...DESKTOP_EXTRA_RESOURCES,
2582 ...(platform === "linux" ? LINUX_BROWSER_SECRET_EXTRA_RESOURCES : []),
25412583 ...(platform === "win" ? WINDOWS_SERVER_EXTRA_RESOURCES : []),
25422584 ...(platform === "win" && wslRuntimeBundled ? WSL_RUNTIME_EXTRA_RESOURCES : []),
25432585 ],
......@@ -3496,6 +3538,13 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
34963538 arch: options.arch,
34973539 verbose: options.verbose,
34983540 });
3541 yield* stageBrowserSecret({
3542 repoRoot,
3543 stageResourcesDir,
3544 platform: options.platform,
3545 arch: options.arch,
3546 verbose: options.verbose,
3547 });
34993548
35003549 yield* assertPlatformBuildResources(
35013550 options.platform,