# Files changed · PR \#7261 · pingdotgg/t3code

[View on GitCafe](https://git.cafe/pingdotgg/t3code/pull/7261/diffs)

Repository: [pingdotgg/t3code](https://git.cafe/pingdotgg/t3code)

Visibility: public

[feat\(desktop\): resolve Chromium cookie keys on Linux](https://git.cafe/pingdotgg/t3code/pull/7261.md)

Version: 50

Head commit: 0da299e5c45c76f1cb4fae974050aeeea5110cdd

Base commit: eea4224c0a10d6492f837f606ccadc1a25d36088

Comparison base: eea4224c0a10d6492f837f606ccadc1a25d36088

Showing up to 20 entries from this revision. If the pull request changes, reopen the first page before continuing.

## \.github/workflows/ci.yml

Status: modified; +6; −0

```diff
@@ -50,6 +50,9 @@
 
       - name: Typecheck
         run: vpr typecheck
+
+      - name: Install browser secret helper build libraries
+        run: sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config
 
       - name: Build desktop pipeline
         run: vp run build:desktop
@@ -85,6 +88,9 @@
       - name: Ensure Electron runtime is installed
         run: vp run --filter @t3tools/desktop ensure:electron
 
+      - name: Install browser secret helper build libraries
+        run: sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config
+
       - name: Test
         run: vp run --parallel --concurrency-limit 4 --filter '!t3' --filter '!@t3tools/monorepo' test
 
```

## \.github/workflows/release.yml

Status: modified; +6; −2

```diff
@@ -216,6 +216,9 @@
       - name: Typecheck
         run: vp run typecheck
 
+      - name: Install browser secret helper build libraries
+        run: sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config
+
       - name: Test
         run: vp run test
 
@@ -524,12 +527,13 @@
             exit $code
           }
 
-      - name: Install ImageMagick
+      - name: Install Linux desktop build libraries
         if: matrix.platform == 'linux'
         shell: bash
         run: |
+          sudo apt-get update
+          sudo apt-get install -y libsecret-1-dev pkg-config
           if ! command -v magick >/dev/null 2>&1 && ! command -v convert >/dev/null 2>&1; then
-              sudo apt-get update
               sudo apt-get install -y imagemagick
           fi
 
```

## apps/desktop/scripts/browser-secret-native.test.mjs

Status: added; +103; −0

```diff
@@ -1,0 +1,103 @@
+import * as NodeChildProcess from "node:child_process";
+import * as NodeFS from "node:fs";
+import * as NodeOS from "node:os";
+import * as NodePath from "node:path";
+import * as NodeURL from "node:url";
+import { afterAll, beforeAll, describe, expect, it } from "vite-plus/test";
+
+// oxlint-disable-next-line t3code/no-global-process-runtime -- The native compiler targets the actual host; this script has no Effect runtime.
+const hostArch = process.arch;
+// oxlint-disable-next-line t3code/no-global-process-runtime -- Native compilation only runs on the actual Linux host.
+const hostPlatform = process.platform;
+
+describe.skipIf(hostPlatform !== "linux")("bundled libsecret helper", () => {
+  let directory;
+  let executable;
+  beforeAll(() => {
+    directory = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-browser-secret-test-"));
+    executable = NodePath.join(directory, "t3-browser-secret");
+    const root = NodeURL.fileURLToPath(new URL("../../../native/browser-secret/", import.meta.url));
+    const flags = NodeChildProcess.execFileSync(
+      "pkg-config",
+      ["--cflags", "--libs", "libsecret-1"],
+      {
+        encoding: "utf8",
+      },
+    )
+      .trim()
+      .split(/\s+/);
+    NodeChildProcess.execFileSync(
+      process.env.CC || "cc",
+      [
+        "-std=c11",
+        "-Wall",
+        "-Wextra",
+        "-Werror",
+        NodePath.join(root, "main.c"),
+        NodePath.join(root, "test.c"),
+        "-Wl,--wrap=secret_service_search_sync",
+        "-Wl,--wrap=secret_item_get_locked",
+        "-Wl,--wrap=secret_item_get_secret",
+        "-o",
+        executable,
+        ...flags,
+      ],
+      { stdio: "pipe" },
+    );
+  });
+  afterAll(() => {
+    if (directory) NodeFS.rmSync(directory, { recursive: true, force: true });
+  });
+
+  const run = (args) =>
+    NodeChildProcess.spawnSync(executable, args, {
+      env: { ...process.env, DBUS_SESSION_BUS_ADDRESS: "unix:path=/unused-test-bus" },
+    });
+
+  it("builds an executable for the requested architecture into a staged resource directory", () => {
+    const output = NodePath.join(directory, "resources", "browser-secret", "t3-browser-secret");
+    NodeChildProcess.execFileSync(process.execPath, [
+      NodeURL.fileURLToPath(new URL("./build-browser-secret.mjs", import.meta.url)),
+      "--arch",
+      hostArch,
+      "--output",
+      output,
+    ]);
+    const header = NodeFS.readFileSync(output).subarray(0, 20);
+    expect(header.toString("hex", 0, 6)).toBe("7f454c460201");
+    expect(header.readUInt16LE(18)).toBe({ x64: 62, arm64: 183 }[hostArch]);
+    expect(NodeFS.statSync(output).mode & 0o111).not.toBe(0);
+    // Invalid arguments exit before the real executable could contact a keyring.
+    expect(NodeChildProcess.spawnSync(output, []).status).toBe(64);
+  });
+
+  it("preserves the exact secret bytes with no added or removed delimiter", () => {
+    const result = run(["success"]);
+    expect(result.status).toBe(0);
+    expect(result.stdout).toEqual(Buffer.from("secret\0with whitespace \t\r\n"));
+    expect(result.stderr.length).toBe(0);
+  });
+
+  for (const [scenario, code] of [
+    ["missing", 2],
+    ["empty", 2],
+    ["locked", 3],
+    ["cancelled", 3],
+    ["denied", 3],
+    ["unavailable", 4],
+    ["unloaded", 4],
+  ]) {
+    it(`reports ${scenario} without emitting a secret`, () => {
+      const result = run([scenario]);
+      expect(result.status).toBe(code);
+      expect(result.stdout.length).toBe(0);
+    });
+  }
+  it("rejects invalid arguments before accessing the keyring", () => {
+    for (const args of [[], [""], ["chrome", "extra"]]) {
+      const result = run(args);
+      expect(result.status).toBe(64);
+      expect(result.stdout.length).toBe(0);
+    }
+  });
+});
```

## apps/desktop/scripts/build-browser-secret.mjs

Status: added; +66; −0

```diff
@@ -1,0 +1,66 @@
+import * as NodeChildProcess from "node:child_process";
+import * as NodeFS from "node:fs";
+import * as NodePath from "node:path";
+import * as NodeURL from "node:url";
+import * as NodeUtil from "node:util";
+
+// oxlint-disable-next-line t3code/no-global-process-runtime -- The native compiler targets the actual host; this script has no Effect runtime.
+const hostArch = process.arch;
+// oxlint-disable-next-line t3code/no-global-process-runtime -- Native compilation only runs on the actual Linux host.
+const hostPlatform = process.platform;
+
+const { values } = NodeUtil.parseArgs({
+  options: { output: { type: "string" }, arch: { type: "string", default: hostArch } },
+});
+
+if (hostPlatform === "linux") {
+  const machine = { x64: 62, arm64: 183 }[values.arch];
+  if (machine === undefined) throw new Error(`Unsupported Linux architecture: ${values.arch}`);
+  const root = NodeURL.fileURLToPath(new URL("../../../native/browser-secret/", import.meta.url));
+  const source = NodePath.resolve(root, "main.c");
+  const output = values.output ?? NodePath.resolve(root, "build", values.arch, "t3-browser-secret");
+  const matchesArchitecture = (file) => {
+    const header = NodeFS.readFileSync(file).subarray(0, 20);
+    return header.toString("hex", 0, 6) === "7f454c460201" && header.readUInt16LE(18) === machine;
+  };
+  let current = false;
+  try {
+    current =
+      NodeFS.statSync(output).mtimeMs >=
+        Math.max(
+          NodeFS.statSync(source).mtimeMs,
+          NodeFS.statSync(NodeURL.fileURLToPath(import.meta.url)).mtimeMs,
+        ) && matchesArchitecture(output);
+  } catch {
+    /* The first build has no output yet. */
+  }
+  if (!current) {
+    let flags;
+    try {
+      flags = NodeChildProcess.execFileSync("pkg-config", ["--cflags", "--libs", "libsecret-1"], {
+        encoding: "utf8",
+      })
+        .trim()
+        .split(/\s+/);
+    } catch (cause) {
+      throw new Error(
+        "Building the Linux browser import helper requires pkg-config and libsecret development headers (Ubuntu/Debian: libsecret-1-dev).",
+        { cause },
+      );
+    }
+    NodeFS.mkdirSync(NodePath.dirname(output), { recursive: true });
+    const temporary = `${output}.${process.pid}.tmp`;
+    try {
+      NodeChildProcess.execFileSync(
+        process.env.CC || "cc",
+        ["-std=c11", "-O2", "-Wall", "-Wextra", "-Werror", source, "-o", temporary, ...flags],
+        { stdio: "inherit" },
+      );
+      if (!matchesArchitecture(temporary))
+        throw new Error(`C compiler did not produce a Linux ${values.arch} executable.`);
+      NodeFS.renameSync(temporary, output);
+    } finally {
+      NodeFS.rmSync(temporary, { force: true });
+    }
+  }
+}
```

## apps/desktop/scripts/dev-electron.mjs

Status: modified; +6; −0

```diff
@@ -37,6 +37,12 @@
 // oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone dev script has no Effect runtime.
 const hostPlatform = NodeOS.platform();
 
+NodeChildProcess.execFileSync(
+  process.execPath,
+  [NodePath.join(desktopDir, "scripts/build-browser-secret.mjs")],
+  { stdio: "inherit" },
+);
+
 await waitForResources({
   baseDir: desktopDir,
   files: requiredFiles,
```

## apps/desktop/scripts/start-electron.mjs

Status: modified; +7; −0

```diff
@@ -1,7 +1,14 @@
 import * as NodeChildProcess from "node:child_process";
+import * as NodePath from "node:path";
 
 import { desktopDir, resolveElectronLaunchCommand } from "./electron-launcher.mjs";
 
+NodeChildProcess.execFileSync(
+  process.execPath,
+  [NodePath.join(desktopDir, "scripts/build-browser-secret.mjs")],
+  { stdio: "inherit" },
+);
+
 const childEnv = { ...process.env };
 delete childEnv.ELECTRON_RUN_AS_NODE;
 
```

## apps/desktop/src/main.ts

Status: modified; +2; −1

```diff
@@ -59,6 +59,7 @@
 import * as DesktopTelemetryPublisher from "./telemetry/DesktopTelemetryPublisher.ts";
 import * as DesktopUpdates from "./updates/DesktopUpdates.ts";
 import * as BrowserImport from "./preview/BrowserImport/BrowserImport.ts";
+import * as LinuxBrowserSecret from "./preview/BrowserImport/LinuxBrowserSecret.ts";
 import * as BrowserSession from "./preview/BrowserSession.ts";
 import * as PreviewManager from "./preview/Manager.ts";
 import * as DesktopWindow from "./window/DesktopWindow.ts";
@@ -152,7 +153,7 @@
 const desktopPreviewLayer = PreviewManager.layer.pipe(
   // Merged rather than provided so the IPC handlers can reach the import
   // service alongside the manager; both sit on the same BrowserSession.
+  Layer.provideMerge(BrowserImport.layer.pipe(Layer.provide(LinuxBrowserSecret.layer))),
-  Layer.provideMerge(BrowserImport.layer),
   Layer.provideMerge(BrowserSession.layer),
   Layer.provideMerge(desktopFoundationLayer),
 );
```

## apps/desktop/src/preview/BrowserImport/BrowserImport.ts

Status: modified; +10; −10

```diff
@@ -90,11 +90,6 @@
   FileSystem.FileSystem | ChildProcessSpawner.ChildProcessSpawner
 > {
   if (!definition.platforms.includes(context.platform)) return "unsupportedPlatform";
-  // Chromium's key lives in an OS credential store, and only the macOS one is
-  // implemented; Firefox needs no key at all, so it works everywhere.
-  if (definition.engine === "chromium" && context.platform !== "darwin") {
-    return "unsupportedPlatform";
-  }
   if (!(yield* isSourceInstalled(definition, context))) return "notInstalled";
   if (yield* isSourceRunning(definition, context)) return "browserRunning";
   return undefined;
@@ -259,10 +254,11 @@
     // identifiable and each tag is handled on its own below. The success side
     // is normalized to one shape too, so the skipped tally survives either
     // engine — Firefox stores plaintext, so nothing there is ever unreadable.
+    const userDataDirectory = definition.userDataDirectory(pathContext);
     const read: Effect.Effect<
       CookieReadResult,
       ChromiumCookieReadError | FirefoxCookieReadError,
+      FileSystem.FileSystem | Path.Path | Scope.Scope | ChildProcessSpawner.ChildProcessSpawner
-      FileSystem.FileSystem | Path.Path | Scope.Scope
     > =
       definition.engine === "firefox"
         ? readFirefoxCookies(databasePath).pipe(
@@ -270,10 +266,14 @@
           )
         : readChromiumCookies({
             cookieDatabasePath: databasePath,
-            // Only reached on macOS: `unavailableReason` rejects Chromium
-            // elsewhere until those key stores are implemented.
+            keychainService: definition.keychainService,
+            keychainAccount: definition.keychainAccount,
+            linuxSecretApplication: definition.linuxSecretApplication,
-            keychainService: definition.keychainService ?? "",
-            keychainAccount: definition.keychainAccount ?? "",
+            ...(platform === "win32" && userDataDirectory !== undefined
+              ? {
+                  windowsLocalStatePath: pathContext.path.join(userDataDirectory, "Local State"),
+                }
+              : {}),
             platform,
           });
 
```

## apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts

Status: modified; +216; −15

```diff
@@ -8,14 +8,34 @@
 import * as FileSystem from "effect/FileSystem";
 import * as SqlClient from "effect/unstable/sql/SqlClient";
 
-import { readChromiumCookieDatabase } from "./ChromiumCookies.ts";
+import {
+  decryptChromiumValue,
+  readChromiumCookieDatabase,
+  readChromiumCookies,
+} from "./ChromiumCookies.ts";
+import { ChromiumKeyError } from "./ChromiumKeys.ts";
+import { LinuxBrowserSecretPath } from "./LinuxBrowserSecret.ts";
 import { cookieScope } from "./CookieDatabase.ts";
 
+const encryptChromium = (
+  prefix: "v10" | "v11",
+  value: string | Buffer,
+  key: Buffer,
-const encryptV10 = (value: string | Buffer, key: Buffer): Uint8Array => {
+): Uint8Array => {
   const cipher = NodeCrypto.createCipheriv("aes-128-cbc", key, Buffer.alloc(16, 0x20));
+  return Buffer.concat([Buffer.from(prefix), cipher.update(value), cipher.final()]);
-  return Buffer.concat([Buffer.from("v10"), cipher.update(value), cipher.final()]);
 };
 
+const encryptV10 = (value: string | Buffer, key: Buffer): Uint8Array =>
+  encryptChromium("v10", value, key);
+
+const encryptWindowsV10 = (value: string | Buffer, key: Buffer): Uint8Array => {
+  const nonce = Buffer.from("0123456789ab");
+  const cipher = NodeCrypto.createCipheriv("aes-256-gcm", key, nonce);
+  const encrypted = Buffer.concat([cipher.update(value), cipher.final()]);
+  return Buffer.concat([Buffer.from("v10"), nonce, encrypted, cipher.getAuthTag()]);
+};
+
 describe("cookieScope", () => {
   it("keeps a host-only cookie host-only", () => {
     // Chromium stores a host-only cookie without a leading dot. Passing any
@@ -51,6 +71,87 @@
 });
 
 describe("readChromiumCookieDatabase", () => {
+  it("decrypts Windows v10 AES-GCM records and rejects app-bound v20 records", () => {
+    const key = Buffer.from("0123456789abcdef0123456789abcdef");
+    const host = ".example.test";
+    const bound = Buffer.concat([
+      NodeCrypto.createHash("sha256").update(host).digest(),
+      Buffer.from("windows value"),
+    ]);
+
+    expect(
+      decryptChromiumValue(encryptWindowsV10(bound, key), { gcmV10: key }, host, 24, "win32"),
+    ).toBe("windows value");
+    expect(
+      decryptChromiumValue(Buffer.from("v20app-bound"), { gcmV10: key }, host, 24, "win32"),
+    ).toBeNull();
+    expect(
+      decryptChromiumValue(
+        encryptWindowsV10(bound, Buffer.alloc(32, 1)),
+        { gcmV10: key },
+        host,
+        24,
+        "win32",
+      ),
+    ).toBeNull();
+  });
+
+  it.effect(
+    "reports the missing key when no cookies can be read, while preserving partial imports",
+    () =>
+      Effect.gen(function* () {
+        const fileSystem = yield* FileSystem.FileSystem;
+        const directory = yield* fileSystem.makeTempDirectoryScoped({
+          prefix: "t3code-missing-key-",
+        });
+        const filename = `${directory}/Cookies`;
+        const key = Buffer.from("0123456789abcdef");
+        yield* Effect.gen(function* () {
+          const sql = yield* SqlClient.SqlClient;
+          yield* sql`create table meta (key text primary key, value text not null)`;
+          yield* sql`insert into meta values ('version', 23)`;
+          yield* sql`create table cookies (
+          host_key text not null, name text not null, value text not null,
+          encrypted_value blob not null, path text not null, expires_utc integer not null,
+          is_secure integer not null, is_httponly integer not null, samesite integer not null,
+          top_frame_site_key text not null default ''
+        )`;
+          yield* sql`insert into cookies values ('v11.example', 'session', '', ${encryptChromium("v11", "secret", key)}, '/', 0, 1, 1, 1, '')`;
+        }).pipe(Effect.provide(NodeSqliteClient.layer({ filename })));
+
+        const error = yield* readChromiumCookies({
+          cookieDatabasePath: filename,
+          platform: "linux",
+          linuxSecretApplication: "chromium",
+          keychainService: undefined,
+          keychainAccount: undefined,
+        }).pipe(Effect.provideService(LinuxBrowserSecretPath, undefined), Effect.flip);
+        expect(error.reason).toBe("keychainUnavailable");
+
+        yield* Effect.gen(function* () {
+          const sql = yield* SqlClient.SqlClient;
+          yield* sql`insert into cookies values ('v10.example', 'readable', '', ${encryptV10("kept", key)}, '/', 0, 1, 1, 1, '')`;
+        }).pipe(Effect.provide(NodeSqliteClient.layer({ filename })));
+        const keys = {
+          cbcV10: key,
+          cbcV11Error: new ChromiumKeyError({ reason: "keychainUnavailable" }),
+        };
+        const partial = yield* readChromiumCookieDatabase(filename, keys, "linux");
+        expect(partial.cookies.map((cookie) => cookie.value)).toEqual(["kept"]);
+        expect(partial.undecryptable).toBe(1);
+
+        // A partitioned-only jar does not need its key: it is skipped separately.
+        yield* Effect.gen(function* () {
+          const sql = yield* SqlClient.SqlClient;
+          yield* sql`delete from cookies where name = 'readable'`;
+          yield* sql`update cookies set top_frame_site_key = 'https://top.example'`;
+        }).pipe(Effect.provide(NodeSqliteClient.layer({ filename })));
+        const partitioned = yield* readChromiumCookieDatabase(filename, keys, "linux");
+        expect(partitioned.cookies).toEqual([]);
+        expect(partitioned.undecryptable).toBe(1);
+      }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
+  );
+
   it.effect("reads plaintext, encrypted, and genuinely empty cookie values", () =>
     Effect.gen(function* () {
       const fileSystem = yield* FileSystem.FileSystem;
@@ -92,7 +193,7 @@
         `;
       }).pipe(Effect.provide(NodeSqliteClient.layer({ filename })));
 
+      const result = yield* readChromiumCookieDatabase(filename, { cbcV10: key }, "darwin");
-      const result = yield* readChromiumCookieDatabase(filename, key, "darwin");
 
       expect(result.undecryptable).toBe(0);
       expect(result.cookies.map(({ name, value }) => ({ name, value }))).toEqual([
@@ -134,7 +235,7 @@
           ('short.example', 'short', '', ${encryptV10("short value", key)}, '/', 0, 1, 0, 0)`;
       }).pipe(Effect.provide(NodeSqliteClient.layer({ filename })));
 
-      const result = yield* readChromiumCookieDatabase(filename, key, "darwin");
+      const result = yield* readChromiumCookieDatabase(filename, { cbcV10: key }, "darwin");
 
       expect(result.cookies.map(({ name, value }) => ({ name, value }))).toEqual([
         { name: "valid", value: "kept" },
@@ -144,6 +245,104 @@
     }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
   );
 
+  it.effect("decrypts mixed v10 and v11 cookies with their respective keys", () =>
+    Effect.gen(function* () {
+      const fileSystem = yield* FileSystem.FileSystem;
+      const directory = yield* fileSystem.makeTempDirectoryScoped({
+        prefix: "t3code-chromium-cookies-",
+      });
+      const filename = `${directory}/Cookies`;
+      const cbcV10 = Buffer.from("0123456789abcdef");
+      const cbcV11 = Buffer.from("fedcba9876543210");
+      const boundValue = (host: string, value: string) =>
+        Buffer.concat([NodeCrypto.createHash("sha256").update(host).digest(), Buffer.from(value)]);
+
+      yield* Effect.gen(function* () {
+        const sql = yield* SqlClient.SqlClient;
+        yield* sql`create table meta (key text primary key, value text not null)`;
+        yield* sql`insert into meta values ('version', '24')`;
+        yield* sql`
+          create table cookies (
+            host_key text not null, name text not null, value text not null,
+            encrypted_value blob not null, path text not null, expires_utc integer not null,
+            is_secure integer not null, is_httponly integer not null, samesite integer not null,
+            top_frame_site_key text not null default ''
+          )
+        `;
+        yield* sql`insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values
+          ('v10.example', 'v10-cookie', '', ${encryptChromium("v10", boundValue("v10.example", "v10 value"), cbcV10)}, '/', 0, 1, 0, 0)`;
+        yield* sql`insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values
+          ('v11.example', 'v11-cookie', '', ${encryptChromium("v11", boundValue("v11.example", "v11 value"), cbcV11)}, '/', 0, 1, 0, 0)`;
+      }).pipe(Effect.provide(NodeSqliteClient.layer({ filename })));
+
+      const complete = yield* readChromiumCookieDatabase(filename, { cbcV10, cbcV11 }, "linux");
+      expect(complete.cookies.map(({ name, value }) => ({ name, value }))).toEqual([
+        { name: "v10-cookie", value: "v10 value" },
+        { name: "v11-cookie", value: "v11 value" },
+      ]);
+      expect(complete.undecryptable).toBe(0);
+
+      const v10Only = yield* readChromiumCookieDatabase(filename, { cbcV10 }, "linux");
+      expect(v10Only.cookies.map(({ name, value }) => ({ name, value }))).toEqual([
+        { name: "v10-cookie", value: "v10 value" },
+      ]);
+      expect(v10Only.undecryptable).toBe(1);
+      expect(v10Only.undecryptableHosts).toEqual(["v11.example"]);
+    }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
+  );
+
+  it.effect("recovers records written with the empty-passphrase key", () =>
+    Effect.gen(function* () {
+      const fileSystem = yield* FileSystem.FileSystem;
+      const directory = yield* fileSystem.makeTempDirectoryScoped({
+        prefix: "t3code-chromium-cookies-",
+      });
+      const filename = `${directory}/Cookies`;
+      const cbcV10 = Buffer.from("0123456789abcdef");
+      const cbcV11 = Buffer.from("fedcba9876543210");
+      // The key some Linux clients actually encrypted with (crbug.com/1195256):
+      // OSCrypt's derivation over an empty passphrase.
+      const cbcEmpty = NodeCrypto.pbkdf2Sync("", "saltysalt", 1, 16, "sha1");
+
+      yield* Effect.gen(function* () {
+        const sql = yield* SqlClient.SqlClient;
+        yield* sql`create table meta (key text primary key, value text not null)`;
+        yield* sql`insert into meta values ('version', '23')`;
+        yield* sql`
+          create table cookies (
+            host_key text not null, name text not null, value text not null,
+            encrypted_value blob not null, path text not null, expires_utc integer not null,
+            is_secure integer not null, is_httponly integer not null, samesite integer not null,
+            top_frame_site_key text not null default ''
+          )
+        `;
+        yield* sql`insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values
+          ('ev10.example', 'empty-v10', '', ${encryptChromium("v10", "empty v10 value", cbcEmpty)}, '/', 0, 1, 0, 0)`;
+        yield* sql`insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values
+          ('ev11.example', 'empty-v11', '', ${encryptChromium("v11", "empty v11 value", cbcEmpty)}, '/', 0, 1, 0, 0)`;
+      }).pipe(Effect.provide(NodeSqliteClient.layer({ filename })));
+
+      // The records' own keys fail, and the empty key recovers both — the
+      // retry Chromium itself performs.
+      const recovered = yield* readChromiumCookieDatabase(
+        filename,
+        { cbcV10, cbcV11, cbcEmpty },
+        "linux",
+      );
+      expect(recovered.cookies.map(({ name, value }) => ({ name, value }))).toEqual([
+        { name: "empty-v10", value: "empty v10 value" },
+        { name: "empty-v11", value: "empty v11 value" },
+      ]);
+      expect(recovered.undecryptable).toBe(0);
+
+      // Matching Chromium: a record whose own key is missing entirely is not
+      // retried with the empty key.
+      const noV11 = yield* readChromiumCookieDatabase(filename, { cbcV10, cbcEmpty }, "linux");
+      expect(noV11.cookies.map(({ name }) => name)).toEqual(["empty-v10"]);
+      expect(noV11.undecryptableHosts).toEqual(["ev11.example"]);
+    }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
+  );
+
   it.effect("preserves arbitrary long encrypted values from pre-24 schemas", () =>
     Effect.gen(function* () {
       const fileSystem = yield* FileSystem.FileSystem;
@@ -170,7 +369,7 @@
           ('legacy.example', 'legacy', '', ${encryptV10(value, key)}, '/', 0, 0, 0, 0)`;
       }).pipe(Effect.provide(NodeSqliteClient.layer({ filename })));
 
-      const result = yield* readChromiumCookieDatabase(filename, key, "darwin");
+      const result = yield* readChromiumCookieDatabase(filename, { cbcV10: key }, "darwin");
       expect(result.cookies[0]?.value).toBe(value);
       expect(result.undecryptable).toBe(0);
     }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
@@ -192,7 +391,7 @@
 
       const error = yield* readChromiumCookieDatabase(
         filename,
-        Buffer.from("0123456789abcdef"),
+        { cbcV10: Buffer.from("0123456789abcdef") },
         "darwin",
       ).pipe(Effect.flip);
 
@@ -200,7 +399,7 @@
     }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
   );
 
+  it.effect("treats unversioned encrypted values as legacy plaintext on macOS and Linux", () =>
-  it.effect("treats unversioned encrypted values as legacy plaintext only on macOS", () =>
     Effect.gen(function* () {
       const fileSystem = yield* FileSystem.FileSystem;
       const directory = yield* fileSystem.makeTempDirectoryScoped({
@@ -225,14 +424,16 @@
           ('legacy.example', 'legacy', '', ${Buffer.from("legacy cleartext")}, '/', 0, 0, 0, 0)`;
       }).pipe(Effect.provide(NodeSqliteClient.layer({ filename })));
 
-      const mac = yield* readChromiumCookieDatabase(filename, key, "darwin");
-      const linux = yield* readChromiumCookieDatabase(filename, key, "linux");
+      // Chromium's OSCrypt returns unprefixed data as-is on both platforms
+      // (os_crypt_mac.mm and os_crypt_linux.cc: "old data saved as clear
+      // text"), so neither counts it as undecryptable.
+      const mac = yield* readChromiumCookieDatabase(filename, { cbcV10: key }, "darwin");
+      const linux = yield* readChromiumCookieDatabase(filename, { cbcV10: key }, "linux");
 
       expect(mac.cookies[0]?.value).toBe("legacy cleartext");
       expect(mac.undecryptable).toBe(0);
-      expect(linux.cookies).toEqual([]);
-      expect(linux.undecryptable).toBe(1);
-      expect(linux.undecryptableHosts).toEqual(["legacy.example"]);
+      expect(linux.cookies[0]?.value).toBe("legacy cleartext");
+      expect(linux.undecryptable).toBe(0);
     }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
   );
 
@@ -279,8 +480,8 @@
           ('partitioned.example', 'partitioned', 'must skip', ${new Uint8Array()}, '/', 0, 1, 0, 0, 'https://top.example')`;
       }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: chipsFilename })));
 
+      const legacy = yield* readChromiumCookieDatabase(legacyFilename, { cbcV10: key }, "darwin");
-      const legacy = yield* readChromiumCookieDatabase(legacyFilename, key, "darwin");
+      const chips = yield* readChromiumCookieDatabase(chipsFilename, { cbcV10: key }, "darwin");
-      const chips = yield* readChromiumCookieDatabase(chipsFilename, key, "darwin");
 
       expect(legacy.cookies.map(({ name }) => name)).toEqual(["legacy"]);
       expect(legacy.undecryptable).toBe(0);
```

## apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts

Status: modified; +181; −144

```diff
@@ -3,26 +3,35 @@
 /**
  * Chromium cookie extraction.
  *
- * Reads a Chromium-family browser's cookie database and decrypts it with the
- * key the OS keychain hands us, which is the mechanism the browser itself
- * uses. macOS mediates that with a per-app consent prompt, so the user
+ * Reads a Chromium-family browser's cookie database and decrypts each record
+ * with the key its prefix calls for. Key acquisition — and the consent it
+ * needs — lives in `ChromiumKeys`.
- * explicitly approves T3 Code reading it.
  *
+ * Records whose scheme we hold no key for are skipped rather than failing the
+ * whole import: a Linux database can mix `v10` and `v11`. A partial result
- * Deliberately no fallback when the keychain says no: the alternative
+ * reported honestly is more useful than an all-or-nothing error.
- * techniques exist to defeat that consent, and this feature is not worth
- * shipping them.
  *
  * @module ChromiumCookies
  */
-import * as Keyring from "@napi-rs/keyring";
 import * as NodeCrypto from "node:crypto";
 
 import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
 import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Path from "effect/Path";
 import * as Schema from "effect/Schema";
+import * as Scope from "effect/Scope";
 import * as SqlClient from "effect/unstable/sql/SqlClient";
+import { ChildProcessSpawner } from "effect/unstable/process";
 
 import {
+  ChromiumKeyError,
+  ChromiumKeyFailure,
+  readWindowsKey,
+  resolveChromiumKeys,
+  type ChromiumKeyMaterial,
+} from "./ChromiumKeys.ts";
+import {
   bareHost,
   cookieScope,
   snapshotCookieDatabase,
@@ -30,20 +39,20 @@
   type ImportedCookie,
 } from "./CookieDatabase.ts";
 
-/** macOS OSCrypt parameters. Chromium has used these since the feature landed. */
-const MAC_KEY_ITERATIONS = 1003;
-const MAC_KEY_SALT = "saltysalt";
-const MAC_KEY_LENGTH = 16;
-/** OSCrypt uses a fixed IV of 16 spaces rather than a per-record one. */
-const AES_IV = Buffer.alloc(16, 0x20);
+/** OSCrypt's CBC mode uses a fixed IV of 16 spaces rather than a per-record one. */
+const AES_CBC_IV = Buffer.alloc(16, 0x20);
+const AES_GCM_NONCE_LENGTH = 12;
-const V10_PREFIX = "v10";
+const AES_GCM_TAG_LENGTH = 16;
+const isChromiumKeyError = Schema.is(ChromiumKeyError);
 
+/**
+ * Every way the read can fail: the key failures, plus the ones this module
+ * raises itself.
+ */
 export const ChromiumCookieReadReason = Schema.Literals([
-  "needsKeychainApproval",
+  // `readFailed` already comes from the key failures, so it is not repeated.
-  "keychainItemMissing",
+  ...ChromiumKeyFailure.literals,
   "browserRunning",
-  "unsupportedPlatform",
-  "readFailed",
 ]);
 export type ChromiumCookieReadReason = typeof ChromiumCookieReadReason.Type;
 
@@ -79,7 +88,6 @@
   samesite: Schema.Number,
   top_frame_site_key: Schema.String,
 });
-
 const decodeCookieRows = Schema.decodeUnknownEffect(Schema.Array(CookieRow));
 const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0));
 const SchemaVersion = Schema.Union([
@@ -106,11 +114,9 @@
 
 /**
  * Chromium timestamps count microseconds from 1601-01-01; Electron wants
+ * seconds from the UNIX epoch. The microsecond value overflows JavaScript's
+ * safe integer range and `node:sqlite` refuses to narrow it, so the division
- * seconds from the UNIX epoch.
+ * happens in SQL and this only ever sees seconds.
- *
- * The microsecond value overflows JavaScript's safe integer range, and
- * `node:sqlite` refuses to narrow it, so the division happens in SQL and this
- * only ever sees seconds.
  */
 const WEBKIT_EPOCH_OFFSET_SECONDS = 11_644_473_600;
 const toUnixSeconds = (webkitSeconds: number): number | undefined => {
@@ -119,136 +125,150 @@
 };
 
 /**
- * Reads the OSCrypt key from the login keychain.
+ * Chromium >= 127 prefixes the plaintext with SHA-256 of the host key, binding
+ * a cookie to its domain. Strip it when present.
- *
- * Uses the in-process Keychain API rather than shelling out to
- * `/usr/bin/security`, because the keychain attributes both the consent prompt
- * and the resulting ACL entry to the binary that asks. Via the CLI the prompt
- * says "security" and "Always Allow" grants trust to a tool every process on
- * the machine can invoke; in-process it names this app and the grant belongs
- * to it. (In an unsigned dev build the name is the dev Electron binary rather
- * than the shipped app identity.)
- *
- * Deliberately untimed: macOS answers this with a modal, and a timeout racing
- * the user means the prompt can be approved while nothing is left listening —
- * which reads as "approving did nothing".
  */
+const stripDomainBinding = (
+  plaintext: Buffer,
+  domain: string,
+  schemaVersion: number,
+): Buffer | null => {
+  if (schemaVersion < 24) return plaintext;
+  const domainHash = NodeCrypto.createHash("sha256").update(domain).digest();
+  return plaintext.length >= 32 && plaintext.subarray(0, 32).equals(domainHash)
+    ? plaintext.subarray(32)
+    : null;
+};
+
+const decryptCbc = (
+  payload: Buffer,
+  key: Buffer,
+  domain: string,
+  schemaVersion: number,
+): string | null => {
+  try {
+    const decipher = NodeCrypto.createDecipheriv("aes-128-cbc", key, AES_CBC_IV);
+    decipher.setAutoPadding(true);
+    const plaintext = Buffer.concat([decipher.update(payload), decipher.final()]);
-const readMacKeychainPassword = Effect.fn("ChromiumCookies.readMacKeychainPassword")(function* (
-  service: string,
-  account: string,
-  cookieDatabasePath: string,
-) {
+    return stripDomainBinding(plaintext, domain, schemaVersion)?.toString("utf8") ?? null;
+  } catch {
+    return null;
-  const password = yield* Effect.try({
-    try: () => new Keyring.Entry(service, account).getPassword(),
-    catch: (cause) => {
-      const message = String((cause as { message?: unknown } | undefined)?.message ?? "");
-      // Distinguish the causes rather than telling the user to approve a
-      // prompt when approving cannot fix the failure.
-      const missing = /no (matching )?entry|not found/i.test(message);
-      return new ChromiumCookieReadError({
-        reason: missing ? "keychainItemMissing" : "needsKeychainApproval",
-        cookieDatabasePath,
-        cause,
-      });
-    },
-  });
-  if (password === null || password === "") {
-    return yield* new ChromiumCookieReadError({
-      reason: "keychainItemMissing",
-      cookieDatabasePath,
-    });
   }
+};
-  return password;
-});
 
+const decryptGcm = (
+  payload: Buffer,
-const decryptValue = (
-  encrypted: Uint8Array,
   key: Buffer,
   domain: string,
   schemaVersion: number,
-  platform: NodeJS.Platform,
 ): string | null => {
-  const buffer = Buffer.from(encrypted);
+  if (payload.length < AES_GCM_NONCE_LENGTH + AES_GCM_TAG_LENGTH) return null;
-  const version = buffer.subarray(0, 3).toString("latin1");
-
   try {
-    let plaintext: Buffer;
-    if (version === V10_PREFIX) {
-      const decipher = NodeCrypto.createDecipheriv("aes-128-cbc", key, AES_IV);
-      decipher.setAutoPadding(true);
-      plaintext = Buffer.concat([decipher.update(buffer.subarray(3)), decipher.final()]);
-    } else if (platform === "darwin") {
-      // macOS OSCrypt explicitly treats an unversioned encrypted_value as
-      // legacy cleartext. This is platform-specific: Linux uses other version
-      // prefixes, which must not be widened into plaintext cookies.
-      plaintext = buffer;
-    } else {
-      return null;
-    }
-    // Cookie schema 24 requires SHA-256(host_key) at the front of every
-    // encrypted value. Treat a missing or mismatched binding as undecryptable;
-    // older schemas stored arbitrary plaintext here, including long values
-    // whose first 32 bytes must not be interpreted as a hash.
-    if (schemaVersion >= 24) {
-      const domainHash = NodeCrypto.createHash("sha256").update(domain).digest();
-      if (plaintext.length < domainHash.length || !plaintext.subarray(0, 32).equals(domainHash)) {
-        return null;
+    const nonce = payload.subarray(0, AES_GCM_NONCE_LENGTH);
+    const ciphertext = payload.subarray(AES_GCM_NONCE_LENGTH, -AES_GCM_TAG_LENGTH);
-      }
-      plaintext = plaintext.subarray(32);
-    }
+    const tag = payload.subarray(-AES_GCM_TAG_LENGTH);
+    const decipher = NodeCrypto.createDecipheriv("aes-256-gcm", key, nonce);
+    decipher.setAuthTag(tag);
+    const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
+    return stripDomainBinding(plaintext, domain, schemaVersion)?.toString("utf8") ?? null;
-    return plaintext.toString("utf8");
   } catch {
     return null;
   }
 };
 
+/**
+ * Decrypts one stored value, choosing the scheme from its prefix. Returns null
+ * when no key covers that scheme — including Windows' app-bound `v20`, which
+ * this build has no key for at all.
+ */
+export function decryptChromiumValue(
+  encrypted: Uint8Array,
+  keys: ChromiumKeyMaterial,
+  domain: string,
+  schemaVersion = 23,
+  platform: NodeJS.Platform = "linux",
+): string | null {
+  const buffer = Buffer.from(encrypted);
+  if (buffer.length === 0) return "";
+  const prefix = buffer.subarray(0, 3).toString("latin1");
+  const payload = buffer.subarray(3);
+
+  // Windows' legacy v10 format is AES-256-GCM. App-bound records use v20 and
+  // intentionally have no key here, so they fall through as undecryptable.
+  if (platform === "win32") {
+    return prefix === "v10" && keys.gcmV10
+      ? decryptGcm(payload, keys.gcmV10, domain, schemaVersion)
+      : null;
+  }
+
+  // Chromium retries a failed record with a key derived from an empty
+  // passphrase, because some Linux clients wrote data that way
+  // (crbug.com/1195256). A record whose own key is missing entirely stays
+  // skipped, matching Chromium.
+  if (prefix === "v10") {
+    if (!keys.cbcV10) return null;
+    return (
+      decryptCbc(payload, keys.cbcV10, domain, schemaVersion) ??
+      (keys.cbcEmpty ? decryptCbc(payload, keys.cbcEmpty, domain, schemaVersion) : null)
+    );
+  }
+  if (prefix === "v11") {
+    if (!keys.cbcV11) return null;
+    return (
+      decryptCbc(payload, keys.cbcV11, domain, schemaVersion) ??
+      (keys.cbcEmpty ? decryptCbc(payload, keys.cbcEmpty, domain, schemaVersion) : null)
+    );
+  }
+  // No recognised prefix: Chromium on macOS and Linux both treat this as
+  // legacy data stored in the clear and return it as-is, so it is a readable
+  // cookie rather than an undecryptable one. Windows is the exception — its
+  // app-bound `v20` blobs also lack these prefixes and must not be read as
+  // plaintext — but Windows Chromium is not importable here at all.
+  if (platform === "darwin" || platform === "linux") {
+    return stripDomainBinding(buffer, domain, schemaVersion)?.toString("utf8") ?? null;
+  }
+  return null;
+}
+
 /** Reads and decodes one snapshotted Chromium cookie database. */
 export const readChromiumCookieDatabase = Effect.fn("ChromiumCookies.readChromiumCookieDatabase")(
-  function* (snapshotPath: string, key: Buffer, platform: NodeJS.Platform) {
+  function* (snapshotPath: string, keys: ChromiumKeyMaterial, platform: NodeJS.Platform) {
+    const result = yield* Effect.gen(function* () {
-    const rows = yield* Effect.gen(function* () {
       const sql = yield* SqlClient.SqlClient;
       const schemaVersion = yield* sql`select value from meta where key = 'version' limit 1`.pipe(
         Effect.flatMap(decodeSchemaVersion),
         Effect.map(([row]) => row.value),
       );
-      // CHIPS added top_frame_site_key in schema 15. Keep the old query valid
-      // for earlier databases, which do not have the column at all.
       const raw =
         schemaVersion >= 15
-          ? yield* sql`
-              select host_key, name, value, encrypted_value, path,
-                     expires_utc / 1000000 as expires_seconds,
-                     is_secure, is_httponly, samesite, top_frame_site_key
-                from cookies
-            `
-          : yield* sql`
-              select host_key, name, value, encrypted_value, path,
-                     expires_utc / 1000000 as expires_seconds,
-                     is_secure, is_httponly, samesite, '' as top_frame_site_key
+          ? yield* sql`select host_key, name, value, encrypted_value, path,
+                expires_utc / 1000000 as expires_seconds, is_secure, is_httponly,
+                samesite, top_frame_site_key from cookies`
-                from cookies
-            `;
+          : yield* sql`select host_key, name, value, encrypted_value, path,
+                expires_utc / 1000000 as expires_seconds, is_secure, is_httponly,
+                samesite, '' as top_frame_site_key from cookies`;
       return { rows: yield* decodeCookieRows(raw), schemaVersion };
     }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: snapshotPath, readonly: true })));
 
     const cookies: ImportedCookie[] = [];
     let undecryptable = 0;
     const undecryptableHosts = new Set<string>();
-    for (const row of rows.rows) {
-      // Electron's cookies.set contract cannot represent a CHIPS partition
-      // key. Importing this row without one would widen it into an ordinary
+    for (const row of result.rows) {
-      // unpartitioned cookie, so count it as skipped instead.
       if (row.top_frame_site_key !== "") {
         undecryptable += 1;
         undecryptableHosts.add(bareHost(row.host_key));
         continue;
       }
-      // Chromium stores legacy/plaintext cookies in `value` with an empty
-      // encrypted blob. Preserve an actually empty cookie by falling back to
-      // `value`, rather than treating every empty blob as the empty string.
       const value =
         row.encrypted_value.length === 0
           ? row.value
+          : decryptChromiumValue(
+              row.encrypted_value,
+              keys,
+              row.host_key,
+              result.schemaVersion,
+              platform,
+            );
-          : decryptValue(row.encrypted_value, key, row.host_key, rows.schemaVersion, platform);
       if (value === null) {
         undecryptable += 1;
         undecryptableHosts.add(bareHost(row.host_key));
@@ -268,6 +288,19 @@
         sameSite: sameSiteFromColumn(row.samesite),
       });
     }
+    // Keep partial imports, but do not call a missing key a successful import
+    // when it prevented every otherwise importable cookie from being read.
+    if (
+      cookies.length === 0 &&
+      keys.cbcV11Error !== undefined &&
+      result.rows.some(
+        (row) =>
+          row.top_frame_site_key === "" &&
+          Buffer.from(row.encrypted_value.subarray(0, 3)).toString("latin1") === "v11",
+      )
+    ) {
+      return yield* keys.cbcV11Error;
+    }
     return {
       cookies,
       undecryptable,
@@ -278,35 +311,39 @@
 
 export interface ChromiumCookieSource {
   readonly cookieDatabasePath: string;
+  readonly keychainService: string | undefined;
+  readonly keychainAccount: string | undefined;
-  readonly keychainService: string;
-  readonly keychainAccount: string;
+  readonly linuxSecretApplication: string | undefined;
+  readonly windowsLocalStatePath?: string;
   /** Supplied by the caller from `HostProcessPlatform` rather than read here. */
   readonly platform: NodeJS.Platform;
 }
 
 export const readChromiumCookies = Effect.fn("ChromiumCookies.readChromiumCookies")(function* (
   source: ChromiumCookieSource,
+): Effect.fn.Return<
+  CookieReadResult,
+  ChromiumCookieReadError,
+  FileSystem.FileSystem | Path.Path | Scope.Scope | ChildProcessSpawner.ChildProcessSpawner
+> {
+  const keys = yield* (
+    source.platform === "win32" && source.windowsLocalStatePath
+      ? readWindowsKey(source.windowsLocalStatePath).pipe(Effect.map((gcmV10) => ({ gcmV10 })))
-) {
-  if (source.platform !== "darwin") {
-    // Linux (libsecret) and Windows (DPAPI, and App-Bound Encryption on
-    // current Chrome) each need their own key path; only macOS is implemented.
-    return yield* new ChromiumCookieReadError({
-      reason: "unsupportedPlatform",
-      cookieDatabasePath: source.cookieDatabasePath,
-    });
-  }
-
-  const password = yield* readMacKeychainPassword(
-    source.keychainService,
-    source.keychainAccount,
-    source.cookieDatabasePath,
-  );
-  const key = NodeCrypto.pbkdf2Sync(
-    password,
-    MAC_KEY_SALT,
-    MAC_KEY_ITERATIONS,
-    MAC_KEY_LENGTH,
+      : resolveChromiumKeys({
+          platform: source.platform,
+          keychainService: source.keychainService,
-    "sha1",
+          keychainAccount: source.keychainAccount,
+          linuxSecretApplication: source.linuxSecretApplication,
+        })
+  ).pipe(
+    Effect.mapError(
+      (cause: ChromiumKeyError) =>
+        new ChromiumCookieReadError({
+          reason: cause.reason,
+          cookieDatabasePath: source.cookieDatabasePath,
+          cause,
+        }),
+    ),
   );
 
   const snapshotPath = yield* snapshotCookieDatabase(source.cookieDatabasePath).pipe(
@@ -320,11 +357,11 @@
     ),
   );
 
+  return yield* readChromiumCookieDatabase(snapshotPath, keys, source.platform).pipe(
-  return yield* readChromiumCookieDatabase(snapshotPath, key, source.platform).pipe(
     Effect.mapError(
       (cause) =>
         new ChromiumCookieReadError({
-          reason: "readFailed",
+          reason: isChromiumKeyError(cause) ? cause.reason : "readFailed",
           cookieDatabasePath: source.cookieDatabasePath,
           cause,
         }),
```

## apps/desktop/src/preview/BrowserImport/ChromiumKeys.test.ts

Status: added; +274; −0

```diff
@@ -1,0 +1,274 @@
+import { describe, expect, it } from "@effect/vitest";
+import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
+import * as Deferred from "effect/Deferred";
+import * as Effect from "effect/Effect";
+import * as Layer from "effect/Layer";
+import * as Sink from "effect/Sink";
+import * as Stream from "effect/Stream";
+import * as PlatformError from "effect/PlatformError";
+import { ChildProcessSpawner } from "effect/unstable/process";
+
+import {
+  ChromiumKeyError,
+  decodeWindowsWrappedKey,
+  readLinuxSecret,
+  resolveChromiumKeys,
+  unwrapWindowsDpapiKey,
+} from "./ChromiumKeys.ts";
+import { LinuxBrowserSecretPath } from "./LinuxBrowserSecret.ts";
+
+type CapturedCommand = {
+  readonly command: string;
+  readonly args: ReadonlyArray<string>;
+  readonly options: {
+    readonly stdin?: string;
+    readonly env?: Readonly<Record<string, string | undefined>>;
+  };
+};
+
+const helperLayer = (input: {
+  readonly stdout?: string;
+  readonly stderr?: string;
+  readonly stdoutStream?: Stream.Stream<Uint8Array>;
+  readonly stderrStream?: Stream.Stream<Uint8Array>;
+  readonly exitCode?: number;
+  readonly spawnError?: PlatformError.PlatformError;
+  readonly capture?: (command: CapturedCommand) => void;
+}) =>
+  Layer.merge(
+    Layer.succeed(LinuxBrowserSecretPath, "/bundled/browser-secret/t3-browser-secret"),
+    Layer.succeed(
+      ChildProcessSpawner.ChildProcessSpawner,
+      ChildProcessSpawner.make((command) =>
+        input.spawnError
+          ? Effect.fail(input.spawnError)
+          : Effect.succeed(
+              ChildProcessSpawner.makeHandle({
+                pid: ChildProcessSpawner.ProcessId(1),
+                exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(input.exitCode ?? 0)),
+                isRunning: Effect.succeed(false),
+                kill: () => Effect.void,
+                unref: Effect.succeed(Effect.void),
+                stdin: Sink.drain,
+                stdout: input.stdoutStream ?? Stream.encodeText(Stream.make(input.stdout ?? "")),
+                stderr: input.stderrStream ?? Stream.encodeText(Stream.make(input.stderr ?? "")),
+                all: Stream.empty,
+                getInputFd: () => Sink.drain,
+                getOutputFd: () => Stream.empty,
+              }),
+            ).pipe(
+              Effect.tap(() => Effect.sync(() => input.capture?.(command as CapturedCommand))),
+            ),
+      ),
+    ),
+  );
+
+describe("Linux Chromium secrets", () => {
+  it.effect("retains a missing helper failure alongside the keyring-free fallback", () =>
+    Effect.gen(function* () {
+      const keys = yield* resolveChromiumKeys({
+        platform: "linux",
+        keychainService: undefined,
+        keychainAccount: undefined,
+        linuxSecretApplication: "chromium",
+      });
+      expect(keys.cbcV10).toHaveLength(16);
+      expect(keys.cbcV11).toBeUndefined();
+      expect(keys.cbcV11Error?.reason).toBe("keychainUnavailable");
+    }).pipe(
+      Effect.provide(
+        helperLayer({
+          spawnError: PlatformError.systemError({
+            _tag: "NotFound",
+            module: "ChildProcess",
+            method: "spawn",
+          }),
+        }),
+      ),
+    ),
+  );
+
+  it.effect("reports an unconfigured helper without searching PATH", () =>
+    readLinuxSecret("chromium").pipe(
+      Effect.flip,
+      Effect.tap((error) => Effect.sync(() => expect(error.reason).toBe("keychainUnavailable"))),
+      Effect.provideService(
+        ChildProcessSpawner.ChildProcessSpawner,
+        ChildProcessSpawner.make(() => Effect.die("must not spawn")),
+      ),
+      Effect.provideService(LinuxBrowserSecretPath, undefined),
+    ),
+  );
+
+  it.effect("looks up the browser's libsecret application attribute", () => {
+    let captured: CapturedCommand | undefined;
+    return Effect.gen(function* () {
+      const keys = yield* resolveChromiumKeys({
+        platform: "linux",
+        keychainService: "ignored macOS service",
+        keychainAccount: "ignored macOS account",
+        linuxSecretApplication: "msedge",
+      });
+
+      expect(captured?.command).toBe("/bundled/browser-secret/t3-browser-secret");
+      expect(captured?.args).toEqual(["msedge"]);
+      expect(captured?.options.stdin).toBe("ignore");
+      expect(keys.cbcV10).toHaveLength(16);
+      expect(keys.cbcV11).toHaveLength(16);
+    }).pipe(
+      Effect.provide(
+        helperLayer({ stdout: "linux-secret", capture: (value) => (captured = value) }),
+      ),
+    );
+  });
+
+  it.effect("reports an unavailable Secret Service backend as a read failure", () =>
+    Effect.gen(function* () {
+      const error = yield* readLinuxSecret("chrome").pipe(Effect.flip);
+      expect(error).toBeInstanceOf(ChromiumKeyError);
+      expect(error.reason).toBe("keychainUnavailable");
+    }).pipe(
+      Effect.provide(
+        helperLayer({ stderr: "Cannot autolaunch D-Bus without X11 $DISPLAY", exitCode: 1 }),
+      ),
+    ),
+  );
+
+  it.effect("preserves trailing whitespace in the stored secret", () =>
+    Effect.gen(function* () {
+      const secret = yield* readLinuxSecret("chrome");
+      expect(secret).toBe("linux-secret \t\n");
+    }).pipe(Effect.provide(helperLayer({ stdout: "linux-secret \t\n" }))),
+  );
+
+  it.effect("drains stdout and stderr concurrently", () =>
+    Effect.gen(function* () {
+      const stderrDrainStarted = yield* Deferred.make<void>();
+      const stdout = Stream.fromEffect(Deferred.await(stderrDrainStarted)).pipe(
+        Stream.flatMap(() => Stream.encodeText(Stream.make("linux-secret"))),
+      );
+      const stderr = Stream.fromEffect(Deferred.succeed(stderrDrainStarted, undefined)).pipe(
+        Stream.drain,
+      );
+
+      const secret = yield* readLinuxSecret("chrome").pipe(
+        Effect.provide(helperLayer({ stdoutStream: stdout, stderrStream: stderr })),
+      );
+
+      expect(secret).toBe("linux-secret");
+    }),
+  );
+
+  it.effect(
+    "preserves the desktop environment and identifies denial without parsing stderr",
+    () => {
+      let captured: CapturedCommand | undefined;
+      return Effect.gen(function* () {
+        const error = yield* readLinuxSecret("brave").pipe(Effect.flip);
+        expect(error).toBeInstanceOf(ChromiumKeyError);
+        expect(error.reason).toBe("needsKeychainApproval");
+        expect(captured?.options.env?.LC_ALL).toBe("localized");
+        expect(captured?.options.env?.PATH).toBe("/synthetic/bin");
+        expect(captured?.options.env?.SESSION_MARKER).toBe("kept");
+      }).pipe(
+        Effect.provide(
+          helperLayer({
+            stderr: "Zugriff verweigert",
+            exitCode: 3,
+            capture: (value) => (captured = value),
+          }),
+        ),
+        Effect.provideService(HostProcessEnvironment, {
+          PATH: "/synthetic/bin",
+          SESSION_MARKER: "kept",
+          LC_ALL: "localized",
+        }),
+      );
+    },
+  );
+
+  it.effect("does not discard a denied unlock prompt while resolving keys", () =>
+    Effect.gen(function* () {
+      const error = yield* resolveChromiumKeys({
+        platform: "linux",
+        keychainService: undefined,
+        keychainAccount: undefined,
+        linuxSecretApplication: "brave",
+      }).pipe(Effect.flip);
+      expect(error.reason).toBe("needsKeychainApproval");
+    }).pipe(Effect.provide(helperLayer({ stderr: "Keyring is locked", exitCode: 3 }))),
+  );
+
+  it.effect("keeps the v10 fallback when the Secret Service backend is unavailable", () =>
+    Effect.gen(function* () {
+      const keys = yield* resolveChromiumKeys({
+        platform: "linux",
+        keychainService: undefined,
+        keychainAccount: undefined,
+        linuxSecretApplication: "chrome",
+      });
+      expect(keys.cbcV10).toHaveLength(16);
+      expect(keys.cbcV11).toBeUndefined();
+    }).pipe(
+      Effect.provide(
+        helperLayer({
+          stderr: "Cannot autolaunch D-Bus without X11 $DISPLAY",
+          exitCode: 1,
+        }),
+      ),
+    ),
+  );
+
+  it.effect("keeps the v10 fallback when no matching v11 secret exists", () =>
+    Effect.gen(function* () {
+      const keys = yield* resolveChromiumKeys({
+        platform: "linux",
+        keychainService: undefined,
+        keychainAccount: undefined,
+        linuxSecretApplication: "vivaldi",
+      });
+      expect(keys.cbcV10).toHaveLength(16);
+      expect(keys.cbcV11).toBeUndefined();
+    }).pipe(Effect.provide(helperLayer({ exitCode: 2 }))),
+  );
+});
+
+describe("Windows Chromium secrets", () => {
+  it.effect("accepts only DPAPI-wrapped non-app-bound keys", () =>
+    Effect.gen(function* () {
+      const wrapped = Buffer.from("wrapped-key");
+      const encoded = Buffer.concat([Buffer.from("DPAPI"), wrapped]).toString("base64");
+      const localState = `{"os_crypt":{"encrypted_key":"${encoded}"}}`;
+      expect(yield* decodeWindowsWrappedKey(localState)).toEqual(wrapped);
+
+      const appBound = yield* decodeWindowsWrappedKey(
+        `{"os_crypt":{"encrypted_key":"${encoded}","app_bound_encrypted_key":"present"}}`,
+      ).pipe(Effect.flip);
+      expect(appBound.reason).toBe("unsupportedPlatform");
+
+      const malformed = yield* decodeWindowsWrappedKey(
+        `{"os_crypt":{"encrypted_key":"${wrapped.toString("base64")}"}}`,
+      ).pipe(Effect.flip);
+      expect(malformed.reason).toBe("readFailed");
+    }),
+  );
+
+  it.effect("unwraps the binary key through PowerShell without placing it in argv", () => {
+    let captured: CapturedCommand | undefined;
+    const wrapped = Buffer.from("wrapped-key");
+    const key = Buffer.from("0123456789abcdef0123456789abcdef");
+    return Effect.gen(function* () {
+      expect(yield* unwrapWindowsDpapiKey(wrapped)).toEqual(key);
+      expect(captured?.command).toBe(
+        "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe",
+      );
+      expect(captured?.args).toContain("-NonInteractive");
+      expect(captured?.args.join(" ")).not.toContain(wrapped.toString("base64"));
+    }).pipe(
+      Effect.provide(
+        helperLayer({ stdout: key.toString("base64"), capture: (value) => (captured = value) }),
+      ),
+      Effect.provideService(HostProcessEnvironment, { SystemRoot: "C:\\Windows" }),
+    );
+  });
+});
```

## apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts

Status: added; +333; −0

```diff
@@ -1,0 +1,333 @@
+// @effect-diagnostics nodeBuiltinImport:off - `node:crypto` implements the
+// OSCrypt key derivation Chromium uses; Effect has no equivalent.
+/**
+ * Chromium cookie-encryption keys, per platform.
+ *
+ * Chromium calls this OSCrypt, and it works differently on each OS:
+ *
+ * - **macOS** keeps one key in the login keychain. Reading it prompts the
+ *   user, which is the consent this feature is built around.
+ * - **Linux** may keep a key in libsecret/kwallet (`v11` records), or use a
+ *   hardcoded `peanuts` passphrase when no keyring is available (`v10`). Both
+ *   can appear in the same database, so both are derived up front and chosen
+ *   per record.
+ *
+ * - **Windows** legacy Chromium stores protect a random AES key with DPAPI.
+ *   App-Bound Encryption remains deliberately unsupported.
+ *
+ * @module ChromiumKeys
+ */
+import * as Keyring from "@napi-rs/keyring";
+import * as NodeCrypto from "node:crypto";
+
+import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
+
+import * as Effect from "effect/Effect";
+import * as Encoding from "effect/Encoding";
+import * as FileSystem from "effect/FileSystem";
+import * as Schema from "effect/Schema";
+import * as Stream from "effect/Stream";
+import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
+import { LinuxBrowserSecretPath } from "./LinuxBrowserSecret.ts";
+
+const KEY_SALT = "saltysalt";
+const KEY_LENGTH = 16;
+/** macOS stretches the keychain secret; Linux uses a single iteration. */
+const MAC_KEY_ITERATIONS = 1003;
+const LINUX_KEY_ITERATIONS = 1;
+/** Chromium's documented fallback passphrase when no Linux keyring is present. */
+const LINUX_FALLBACK_PASSPHRASE = "peanuts";
+
+export const ChromiumKeyFailure = Schema.Literals([
+  "needsKeychainApproval",
+  "keychainItemMissing",
+  "keychainUnavailable",
+  "unsupportedPlatform",
+  /** The key store itself could not be read, as opposed to holding no key. */
+  "readFailed",
+]);
+export type ChromiumKeyFailure = typeof ChromiumKeyFailure.Type;
+
+export class ChromiumKeyError extends Schema.TaggedErrorClass<ChromiumKeyError>()(
+  "ChromiumKeyError",
+  {
+    reason: ChromiumKeyFailure,
+    /** Kept for the log; never surfaced to the user. */
+    cause: Schema.optional(Schema.Defect()),
+  },
+) {
+  override get message(): string {
+    return `Could not obtain the Chromium cookie key: ${this.reason}.`;
+  }
+}
+
+/**
+ * Keys to try, indexed by the record prefix they decrypt. A database can hold
+ * records written under more than one scheme, so a missing entry means those
+ * records are skipped rather than the whole import failing.
+ */
+export interface ChromiumKeyMaterial {
+  /** AES-128-CBC on macOS, and the keyring-free Linux fallback. */
+  readonly cbcV10?: Buffer;
+  /** AES-128-CBC, Linux keyring-derived. */
+  readonly cbcV11?: Buffer;
+  /** Retained so an import that needs this key can report why it is missing. */
+  readonly cbcV11Error?: ChromiumKeyError;
+  /**
+   * AES-128-CBC from an empty passphrase. Some Linux clients wrote records
+   * with it (crbug.com/1195256), so Chromium — and this import — retry with it
+   * after a record's own key fails.
+   */
+  readonly cbcEmpty?: Buffer;
+  /** AES-256-GCM key used by pre-App-Bound Chromium on Windows. */
+  readonly gcmV10?: Buffer;
+}
+
+const derive = (passphrase: string, iterations: number) =>
+  NodeCrypto.pbkdf2Sync(passphrase, KEY_SALT, iterations, KEY_LENGTH, "sha1");
+
+/**
+ * Reads the macOS OSCrypt secret from the login keychain.
+ *
+ * Uses the in-process Keychain API rather than shelling out to
+ * `/usr/bin/security`, because macOS attributes both the consent prompt and the
+ * resulting ACL entry to the binary that asks. Via the CLI the prompt says
+ * "security" and "Always Allow" grants trust to a tool every process on the
+ * machine can invoke; in-process it names this app and the grant belongs to it.
+ * (In an unsigned dev build the name is the dev binary, not the shipped app
+ * identity.)
+ *
+ * Deliberately untimed: macOS answers this with a modal, and a timeout racing
+ * the user means the prompt can be approved while nothing is left listening,
+ * which reads as "approving did nothing".
+ */
+const readKeychainSecret = Effect.fn("ChromiumKeys.readKeychainSecret")(function* (
+  service: string,
+  account: string,
+) {
+  const secret = yield* Effect.try({
+    try: () => new Keyring.Entry(service, account).getPassword(),
+    catch: (cause) => {
+      const message = String((cause as { message?: unknown } | undefined)?.message ?? "");
+      // Distinguish the causes rather than reporting "approve the prompt" for
+      // a failure approving cannot fix.
+      const missing = /no (matching )?entry|not found/i.test(message);
+      return new ChromiumKeyError({
+        reason: missing ? "keychainItemMissing" : "needsKeychainApproval",
+        cause,
+      });
+    },
+  });
+  if (secret === null || secret === "") {
+    return yield* new ChromiumKeyError({ reason: "keychainItemMissing" });
+  }
+  return secret;
+});
+
+/**
+ * The bundled helper searches Chromium's libsecret schema and application
+ * attribute, retaining the desktop's normal unlock prompt. Its exit codes
+ * distinguish a missing key, denied access, and an unavailable keyring without
+ * parsing localized error messages. Stdout is the unmodified secret.
+ */
+export const readLinuxSecret = Effect.fn("ChromiumKeys.readLinuxSecret")(function* (
+  application: string,
+) {
+  return yield* Effect.scoped(
+    Effect.gen(function* () {
+      const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
+      const environment = yield* HostProcessEnvironment;
+      const helper = yield* LinuxBrowserSecretPath;
+      if (helper === undefined) {
+        return yield* new ChromiumKeyError({ reason: "keychainUnavailable" });
+      }
+      const handle = yield* spawner
+        .spawn(ChildProcess.make(helper, [application], { stdin: "ignore", env: environment }))
+        .pipe(
+          Effect.mapError(
+            (cause) => new ChromiumKeyError({ reason: "keychainUnavailable", cause }),
+          ),
+        );
+      const [secret, , exitCode] = yield* Effect.all(
+        [
+          handle.stdout.pipe(Stream.decodeText(), Stream.mkString),
+          handle.stderr.pipe(Stream.runDrain),
+          handle.exitCode,
+        ],
+        { concurrency: "unbounded" },
+      ).pipe(
+        Effect.mapError((cause) => new ChromiumKeyError({ reason: "keychainUnavailable", cause })),
+      );
+      if (Number(exitCode) !== 0) {
+        return yield* new ChromiumKeyError({
+          reason:
+            Number(exitCode) === 2
+              ? "keychainItemMissing"
+              : Number(exitCode) === 3
+                ? "needsKeychainApproval"
+                : "keychainUnavailable",
+        });
+      }
+      if (secret === "") {
+        return yield* new ChromiumKeyError({ reason: "keychainItemMissing" });
+      }
+      return secret;
+    }),
+  );
+});
+
+const WindowsLocalState = Schema.Struct({
+  os_crypt: Schema.Struct({
+    encrypted_key: Schema.String,
+    app_bound_encrypted_key: Schema.optional(Schema.String),
+  }),
+});
+const decodeWindowsLocalState = Schema.decodeUnknownEffect(
+  Schema.fromJsonString(WindowsLocalState),
+);
+const DPAPI_PREFIX = Buffer.from("DPAPI");
+const WINDOWS_KEY_LENGTH = 32;
+const WINDOWS_DPAPI_SCRIPT =
+  "Add-Type -AssemblyName System.Security;" +
+  "$value=[Console]::In.ReadToEnd();" +
+  "$encrypted=[Convert]::FromBase64String($value);" +
+  "$plain=[Security.Cryptography.ProtectedData]::Unprotect($encrypted,$null,[Security.Cryptography.DataProtectionScope]::CurrentUser);" +
+  "[Console]::Out.Write([Convert]::ToBase64String($plain))";
+
+export const decodeWindowsWrappedKey = Effect.fn("ChromiumKeys.decodeWindowsWrappedKey")(function* (
+  contents: string,
+) {
+  const state = yield* decodeWindowsLocalState(contents).pipe(
+    Effect.mapError((cause) => new ChromiumKeyError({ reason: "readFailed", cause })),
+  );
+  if (state.os_crypt.app_bound_encrypted_key !== undefined) {
+    return yield* new ChromiumKeyError({ reason: "unsupportedPlatform" });
+  }
+  const wrapped = yield* Effect.fromResult(
+    Encoding.decodeBase64(state.os_crypt.encrypted_key),
+  ).pipe(Effect.mapError((cause) => new ChromiumKeyError({ reason: "readFailed", cause })));
+  const wrappedBuffer = Buffer.from(wrapped);
+  if (!wrappedBuffer.subarray(0, DPAPI_PREFIX.length).equals(DPAPI_PREFIX)) {
+    return yield* new ChromiumKeyError({ reason: "readFailed" });
+  }
+  return wrappedBuffer.subarray(DPAPI_PREFIX.length);
+});
+
+/** Unwraps a key with the current Windows user's DPAPI identity. */
+export const unwrapWindowsDpapiKey = Effect.fn("ChromiumKeys.unwrapWindowsDpapiKey")(function* (
+  wrapped: Buffer,
+) {
+  const environment = yield* HostProcessEnvironment;
+  return yield* Effect.scoped(
+    Effect.gen(function* () {
+      const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
+      const windowsRoot = environment.SystemRoot ?? environment.WINDIR;
+      const powershell = windowsRoot
+        ? `${windowsRoot}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`
+        : "powershell.exe";
+      const handle = yield* spawner
+        .spawn(
+          ChildProcess.make(
+            powershell,
+            [
+              "-NoLogo",
+              "-NoProfile",
+              "-NonInteractive",
+              "-WindowStyle",
+              "Hidden",
+              "-Command",
+              WINDOWS_DPAPI_SCRIPT,
+            ],
+            {
+              env: environment,
+              stdin: Stream.encodeText(Stream.make(wrapped.toString("base64"))),
+            },
+          ),
+        )
+        .pipe(
+          Effect.mapError(
+            (cause) => new ChromiumKeyError({ reason: "keychainUnavailable", cause }),
+          ),
+        );
+      const [plainEncoded, , exitCode] = yield* Effect.all(
+        [
+          handle.stdout.pipe(Stream.decodeText(), Stream.mkString),
+          handle.stderr.pipe(Stream.runDrain),
+          handle.exitCode,
+        ],
+        { concurrency: "unbounded" },
+      ).pipe(Effect.mapError((cause) => new ChromiumKeyError({ reason: "readFailed", cause })));
+      if (Number(exitCode) !== 0) {
+        return yield* new ChromiumKeyError({ reason: "readFailed" });
+      }
+      const plain = yield* Effect.fromResult(Encoding.decodeBase64(plainEncoded)).pipe(
+        Effect.mapError((cause) => new ChromiumKeyError({ reason: "readFailed", cause })),
+      );
+      if (plain.length !== WINDOWS_KEY_LENGTH) {
+        return yield* new ChromiumKeyError({ reason: "readFailed" });
+      }
+      return Buffer.from(plain);
+    }),
+  );
+});
+
+/** Reads and unwraps a legacy Windows Chromium key without exposing it in argv. */
+export const readWindowsKey = Effect.fn("ChromiumKeys.readWindowsKey")(function* (
+  localStatePath: string,
+) {
+  const fileSystem = yield* FileSystem.FileSystem;
+  const contents = yield* fileSystem
+    .readFileString(localStatePath)
+    .pipe(Effect.mapError((cause) => new ChromiumKeyError({ reason: "readFailed", cause })));
+  return yield* unwrapWindowsDpapiKey(yield* decodeWindowsWrappedKey(contents));
+});
+
+export interface ChromiumKeyRequest {
+  readonly platform: NodeJS.Platform;
+  readonly keychainService: string | undefined;
+  readonly keychainAccount: string | undefined;
+  readonly linuxSecretApplication: string | undefined;
+}
+
+export const resolveChromiumKeys = Effect.fn("ChromiumKeys.resolveChromiumKeys")(function* (
+  request: ChromiumKeyRequest,
+): Effect.fn.Return<
+  ChromiumKeyMaterial,
+  ChromiumKeyError,
+  ChildProcessSpawner.ChildProcessSpawner
+> {
+  if (request.platform === "darwin") {
+    if (!request.keychainService || !request.keychainAccount) {
+      return yield* new ChromiumKeyError({ reason: "unsupportedPlatform" });
+    }
+    const secret = yield* readKeychainSecret(request.keychainService, request.keychainAccount);
+    return { cbcV10: derive(secret, MAC_KEY_ITERATIONS) };
+  }
+
+  if (request.platform === "linux") {
+    // The fallback passphrase always applies to `v10` records; a keyring
+    // secret, when one is reachable, additionally unlocks `v11`. Preserve its
+    // failure until the reader knows whether any cookies needed that key.
+    const keyringSecret = request.linuxSecretApplication
+      ? yield* readLinuxSecret(request.linuxSecretApplication).pipe(
+          // v10 remains importable when Secret Service is absent or does not
+          // contain a key. An explicit denial/lock/cancel remains a consent
+          // failure rather than being silently downgraded.
+          Effect.catch((error) =>
+            error.reason === "needsKeychainApproval" ? Effect.fail(error) : Effect.succeed(error),
+          ),
+        )
+      : undefined;
+    return {
+      cbcV10: derive(LINUX_FALLBACK_PASSPHRASE, LINUX_KEY_ITERATIONS),
+      ...(typeof keyringSecret === "string"
+        ? { cbcV11: derive(keyringSecret, LINUX_KEY_ITERATIONS) }
+        : keyringSecret
+          ? { cbcV11Error: keyringSecret }
+          : {}),
+      cbcEmpty: derive("", LINUX_KEY_ITERATIONS),
+    };
+  }
+
+  return yield* new ChromiumKeyError({ reason: "unsupportedPlatform" });
+});
```

## apps/desktop/src/preview/BrowserImport/LinuxBrowserSecret.test.ts

Status: added; +69; −0

```diff
@@ -1,0 +1,69 @@
+import * as NodeServices from "@effect/platform-node/NodeServices";
+import { assert, it } from "@effect/vitest";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Layer from "effect/Layer";
+import * as Path from "effect/Path";
+
+import * as DesktopConfig from "../../app/DesktopConfig.ts";
+import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts";
+import * as LinuxBrowserSecret from "./LinuxBrowserSecret.ts";
+
+it.layer(NodeServices.layer)("Linux browser secret path", (it) => {
+  it.effect("finds development and packaged helpers without falling back outside the install", () =>
+    Effect.gen(function* () {
+      const fileSystem = yield* FileSystem.FileSystem;
+      const path = yield* Path.Path;
+      const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-browser-secret-path-" });
+      const resourcesPath = path.join(root, "install", "resources");
+      const native = path.join(
+        root,
+        "native",
+        "browser-secret",
+        "build",
+        "x64",
+        "t3-browser-secret",
+      );
+      const staged = path.join(
+        root,
+        "apps",
+        "desktop",
+        "prod-resources",
+        "browser-secret",
+        "t3-browser-secret",
+      );
+      const packaged = path.join(resourcesPath, "browser-secret", "t3-browser-secret");
+      for (const filename of [native, staged, packaged]) {
+        yield* fileSystem.makeDirectory(path.dirname(filename), { recursive: true });
+        yield* fileSystem.writeFileString(filename, "helper");
+      }
+      const resolve = (isPackaged: boolean, platform: NodeJS.Platform = "linux") => {
+        const environment = DesktopEnvironment.layer({
+          dirname: path.join(root, "apps", "desktop", "dist-electron"),
+          homeDirectory: root,
+          platform,
+          processArch: "x64",
+          appVersion: "0.0.1",
+          appPath: path.join(resourcesPath, "app.asar"),
+          isPackaged,
+          resourcesPath,
+          runningUnderArm64Translation: false,
+        }).pipe(Layer.provide(DesktopConfig.layerTest({})));
+        return LinuxBrowserSecret.LinuxBrowserSecretPath.pipe(
+          Effect.provide(LinuxBrowserSecret.layer.pipe(Layer.provide(environment))),
+        );
+      };
+
+      assert.equal(yield* resolve(false), native);
+      assert.equal(yield* resolve(true), packaged);
+      yield* fileSystem.remove(native);
+      assert.equal(yield* resolve(false), staged);
+      yield* fileSystem.remove(packaged);
+      assert.isUndefined(yield* resolve(true));
+      assert.isUndefined(yield* resolve(false, "darwin"));
+      assert.isUndefined(yield* resolve(false, "win32"));
+      yield* fileSystem.remove(staged);
+      assert.isUndefined(yield* resolve(false));
+    }).pipe(Effect.scoped),
+  );
+});
```

## apps/desktop/src/preview/BrowserImport/LinuxBrowserSecret.ts

Status: added; +40; −0

```diff
@@ -1,0 +1,40 @@
+import * as Context from "effect/Context";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Layer from "effect/Layer";
+
+import { DesktopEnvironment } from "../../app/DesktopEnvironment.ts";
+
+/** Absolute path to the helper shipped with this desktop instance. */
+export const LinuxBrowserSecretPath = Context.Reference<string | undefined>(
+  "@t3tools/desktop/preview/BrowserImport/LinuxBrowserSecretPath",
+  { defaultValue: () => undefined },
+);
+
+export const layer = Layer.effect(
+  LinuxBrowserSecretPath,
+  Effect.gen(function* () {
+    const environment = yield* DesktopEnvironment;
+    if (environment.platform !== "linux") return undefined;
+    const fileSystem = yield* FileSystem.FileSystem;
+    const relative = environment.path.join("browser-secret", "t3-browser-secret");
+    const candidates = environment.isPackaged
+      ? [environment.path.join(environment.resourcesPath, relative)]
+      : [
+          environment.path.join(
+            environment.rootDir,
+            "native",
+            "browser-secret",
+            "build",
+            environment.processArch,
+            "t3-browser-secret",
+          ),
+          ...environment.resolveResourcePathCandidates(relative),
+        ];
+    for (const candidate of candidates) {
+      if (yield* fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false)))
+        return candidate;
+    }
+    return undefined;
+  }),
+);
```

## apps/desktop/src/preview/BrowserImport/Sources.test.ts

Status: modified; +118; −0

```diff
@@ -28,10 +28,32 @@
   posixLockIsHeld,
   listSourceProfiles,
   sourcePathContext,
+  windowsChromiumCookiesAreHeld,
 } from "./Sources.ts";
 
 const helium = BROWSER_IMPORT_SOURCES.find((source) => source.id === "helium")!;
 
+describe("Linux Chromium secret applications", () => {
+  it("pins the libsecret application attribute for each supported fork", () => {
+    assert.deepEqual(
+      Object.fromEntries(
+        BROWSER_IMPORT_SOURCES.filter((source) => source.platforms.includes("linux")).map(
+          (source) => [source.id, source.linuxSecretApplication],
+        ),
+      ),
+      {
+        chrome: "chrome",
+        edge: "msedge",
+        brave: "brave",
+        vivaldi: "vivaldi",
+        opera: "opera",
+        helium: "chromium",
+        firefox: undefined,
+      },
+    );
+  });
+});
+
 /** A scratch home with the source's user-data directory already created. */
 const withSourceHome = Effect.fnUntraced(function* () {
   const fileSystem = yield* FileSystem.FileSystem;
@@ -83,7 +105,103 @@
     database.close();
   });
 
+describe("Helium on Linux", () => {
+  it.effect("discovers its profiles and checks the user-data lock", () =>
+    run(
+      Effect.gen(function* () {
+        const fileSystem = yield* FileSystem.FileSystem;
+        const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-helium-linux-" });
+        const context = yield* sourcePathContext.pipe(
+          Effect.provideService(HostProcessEnvironment, { HOME: home }),
+          Effect.provideService(HostProcessPlatform, "linux"),
+        );
+        const root = `${home}/.config/net.imput.helium`;
+        yield* fileSystem.makeDirectory(`${root}/Default`, { recursive: true });
+        yield* writeCookieDatabase(`${root}/Default/Cookies`, 3);
+        yield* fileSystem.writeFileString(
+          `${root}/Local State`,
+          '{"profile":{"info_cache":{"Default":{"name":"Personal"}}}}',
+        );
+
+        assert.include(helium.platforms, "linux");
+        assert.isTrue(yield* isSourceInstalled(helium, context));
+        assert.deepEqual(yield* listSourceProfiles(helium, context), [
+          { directory: "Default", name: "Personal", cookieCount: 3 },
+        ]);
+        assert.isFalse(yield* isSourceRunning(helium, context));
+        yield* fileSystem.symlink("foreign-host-4242", `${root}/SingletonLock`);
+        assert.isTrue(yield* isSourceRunning(helium, context));
+      }),
+    ),
+  );
+});
+
+describe("Helium on Windows", () => {
+  it.effect("uses Helium's local app-data profile while other Chromium forks stay disabled", () =>
+    run(
+      Effect.gen(function* () {
+        const context = yield* sourcePathContext.pipe(
+          Effect.provideService(HostProcessEnvironment, {
+            USERPROFILE: "C:\\Users\\browser-user",
+            LOCALAPPDATA: "C:\\Users\\browser-user\\AppData\\Local",
+          }),
+          Effect.provideService(HostProcessPlatform, "win32"),
+        );
+
+        assert.include(helium.platforms, "win32");
+        assert.equal(
+          helium.userDataDirectory(context),
+          context.path.join(
+            "C:\\Users\\browser-user\\AppData\\Local",
+            "imput",
+            "Helium",
+            "User Data",
+          ),
+        );
+        for (const source of BROWSER_IMPORT_SOURCES) {
+          if (source.engine === "chromium" && source.id !== "helium") {
+            assert.notInclude(source.platforms, "win32");
+          }
+        }
+      }),
+    ),
+  );
+});
+
 describe("isSourceRunning", () => {
+  it.effect("uses the held cookie database as Chromium's Windows running signal", () =>
+    run(
+      Effect.gen(function* () {
+        const fileSystem = yield* FileSystem.FileSystem;
+        const home = yield* fileSystem.makeTempDirectoryScoped({
+          prefix: "t3code-helium-windows-lock-",
+        });
+        const context = yield* sourcePathContext.pipe(
+          Effect.provideService(HostProcessEnvironment, {
+            HOME: home,
+            LOCALAPPDATA: home,
+          }),
+          Effect.provideService(HostProcessPlatform, "win32"),
+        );
+        const profile = context.path.join(helium.userDataDirectory(context)!, "Default");
+        const database = context.path.join(profile, "Network", "Cookies");
+        yield* fileSystem.makeDirectory(context.path.join(profile, "Network"), { recursive: true });
+        yield* writeCookieDatabase(database, 1);
+
+        const probed: string[] = [];
+        assert.isTrue(
+          yield* windowsChromiumCookiesAreHeld(helium, context, (path) =>
+            Effect.sync(() => {
+              probed.push(path);
+              return true;
+            }),
+          ),
+        );
+        assert.deepEqual(probed, [database]);
+      }),
+    ),
+  );
+
   it.effect("reads Chromium's dangling SingletonLock symlink as a running browser", () =>
     run(
       Effect.gen(function* () {
```

## apps/desktop/src/preview/BrowserImport/Sources.ts

Status: modified; +60; −12

```diff
@@ -6,11 +6,11 @@
  * keeps them in plain SQLite with no key at all, so it needs no keychain and
  * works the same on every platform.
  *
+ * Each entry pins its own paths and credential-store coordinates rather than
+ * deriving them, because the forks do not agree. macOS uses service/account
+ * pairs, while Linux Chromium uses a custom libsecret schema keyed by an
- * Each entry pins its own paths and keychain coordinates rather than deriving
+ * `application` attribute. The user-data directory also differs per fork and
- * them, because the forks do not agree. Helium uses the keychain service
- * "Helium Storage Key" / account "Helium" where Chrome and its closer
- * relatives use "<Name> Safe Storage" / "<Name>", and the user-data directory
- * differs per fork and per platform.
+ * per platform.
  *
  * @module BrowserImportSources
  */
@@ -58,6 +58,8 @@
   /** Chromium on macOS only: where the OSCrypt key lives in the keychain. */
   readonly keychainService?: string;
   readonly keychainAccount?: string;
+  /** Chromium's `application` attribute in the Linux libsecret schema. */
+  readonly linuxSecretApplication?: string;
 }
 
 const macApplicationSupport = (
@@ -66,10 +68,9 @@
 ) => context.path.join(context.home, "Library", "Application Support", ...segments);
 
 /**
+ * One Chromium fork. The leaves differ per fork; omitting a platform's
+ * segments marks the fork as unavailable there. Most Windows Chromium builds
- * One Chromium fork on macOS and Linux. The leaves differ per fork; omitting a
- * platform's segments marks the fork as unavailable there. Windows is left out
- * on purpose: since Chrome 127 those cookies are encrypted to the browser's own
- * identity (App-Bound Encryption), so no other process can read them.
+ * use App-Bound Encryption, but forks can retain the older DPAPI-backed store.
  */
 const chromiumSource = (input: {
   readonly id: BrowserImportSourceId;
@@ -78,6 +79,8 @@
   readonly keychainAccount: string;
   readonly macSegments: ReadonlyArray<string>;
   readonly linuxSegments?: ReadonlyArray<string>;
+  readonly linuxSecretApplication?: string;
+  readonly windowsSegments?: ReadonlyArray<string>;
 }): BrowserImportSourceDefinition => ({
   id: input.id,
   name: input.name,
@@ -85,11 +88,20 @@
   platforms: [
     "darwin" as NodeJS.Platform,
     ...(input.linuxSegments ? ["linux" as NodeJS.Platform] : []),
+    ...(input.windowsSegments ? ["win32" as NodeJS.Platform] : []),
   ],
   keychainService: input.keychainService,
   keychainAccount: input.keychainAccount,
+  ...(input.linuxSecretApplication === undefined
+    ? {}
+    : { linuxSecretApplication: input.linuxSecretApplication }),
   userDataDirectory: (context) => {
     if (context.platform === "darwin") return macApplicationSupport(context, ...input.macSegments);
+    if (context.platform === "win32") {
+      return input.windowsSegments && context.localAppData
+        ? context.path.join(context.localAppData, ...input.windowsSegments)
+        : undefined;
+    }
     return input.linuxSegments
       ? context.path.join(context.home, ".config", ...input.linuxSegments)
       : undefined;
@@ -104,6 +116,7 @@
     keychainAccount: "Chrome",
     macSegments: ["Google", "Chrome"],
     linuxSegments: ["google-chrome"],
+    linuxSecretApplication: "chrome",
   }),
   chromiumSource({
     id: "edge",
@@ -112,6 +125,7 @@
     keychainAccount: "Microsoft Edge",
     macSegments: ["Microsoft Edge"],
     linuxSegments: ["microsoft-edge"],
+    linuxSecretApplication: "msedge",
   }),
   chromiumSource({
     id: "brave",
@@ -120,6 +134,7 @@
     keychainAccount: "Brave",
     macSegments: ["BraveSoftware", "Brave-Browser"],
     linuxSegments: ["BraveSoftware", "Brave-Browser"],
+    linuxSecretApplication: "brave",
   }),
   chromiumSource({
     id: "vivaldi",
@@ -128,6 +143,7 @@
     keychainAccount: "Vivaldi",
     macSegments: ["Vivaldi"],
     linuxSegments: ["vivaldi"],
+    linuxSecretApplication: "vivaldi",
   }),
   chromiumSource({
     id: "opera",
@@ -136,8 +152,9 @@
     keychainAccount: "Opera",
     macSegments: ["com.operasoftware.Opera"],
     linuxSegments: ["opera"],
+    linuxSecretApplication: "opera",
   }),
-  // Arc and Helium ship macOS-only builds.
+  // Arc has no Linux build.
   chromiumSource({
     id: "arc",
     name: "Arc",
@@ -151,6 +168,10 @@
     keychainService: "Helium Storage Key",
     keychainAccount: "Helium",
     macSegments: ["net.imput.helium"],
+    linuxSegments: ["net.imput.helium"],
+    windowsSegments: ["imput", "Helium", "User Data"],
+    // Helium retains Chromium's libsecret application name on Linux.
+    linuxSecretApplication: "chromium",
   }),
   {
     id: "firefox",
@@ -544,7 +565,30 @@
   );
 });
 
+type WindowsLockProbe = (path: string) => Effect.Effect<boolean, never, FileSystem.FileSystem>;
+
 /**
+ * Chromium does not create its POSIX `SingletonLock` symlink on Windows. The
+ * live cookie database is opened without sharing instead, so probing each
+ * profile's current jar is the reliable running signal there.
+ */
+export const windowsChromiumCookiesAreHeld = Effect.fnUntraced(function* (
+  definition: BrowserImportSourceDefinition,
+  context: BrowserImportPathContext,
+  lockIsHeld: WindowsLockProbe = windowsLockIsHeld,
+) {
+  const profiles = yield* listSourceProfiles(definition, context);
+  const held = yield* Effect.forEach(profiles, (profile) =>
+    resolveCookieDatabase(definition, context, profile.directory).pipe(
+      Effect.flatMap((database) =>
+        database === undefined ? Effect.succeed(false) : lockIsHeld(database),
+      ),
+    ),
+  );
+  return held.some(Boolean);
+});
+
+/**
  * Whether a Firefox `lock` symlink's `<ip>:[+]<pid>` target still names a
  * live owner. Firefox writes this symlink beside the profile while it runs and
  * unlinks it on a clean exit, so a dangling one is either live or a crash.
@@ -694,9 +738,13 @@
   const fileSystem = yield* FileSystem.FileSystem;
   const root = definition.userDataDirectory(context);
   if (root === undefined) return false;
+  // Probe the source's own lock state rather than scanning the process table.
+  // Chromium exposes that through the cookie jar on Windows and through its
-  // Both engines leave a lock file for as long as an instance holds a profile,
+  // user-data SingletonLock on POSIX.
-  // which is far cheaper and more targeted than scanning the process table.
   if (definition.engine !== "firefox") {
+    if (context.platform === "win32") {
+      return yield* windowsChromiumCookiesAreHeld(definition, context);
+    }
     const currentHost = yield* HostProcessHostname;
     const lock = context.path.join(root, "SingletonLock");
     return yield* fileSystem.readLink(lock).pipe(
```

## apps/desktop/vite.config.ts

Status: modified; +5; −3

```diff
@@ -14,18 +14,20 @@
   run: {
     tasks: {
       build: {
-        command: "node scripts/build-preview-annotation-css.mjs && vp pack",
+        command:
+          "node scripts/build-browser-secret.mjs && node scripts/build-preview-annotation-css.mjs && vp pack",
         dependsOn: ["t3#build"],
         cache: false,
       },
       dev: {
         command:
-          "node scripts/build-preview-annotation-css.mjs && cross-env T3CODE_DESKTOP_DEV=1 vp pack --watch",
+          "node scripts/build-browser-secret.mjs && node scripts/build-preview-annotation-css.mjs && cross-env T3CODE_DESKTOP_DEV=1 vp pack --watch",
         dependsOn: ["t3#build"],
         cache: false,
       },
       "dev:bundle": {
-        command: "node scripts/build-preview-annotation-css.mjs && vp pack --watch",
+        command:
+          "node scripts/build-browser-secret.mjs && node scripts/build-preview-annotation-css.mjs && vp pack --watch",
         cache: false,
       },
       "dev:electron": {
```

## apps/web/src/components/settings/IntegrationsSettings.logic.test.ts

Status: modified; +1; −0

```diff
@@ -87,6 +87,7 @@
   it("recovers the reason token from the flattened message", () => {
     expect(importFailureReason(failure("browserRunning"))).toBe("browserRunning");
     expect(importFailureReason(failure("readFailed"))).toBe("readFailed");
+    expect(importFailureReason(failure("keychainUnavailable"))).toBe("keychainUnavailable");
     // A settings write that fails after the cookies landed is its own case,
     // not a read failure over a database that was in fact read.
     expect(importFailureReason(failure("profileNotSaved"))).toBe("profileNotSaved");
```

## apps/web/src/components/settings/browserImportWizard.logic.test.ts

Status: modified; +1; −0

```diff
@@ -155,6 +155,7 @@
 describe("isRetryableReason", () => {
   it("offers a retry for failures a second attempt can clear", () => {
     expect(isRetryableReason("needsKeychainApproval")).toBe(true);
+    expect(isRetryableReason("keychainUnavailable")).toBe(true);
     expect(isRetryableReason("readFailed")).toBe(true);
   });
 
```

## apps/web/src/components/settings/browserImportWizard.logic.ts

Status: modified; +1; −0

```diff
@@ -133,6 +133,7 @@
   switch (reason) {
     case "needsKeychainApproval":
     case "keychainItemMissing":
+    case "keychainUnavailable":
     case "readFailed":
     case "sessionUnavailable":
     case "profileNotSaved":
```

[Next page](https://git.cafe/pingdotgg/t3code/pull/7261/diffs?after=ImFwcHMvd2ViL3NyYy9jb21wb25lbnRzL3NldHRpbmdzL2Jyb3dzZXJJbXBvcnRXaXphcmQubG9naWMudHMi&expectedVersion=50&format=markdown)
