feat(desktop): resolve Chromium cookie keys on Linux#7261
32 unresolved threads · Jump to file
- apps/desktop/src/preview/BrowserImport/Sources.ts:91 · ### Windows Helium running state undetected **Medium Severity** <!-- DESCRIPTION START --> Enablin
- apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts:232 · <!-- MURMUR_IGNORE --> 🟠 **High** `BrowserImport/ChromiumKeys.ts:232` Linux imports silently omit
- apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts:183–184 · The `| Buffer` arm plus the `Buffer.isBuffer` normalization is a compatibility shim for the pre-`Chr
- apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts:140 · ### DPAPI ignores PowerShell exit status **High Severity** <!-- DESCRIPTION START --> `unprotectWi
- apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts:269–272 · <!-- MURMUR_IGNORE --> `BrowserImport/ChromiumKeys.ts:272` `unprotectWithDpapi` fails with `Chromiu
- apps/desktop/src/preview/BrowserImport/BrowserImport.ts:88 · 🟡 **Medium** `BrowserImport/BrowserImport.ts:88` Linux imports now skip legacy Chromium cookie row
- apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts:170 · 🟠 **High** `BrowserImport/ChromiumCookies.ts:170` Legacy Linux `v10`/`v11` cookies encrypted with
- scripts/build-desktop-artifact.ts:2224 · 🟠 **High** `scripts/build-desktop-artifact.ts:2224` Linux builds from macOS or Windows hosts succe
- apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts:148–153 · <!-- MURMUR_IGNORE --> `BrowserImport/ChromiumKeys.ts:153` These branches manufacture an `Error` pu
- apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts:252 · ### App-bound import looks successful **High Severity** <!-- DESCRIPTION START --> `appBoundEncryp
- apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts:39 · <!-- MURMUR_IGNORE --> `BrowserImport/ChromiumKeys.ts:39` `readFailed` is never constructed in this
- apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts:237 · ### Decrypt skips never counted **Medium Severity** <!-- DESCRIPTION START --> Records skipped for
- native/browser-secret/main.c:10–10 · 🟠 **High** `browser-secret/main.c:10` Pre-existing Chromium v11 keys stored without a schema-name
- apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts:158–159 · <!-- MURMUR_IGNORE --> 🟡 **Medium** `BrowserImport/ChromiumKeys.ts:158` `readLocalState` turns mis
- apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts:159 · ### Wrong Windows failure copy **Medium Severity** <!-- DESCRIPTION START --> DPAPI / PowerShell f
- apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts:238 · <!-- MURMUR_IGNORE --> 🟡 **Medium** `BrowserImport/ChromiumCookies.ts:238` `readChromiumCookies` s
- apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts:206 · ### Linux keyring lookup uses wrong attributes **High Severity** <!-- DESCRIPTION START --> Linux
- apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts:187 · <!-- MURMUR_IGNORE --> `BrowserImport/ChromiumKeys.ts:187` `readFailed` is raised here without the
- apps/desktop/src/main.ts:156–156 · 🟠 **High** `src/main.ts:156` Linux cookie imports always report `keychainUnavailable` when `readLi
- scripts/build-desktop-artifact.ts:2230–2230 · 🟠 **High** `scripts/build-desktop-artifact.ts:2230` Linux builds requested with `arch: "universal"
- apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts:156–159 · <!-- MURMUR_IGNORE --> `BrowserImport/ChromiumKeys.ts:159` `readLocalState` swallows both the file-
- apps/desktop/src/main.ts:156 · ### Helper path dropped at runtime **High Severity** <!-- DESCRIPTION START --> `Layer.provide(Lin
- apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts:143–143 · 🟡 **Medium** `BrowserImport/ChromiumKeys.ts:143` `readLinuxSecret` can select a non-Chromium Secre
- apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts:153–155 · <!-- MURMUR_IGNORE --> 🟡 **Medium** `BrowserImport/ChromiumCookies.ts:153` `readChromiumCookies` r
- apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts:152 · ### PowerShell may hang on stdin **Medium Severity** <!-- DESCRIPTION START --> `ChildProcess.make
- apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts:139–140 · <!-- MURMUR_IGNORE --> `BrowserImport/ChromiumKeys.ts:140` The PowerShell exit code is never inspec
- apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts:115 · 🟡 **Medium** `BrowserImport/ChromiumKeys.ts:115` A localized keyring denial is classified as `read
- apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts:116–118 · <!-- MURMUR_IGNORE --> 🟡 **Medium** `BrowserImport/ChromiumCookies.ts:116` A record with a domain
- apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts:169–174 · This prefix dispatch is the behavior change this PR is about (a Linux database mixing `v10`/`v11`, w
- apps/desktop/src/preview/BrowserImport/ChromiumKeys.test.ts:1–2 · This new suppression directive has no adjacent explanation, unlike every sibling in this directory (
- apps/desktop/undefined/Profiles/gx7x7fqx.default-release/cookies.sqlite:1 · ### Accidental profile fixture committed **Low Severity** <!-- DESCRIPTION START --> A dummy `cook
- apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts:237 · <!-- MURMUR_IGNORE --> 🟠 **High** `BrowserImport/ChromiumCookies.ts:237` Linux imports blank out p
apps/desktop/src/preview/BrowserImport/Sources.ts · Earlier revision a771eb69 · right 91
Original diff
@@ -78,18 +79,29 @@ const chromiumSource = (input: {
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,
engine: "chromium",
platforms: [
"darwin" as NodeJS.Platform,
...(input.linuxSegments ? ["linux" as NodeJS.Platform] : []),
+ ...(input.windowsSegments ? ["win32" as NodeJS.Platform] : []),Windows Helium running state undetected
Medium Severity
Enabling Helium on Windows still uses isSourceRunning's POSIX SingletonLock symlink check. Windows Chromium holds lockfile instead, so a running Helium looks idle. The quit-browser prompt never appears and import can VACUUM INTO a live jar, failing as readFailed or snapshotting mid-write cookies.
Additional Locations (2)
Reviewed by Cursor Bugbot for commit a771eb69ba4c518dcffaf64da29e3429f8fa1702. Configure here.
apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts · Earlier revision 0ad36895 · right 232
Original diff
@@ -0,0 +1,263 @@
+// @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** wraps an AES-256-GCM key in DPAPI inside `Local State`. Since
+ * Chrome 127 it *also* keeps an app-bound key that only the browser binary
+ * can unwrap, and cookies written under it carry a `v20` prefix. Those are
+ * not readable by anything else, by design, and this module does not try.
+ *
+ * @module ChromiumKeys
+ */
+import * as Keyring from "@napi-rs/keyring";
+import * as NodeCrypto from "node:crypto";
+
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Schema from "effect/Schema";
+import * as Stream from "effect/Stream";
+import * as ChildProcess from "effect/unstable/process/ChildProcess";
+import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
+
+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",
+ "appBoundEncryption",
+ "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 Linux. */
+ readonly cbcV10?: Buffer;
+ /** AES-128-CBC, Linux keyring-derived. */
+ readonly cbcV11?: Buffer;
+ /** AES-256-GCM on Windows. */
+ readonly gcmV10?: Buffer;
+}
+
+const derive = (passphrase: string, iterations: number) =>
+ NodeCrypto.pbkdf2Sync(passphrase, KEY_SALT, iterations, KEY_LENGTH, "sha1");
+
+/**
+ * Reads the OSCrypt key from the macOS 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;
+});
+
+/**
+ * Unwraps the Windows DPAPI-protected key via PowerShell.
+ *
+ * Shelling out is fine here in a way it is not on macOS: DPAPI is transparent
+ * to any process running as the user, so there is no consent prompt to
+ * misattribute and no ACL entry to write against the wrong binary.
+ */
+const unprotectWithDpapi = Effect.fn("ChromiumKeys.unprotectWithDpapi")(function* (
+ protectedKey: Buffer,
+) {
+ const script = [
+ "Add-Type -AssemblyName System.Security;",
+ `$b=[Convert]::FromBase64String('${protectedKey.toString("base64")}');`,
+ "$u=[System.Security.Cryptography.ProtectedData]::Unprotect($b,$null,",
+ "[System.Security.Cryptography.DataProtectionScope]::CurrentUser);",
+ "[Convert]::ToBase64String($u)",
+ ].join("");
+
+ const process = yield* ChildProcess.make("powershell.exe", [
+ "-NoProfile",
+ "-NonInteractive",
+ "-Command",
+ script,
+ ]);
+ const stdout = yield* process.stdout.pipe(Stream.decodeText(), Stream.mkString);
+ const exitCode = yield* process.exitCode;
+
+ // A failed `Unprotect` still exits, printing nothing. Without these checks
+ // the empty stdout decodes to a zero-length Buffer, which is truthy, so
+ // `gcmV10` would be populated with an unusable key: every record then fails
+ // to decrypt and the import reports success having written nothing.
+ if (exitCode !== 0) {
+ return yield* new ChromiumKeyError({
+ reason: "keychainItemMissing",
+ cause: new Error(`powershell.exe exited with ${exitCode}`),
+ });
+ }
+ const unwrapped = Buffer.from(stdout.trim(), "base64");
+ if (unwrapped.length === 0) {
+ return yield* new ChromiumKeyError({
+ reason: "keychainItemMissing",
+ cause: new Error("powershell.exe returned no key material"),
+ });
+ }
+ return unwrapped;
+});
+
+/** The slice of `Local State` that carries the wrapped keys. */
+const LocalState = Schema.Struct({
+ os_crypt: Schema.optional(
+ Schema.Struct({
+ encrypted_key: Schema.optional(Schema.String),
+ app_bound_encrypted_key: Schema.optional(Schema.String),
+ }),
+ ),
+});
+const decodeLocalState = Schema.decodeUnknownEffect(Schema.fromJsonString(LocalState));
+
+/**
+ * Reads the metadata file that carries the wrapped Windows keys.
+ *
+ * Failures are reported rather than flattened into an empty document: a
+ * missing, unreadable or corrupt `Local State` is a different problem from a
+ * browser that has no key yet, and telling the user the latter sends them
+ * looking in the wrong place.
+ */
+const readLocalState = Effect.fn("ChromiumKeys.readLocalState")(function* (localStatePath: string) {
+ const fileSystem = yield* FileSystem.FileSystem;
+ return yield* fileSystem.readFileString(localStatePath).pipe(
+ Effect.flatMap(decodeLocalState),
+ Effect.mapError((cause) => new ChromiumKeyError({ reason: "readFailed", cause })),
+ );
+});
+
+/**
+ * Windows stores the key base64-encoded with a literal `DPAPI` marker in
+ * front, which has to come off before unwrapping.
+ */
+const DPAPI_MARKER = "DPAPI";
+
+export function stripDpapiMarker(encodedKey: string): Buffer {
+ const raw = Buffer.from(encodedKey, "base64");
+ return raw.subarray(0, DPAPI_MARKER.length).toString("latin1") === DPAPI_MARKER
+ ? raw.subarray(DPAPI_MARKER.length)
+ : raw;
+}
+
+export interface ChromiumKeyRequest {
+ readonly platform: NodeJS.Platform;
+ readonly keychainService: string | undefined;
+ readonly keychainAccount: string | undefined;
+ readonly localStatePath: string;
+}
+
+export const resolveChromiumKeys = Effect.fn("ChromiumKeys.resolveChromiumKeys")(function* (
+ request: ChromiumKeyRequest,
+): Effect.fn.Return<
+ ChromiumKeyMaterial,
+ ChromiumKeyError,
+ FileSystem.FileSystem | 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`. Failing to
+ // reach the keyring is not fatal — it just leaves those records skipped.
+ const keyringSecret =
+ request.keychainService && request.keychainAccount
+ ? yield* readKeychainSecret(request.keychainService, request.keychainAccount).pipe(🟠 High BrowserImport/ChromiumKeys.ts:232
Linux imports silently omit every v11 cookie when Chromium stores its OSCrypt secret in Secret Service, making the import incomplete. readKeychainSecret uses Keyring.Entry(service, account) with the macOS-style coordinates, but Linux Chromium stores the secret under its custom Secret Service schema (for example, chrome_libsecret_os_crypt_password_v2 with an application attribute); the lookup fails and Effect.orElseSucceed suppresses the error. Use the Linux-specific Secret Service lookup and derive cbcV11 from that result.
✅ Resolved in c188be9758bab40e0a9d93176c917030b2936b4b
Fixed in the updated #7261. Linux v11 key lookup uses Secret Service via secret-tool lookup application with per-browser application attributes, not macOS service/account labels. Missing backend/key can retain partial v10 support; explicit denial or cancellation propagates. Subprocess and whitespace-preservation fixtures pass.
apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts · Earlier revision c188be97 · right 183–184
Original diff
@@ -116,136 +118,106 @@ const toUnixSeconds = (webkitSeconds: number): number | undefined => {
};
/**
- * Reads the OSCrypt key from the login keychain.
- *
- * 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".
+ * Chromium >= 127 prefixes the plaintext with SHA-256 of the host key, binding
+ * a cookie to its domain. Strip it when present.
*/
-const readMacKeychainPassword = Effect.fn("ChromiumCookies.readMacKeychainPassword")(function* (
- service: string,
- account: string,
- cookieDatabasePath: string,
-) {
- 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 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 decryptValue = (
- encrypted: Uint8Array,
+const decryptCbc = (
+ payload: Buffer,
key: Buffer,
domain: string,
schemaVersion: number,
- platform: NodeJS.Platform,
): string | null => {
- const buffer = Buffer.from(encrypted);
- 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;
- }
- plaintext = plaintext.subarray(32);
- }
- return plaintext.toString("utf8");
+ const decipher = NodeCrypto.createDecipheriv("aes-128-cbc", key, AES_CBC_IV);
+ decipher.setAutoPadding(true);
+ const plaintext = Buffer.concat([decipher.update(payload), decipher.final()]);
+ return stripDomainBinding(plaintext, domain, schemaVersion)?.toString("utf8") ?? null;
} 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);
+
+ if (prefix === "v10") {
+ return keys.cbcV10 ? decryptCbc(payload, keys.cbcV10, domain, schemaVersion) : null;
+ }
+ if (prefix === "v11") {
+ return keys.cbcV11 ? decryptCbc(payload, keys.cbcV11, domain, schemaVersion) : null;
+ }
+ if (platform === "darwin") {
+ 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) {
- const rows = yield* Effect.gen(function* () {
+ function* (snapshotPath: string, keys: ChromiumKeyMaterial | Buffer, platform: NodeJS.Platform) {
+ const keyMaterial = Buffer.isBuffer(keys) ? { cbcV10: keys } : keys;apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts · right 183–184
The | Buffer arm plus the Buffer.isBuffer normalization is a compatibility shim for the pre-ChromiumKeyMaterial signature: the only remaining caller that passes a bare Buffer is ChromiumCookies.test.ts (lines 84, 126, 162, 182, 217-218, 271-272) — production (readChromiumCookies, line 291) always passes ChromiumKeyMaterial. Consider narrowing the parameter to keys: ChromiumKeyMaterial, dropping the keyMaterial normalization, and updating those test call sites to { cbcV10: key }, so the runtime type test disappears and the key shape has one representation.
Posted via Macroscope — Effect Service Conventions
apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts · Earlier revision 6a2afea3 · right 140
Original diff
@@ -0,0 +1,234 @@
+// @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** wraps an AES-256-GCM key in DPAPI inside `Local State`. Since
+ * Chrome 127 it *also* keeps an app-bound key that only the browser binary
+ * can unwrap, and cookies written under it carry a `v20` prefix. Those are
+ * not readable by anything else, by design, and this module does not try.
+ *
+ * @module ChromiumKeys
+ */
+import * as Keyring from "@napi-rs/keyring";
+import * as NodeCrypto from "node:crypto";
+
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Schema from "effect/Schema";
+import * as Stream from "effect/Stream";
+import * as ChildProcess from "effect/unstable/process/ChildProcess";
+import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
+
+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",
+ "appBoundEncryption",
+ "unsupportedPlatform",
+]);
+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 Linux. */
+ readonly cbcV10?: Buffer;
+ /** AES-128-CBC, Linux keyring-derived. */
+ readonly cbcV11?: Buffer;
+ /** AES-256-GCM on Windows. */
+ readonly gcmV10?: Buffer;
+}
+
+const derive = (passphrase: string, iterations: number) =>
+ NodeCrypto.pbkdf2Sync(passphrase, KEY_SALT, iterations, KEY_LENGTH, "sha1");
+
+/**
+ * Reads the OSCrypt key from the macOS 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;
+});
+
+/**
+ * Unwraps the Windows DPAPI-protected key via PowerShell.
+ *
+ * Shelling out is fine here in a way it is not on macOS: DPAPI is transparent
+ * to any process running as the user, so there is no consent prompt to
+ * misattribute and no ACL entry to write against the wrong binary.
+ */
+const unprotectWithDpapi = Effect.fn("ChromiumKeys.unprotectWithDpapi")(function* (
+ protectedKey: Buffer,
+) {
+ const script = [
+ "Add-Type -AssemblyName System.Security;",
+ `$b=[Convert]::FromBase64String('${protectedKey.toString("base64")}');`,
+ "$u=[System.Security.Cryptography.ProtectedData]::Unprotect($b,$null,",
+ "[System.Security.Cryptography.DataProtectionScope]::CurrentUser);",
+ "[Convert]::ToBase64String($u)",
+ ].join("");
+
+ const process = yield* ChildProcess.make("powershell.exe", [
+ "-NoProfile",
+ "-NonInteractive",
+ "-Command",
+ script,
+ ]);
+ const stdout = yield* process.stdout.pipe(Stream.decodeText(), Stream.mkString);
+ return Buffer.from(stdout.trim(), "base64");DPAPI ignores PowerShell exit status
High Severity
unprotectWithDpapi reads PowerShell stdout and never checks exitCode. Effect does not treat a non-zero exit as failure, so a failed ProtectedData.Unprotect still yields an empty/garbage buffer as gcmV10. Every cookie then decrypts to null and the import returns success with zero cookies instead of a ChromiumKeyError.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 6a2afea3ed6cc28722026fb50b2134b11a590dad. Configure here.
apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts · Earlier revision 1be05caf · right 269–272
Original diff
@@ -0,0 +1,278 @@
+// @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** wraps an AES-256-GCM key in DPAPI inside `Local State`. Since
+ * Chrome 127 it *also* keeps an app-bound key that only the browser binary
+ * can unwrap, and cookies written under it carry a `v20` prefix. Those are
+ * not readable by anything else, by design, and this module does not try.
+ *
+ * @module ChromiumKeys
+ */
+import * as Keyring from "@napi-rs/keyring";
+import * as NodeCrypto from "node:crypto";
+
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Schema from "effect/Schema";
+import * as Stream from "effect/Stream";
+import * as ChildProcess from "effect/unstable/process/ChildProcess";
+import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
+
+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",
+ "appBoundEncryption",
+ "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,
+ /**
+ * The metadata file the failure is about, when it is about one. Without it
+ * an unreadable `Local State` is reported against the cookie database the
+ * caller names instead.
+ */
+ localStatePath: Schema.optional(Schema.String),
+ /** Exit status of the helper that was asked to unwrap the key. */
+ exitCode: Schema.optional(Schema.Number),
+ /** Kept for the log; never surfaced to the user. */
+ cause: Schema.optional(Schema.Defect()),
+ },
+) {
+ override get message(): string {
+ const where = this.localStatePath === undefined ? "" : ` at ${this.localStatePath}`;
+ const status = this.exitCode === undefined ? "" : ` (helper exited with ${this.exitCode})`;
+ return `Could not obtain the Chromium cookie key${where}: ${this.reason}${status}.`;
+ }
+}
+
+/**
+ * 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 Linux. */
+ readonly cbcV10?: Buffer;
+ /** AES-128-CBC, Linux keyring-derived. */
+ readonly cbcV11?: Buffer;
+ /** AES-256-GCM on Windows. */
+ readonly gcmV10?: Buffer;
+}
+
+const isChromiumKeyError = Schema.is(ChromiumKeyError);
+
+const derive = (passphrase: string, iterations: number) =>
+ NodeCrypto.pbkdf2Sync(passphrase, KEY_SALT, iterations, KEY_LENGTH, "sha1");
+
+/**
+ * Reads the OSCrypt key from the macOS 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;
+});
+
+/**
+ * Unwraps the Windows DPAPI-protected key via PowerShell.
+ *
+ * Shelling out is fine here in a way it is not on macOS: DPAPI is transparent
+ * to any process running as the user, so there is no consent prompt to
+ * misattribute and no ACL entry to write against the wrong binary.
+ */
+const unprotectWithDpapi = Effect.fn("ChromiumKeys.unprotectWithDpapi")(function* (
+ protectedKey: Buffer,
+) {
+ const script = [
+ "Add-Type -AssemblyName System.Security;",
+ `$b=[Convert]::FromBase64String('${protectedKey.toString("base64")}');`,
+ "$u=[System.Security.Cryptography.ProtectedData]::Unprotect($b,$null,",
+ "[System.Security.Cryptography.DataProtectionScope]::CurrentUser);",
+ "[Convert]::ToBase64String($u)",
+ ].join("");
+
+ const process = yield* ChildProcess.make("powershell.exe", [
+ "-NoProfile",
+ "-NonInteractive",
+ "-Command",
+ script,
+ ]);
+ const stdout = yield* process.stdout.pipe(Stream.decodeText(), Stream.mkString);
+ const exitCode = yield* process.exitCode;
+
+ // A failed `Unprotect` still exits, printing nothing. Without these checks
+ // the empty stdout decodes to a zero-length Buffer, which is truthy, so
+ // `gcmV10` would be populated with an unusable key: every record then fails
+ // to decrypt and the import reports success having written nothing.
+ if (exitCode !== 0) {
+ return yield* new ChromiumKeyError({ reason: "readFailed", exitCode });
+ }
+ const unwrapped = Buffer.from(stdout.trim(), "base64");
+ if (unwrapped.length === 0) {
+ return yield* new ChromiumKeyError({ reason: "readFailed", exitCode });
+ }
+ return unwrapped;
+});
+
+/** The slice of `Local State` that carries the wrapped keys. */
+const LocalState = Schema.Struct({
+ os_crypt: Schema.optional(
+ Schema.Struct({
+ encrypted_key: Schema.optional(Schema.String),
+ app_bound_encrypted_key: Schema.optional(Schema.String),
+ }),
+ ),
+});
+const decodeLocalState = Schema.decodeUnknownEffect(Schema.fromJsonString(LocalState));
+
+/**
+ * Reads the metadata file that carries the wrapped Windows keys.
+ *
+ * Failures are reported rather than flattened into an empty document: a
+ * missing, unreadable or corrupt `Local State` is a different problem from a
+ * browser that has no key yet, and telling the user the latter sends them
+ * looking in the wrong place.
+ */
+const readLocalState = Effect.fn("ChromiumKeys.readLocalState")(function* (localStatePath: string) {
+ const fileSystem = yield* FileSystem.FileSystem;
+ return yield* fileSystem.readFileString(localStatePath).pipe(
+ Effect.flatMap(decodeLocalState),
+ Effect.mapError(
+ (cause) => new ChromiumKeyError({ reason: "readFailed", localStatePath, cause }),
+ ),
+ );
+});
+
+/**
+ * Windows stores the key base64-encoded with a literal `DPAPI` marker in
+ * front, which has to come off before unwrapping.
+ */
+const DPAPI_MARKER = "DPAPI";
+
+export function stripDpapiMarker(encodedKey: string): Buffer {
+ const raw = Buffer.from(encodedKey, "base64");
+ return raw.subarray(0, DPAPI_MARKER.length).toString("latin1") === DPAPI_MARKER
+ ? raw.subarray(DPAPI_MARKER.length)
+ : raw;
+}
+
+export interface ChromiumKeyRequest {
+ readonly platform: NodeJS.Platform;
+ readonly keychainService: string | undefined;
+ readonly keychainAccount: string | undefined;
+ readonly localStatePath: string;
+}
+
+export const resolveChromiumKeys = Effect.fn("ChromiumKeys.resolveChromiumKeys")(function* (
+ request: ChromiumKeyRequest,
+): Effect.fn.Return<
+ ChromiumKeyMaterial,
+ ChromiumKeyError,
+ FileSystem.FileSystem | 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`. Failing to
+ // reach the keyring is not fatal — it just leaves those records skipped.
+ const keyringSecret =
+ request.keychainService && request.keychainAccount
+ ? yield* readKeychainSecret(request.keychainService, request.keychainAccount).pipe(
+ // No Secret Service, locked keyring, or a differently-keyed entry.
+ Effect.orElseSucceed(() => undefined),
+ )
+ : undefined;
+ return {
+ cbcV10: derive(LINUX_FALLBACK_PASSPHRASE, LINUX_KEY_ITERATIONS),
+ ...(keyringSecret ? { cbcV11: derive(keyringSecret, LINUX_KEY_ITERATIONS) } : {}),
+ };
+ }
+
+ if (request.platform === "win32") {
+ const localState = yield* readLocalState(request.localStatePath);
+ const encodedKey = localState.os_crypt?.encrypted_key;
+ if (!encodedKey) {
+ // An app-bound key with no legacy key means every record is `v20`.
+ return yield* new ChromiumKeyError({
+ reason: localState.os_crypt?.app_bound_encrypted_key
+ ? "appBoundEncryption"
+ : "keychainItemMissing",
+ });
+ }
+ const unwrapped = yield* unprotectWithDpapi(stripDpapiMarker(encodedKey)).pipe(
+ // Scoped here so the PowerShell process is reaped before we return.
+ Effect.scoped,
+ // A `ChromiumKeyError` already says what went wrong and passes through.
+ // Only a spawn failure — no `powershell.exe`, or one that cannot be
+ // launched — arrives as something else, and that is a read failure, not
+ // a browser that has no key.
+ Effect.catchIf(
+ (error) => !isChromiumKeyError(error),
+ (cause) => new ChromiumKeyError({ reason: "readFailed", cause }),
+ ),apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts · right 269–272
BrowserImport/ChromiumKeys.ts:272
unprotectWithDpapi fails with ChromiumKeyError | PlatformError — both statically known tags — so this is a known-tag recovery expressed as a negated schema predicate. Effect.catchTags on the single tag being wrapped says the same thing directly, lets the domain error pass through by construction, and makes the module-private isChromiumKeyError (line 86) unnecessary, so it can be deleted along with this change.
| 1 | const unwrapped = yield* unprotectWithDpapi(stripDpapiMarker(encodedKey)).pipe( |
| 2 | - Effect.catchIf( |
| 3 | - (error) => !isChromiumKeyError(error), |
| 4 | - (cause) => new ChromiumKeyError({ reason: "readFailed", cause }), |
| 5 | - ), |
| 6 | + Effect.catchTags({ |
| 7 | + PlatformError: (cause) => new ChromiumKeyError({ reason: "readFailed", cause }), |
| 8 | + }), |
| 9 | ); |
No longer relevant as of 7f41045e6ac1f2ef3af6a0f6ef1b824e0a553487
apps/desktop/src/preview/BrowserImport/BrowserImport.ts · Earlier revision e9e173cf · right 88
Original diff
@@ -85,11 +86,6 @@ const unavailableReason = Effect.fn("BrowserImport.unavailableReason")(function*
context: BrowserImportPathContext,
): Effect.fn.Return<BrowserImportUnavailableReason | undefined, never, FileSystem.FileSystem> {
if (!definition.platforms.includes(context.platform)) return "unsupportedPlatform";🟡 Medium BrowserImport/BrowserImport.ts:88
Linux imports now skip legacy Chromium cookie rows as undecryptable, so otherwise readable cookies are omitted. decryptChromiumValue returns null for non-v10/v11 blobs, but Linux Chromium treats blobs without either prefix as legacy cleartext; add that cleartext fallback before counting the cookie as undecryptable.
Also found in 1 other location(s)
apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts:190
On Linux,
decryptChromiumValuereturnsnullfor every non-v10/v11nonempty blob. Chromium's LinuxOSCrypt::DecryptStringtreats a blob without either prefix as legacy cleartext, so importing a profile with such rows now skips otherwise readable cookies and counts them as undecryptable. This is reachable because the change enables Linux imports.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
| 1 | In file @apps/desktop/src/preview/BrowserImport/BrowserImport.ts around line 88: |
| 2 | |
| 3 | Linux imports now skip legacy Chromium cookie rows as undecryptable, so otherwise readable cookies are omitted. `decryptChromiumValue` returns `null` for non-`v10`/`v11` blobs, but Linux Chromium treats blobs without either prefix as legacy cleartext; add that cleartext fallback before counting the cookie as undecryptable. |
| 4 | |
| 5 | Also found in 1 other location(s): |
| 6 | - apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts:190 -- On Linux, `decryptChromiumValue` returns `null` for every non-`v10`/`v11` nonempty blob. Chromium's Linux `OSCrypt::DecryptString` treats a blob without either prefix as legacy cleartext, so importing a profile with such rows now skips otherwise readable cookies and counts them as undecryptable. This is reachable because the change enables Linux imports. |
Fixed in ca1cd02480. Verified against Chromium's os_crypt_linux.cc — same as os_crypt_mac.mm, an unprefixed blob is returned as-is ("old data saved as clear text"). The fallback now applies on Linux too; Windows stays excluded since its app-bound blobs also lack the prefix. Test updated to assert Linux reads it.
Confirmed: Linux now imports unprefixed legacy cleartext blobs, while Windows remains excluded. The original concern is resolved.
apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts · Earlier revision 506b87e3 · right 170
Original diff
@@ -116,136 +118,105 @@ const toUnixSeconds = (webkitSeconds: number): number | undefined => {
};
/**
- * Reads the OSCrypt key from the login keychain.
- *
- * 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".
+ * Chromium >= 127 prefixes the plaintext with SHA-256 of the host key, binding
+ * a cookie to its domain. Strip it when present.
*/
-const readMacKeychainPassword = Effect.fn("ChromiumCookies.readMacKeychainPassword")(function* (
- service: string,
- account: string,
- cookieDatabasePath: string,
-) {
- 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 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 decryptValue = (
- encrypted: Uint8Array,
+const decryptCbc = (
+ payload: Buffer,
key: Buffer,
domain: string,
schemaVersion: number,
- platform: NodeJS.Platform,
): string | null => {
- const buffer = Buffer.from(encrypted);
- 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;
- }
- plaintext = plaintext.subarray(32);
- }
- return plaintext.toString("utf8");
+ const decipher = NodeCrypto.createDecipheriv("aes-128-cbc", key, AES_CBC_IV);
+ decipher.setAutoPadding(true);
+ const plaintext = Buffer.concat([decipher.update(payload), decipher.final()]);
+ return stripDomainBinding(plaintext, domain, schemaVersion)?.toString("utf8") ?? null;
} 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);
+
+ if (prefix === "v10") {
+ return keys.cbcV10 ? decryptCbc(payload, keys.cbcV10, domain, schemaVersion) : null;🟠 High BrowserImport/ChromiumCookies.ts:170
Legacy Linux v10/v11 cookies encrypted with the empty-passphrase fallback are returned as null and skipped, even though Chromium can decrypt them. decryptChromiumValue only attempts the normal cbcV10/cbcV11 key and does not retry with Chromium’s PBKDF2-derived empty-passphrase key used for crbug 1195256 compatibility. Add that fallback key and retry prefixed decryption when the normal key fails.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
| 1 | In file @apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts around line 170: |
| 2 | |
| 3 | Legacy Linux `v10`/`v11` cookies encrypted with the empty-passphrase fallback are returned as `null` and skipped, even though Chromium can decrypt them. `decryptChromiumValue` only attempts the normal `cbcV10`/`cbcV11` key and does not retry with Chromium’s PBKDF2-derived empty-passphrase key used for crbug 1195256 compatibility. Add that fallback key and retry prefixed decryption when the normal key fails. |
Fixed in c0f7f052fd. Verified against Chromium’s os_crypt_linux.cc: after a record’s own key fails, Chromium retries with a PBKDF2 key over the empty passphrase (crbug.com/1195256). The Linux key material now carries that key and decryptChromiumValue retries with it on v10/v11 failure — and only when the record’s own key exists and failed, matching Chromium (a missing key still skips). Regression test covers both prefixes plus the no-retry-without-primary-key case.
apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts · Earlier revision 032f5da6 · right 148–153
Original diff
@@ -0,0 +1,263 @@
+// @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** wraps an AES-256-GCM key in DPAPI inside `Local State`. Since
+ * Chrome 127 it *also* keeps an app-bound key that only the browser binary
+ * can unwrap, and cookies written under it carry a `v20` prefix. Those are
+ * not readable by anything else, by design, and this module does not try.
+ *
+ * @module ChromiumKeys
+ */
+import * as Keyring from "@napi-rs/keyring";
+import * as NodeCrypto from "node:crypto";
+
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Schema from "effect/Schema";
+import * as Stream from "effect/Stream";
+import * as ChildProcess from "effect/unstable/process/ChildProcess";
+import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
+
+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",
+ "appBoundEncryption",
+ "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 Linux. */
+ readonly cbcV10?: Buffer;
+ /** AES-128-CBC, Linux keyring-derived. */
+ readonly cbcV11?: Buffer;
+ /** AES-256-GCM on Windows. */
+ readonly gcmV10?: Buffer;
+}
+
+const derive = (passphrase: string, iterations: number) =>
+ NodeCrypto.pbkdf2Sync(passphrase, KEY_SALT, iterations, KEY_LENGTH, "sha1");
+
+/**
+ * Reads the OSCrypt key from the macOS 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;
+});
+
+/**
+ * Unwraps the Windows DPAPI-protected key via PowerShell.
+ *
+ * Shelling out is fine here in a way it is not on macOS: DPAPI is transparent
+ * to any process running as the user, so there is no consent prompt to
+ * misattribute and no ACL entry to write against the wrong binary.
+ */
+const unprotectWithDpapi = Effect.fn("ChromiumKeys.unprotectWithDpapi")(function* (
+ protectedKey: Buffer,
+) {
+ const script = [
+ "Add-Type -AssemblyName System.Security;",
+ `$b=[Convert]::FromBase64String('${protectedKey.toString("base64")}');`,
+ "$u=[System.Security.Cryptography.ProtectedData]::Unprotect($b,$null,",
+ "[System.Security.Cryptography.DataProtectionScope]::CurrentUser);",
+ "[Convert]::ToBase64String($u)",
+ ].join("");
+
+ const process = yield* ChildProcess.make("powershell.exe", [
+ "-NoProfile",
+ "-NonInteractive",
+ "-Command",
+ script,
+ ]);
+ const stdout = yield* process.stdout.pipe(Stream.decodeText(), Stream.mkString);
+ const exitCode = yield* process.exitCode;
+
+ // A failed `Unprotect` still exits, printing nothing. Without these checks
+ // the empty stdout decodes to a zero-length Buffer, which is truthy, so
+ // `gcmV10` would be populated with an unusable key: every record then fails
+ // to decrypt and the import reports success having written nothing.
+ if (exitCode !== 0) {
+ return yield* new ChromiumKeyError({
+ reason: "keychainItemMissing",
+ cause: new Error(`powershell.exe exited with ${exitCode}`),
+ });
+ }apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts · right 148–153
BrowserImport/ChromiumKeys.ts:153
These branches manufacture an Error purely to carry the exit code as text, and label a PowerShell failure keychainItemMissing — the one reason whose user copy tells the user the browser has no key. The exit code is stable, serializable domain context and belongs in a structural attribute (the repo already does this in DesktopLinuxUrlHandlerRegistrationError with exitCode: Schema.optionalKey(Schema.Number) and a message derived from it), and a non-zero exit is readFailed rather than a missing item. Consider adding exitCode to ChromiumKeyError, folding it into message, and dropping the synthetic cause.
No longer relevant as of 1961e3fff8f9c682c259404e9477593109e221ee
apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts · Earlier revision 0ad36895 · right 252
Original diff
@@ -0,0 +1,263 @@
+// @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** wraps an AES-256-GCM key in DPAPI inside `Local State`. Since
+ * Chrome 127 it *also* keeps an app-bound key that only the browser binary
+ * can unwrap, and cookies written under it carry a `v20` prefix. Those are
+ * not readable by anything else, by design, and this module does not try.
+ *
+ * @module ChromiumKeys
+ */
+import * as Keyring from "@napi-rs/keyring";
+import * as NodeCrypto from "node:crypto";
+
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Schema from "effect/Schema";
+import * as Stream from "effect/Stream";
+import * as ChildProcess from "effect/unstable/process/ChildProcess";
+import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
+
+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",
+ "appBoundEncryption",
+ "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 Linux. */
+ readonly cbcV10?: Buffer;
+ /** AES-128-CBC, Linux keyring-derived. */
+ readonly cbcV11?: Buffer;
+ /** AES-256-GCM on Windows. */
+ readonly gcmV10?: Buffer;
+}
+
+const derive = (passphrase: string, iterations: number) =>
+ NodeCrypto.pbkdf2Sync(passphrase, KEY_SALT, iterations, KEY_LENGTH, "sha1");
+
+/**
+ * Reads the OSCrypt key from the macOS 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;
+});
+
+/**
+ * Unwraps the Windows DPAPI-protected key via PowerShell.
+ *
+ * Shelling out is fine here in a way it is not on macOS: DPAPI is transparent
+ * to any process running as the user, so there is no consent prompt to
+ * misattribute and no ACL entry to write against the wrong binary.
+ */
+const unprotectWithDpapi = Effect.fn("ChromiumKeys.unprotectWithDpapi")(function* (
+ protectedKey: Buffer,
+) {
+ const script = [
+ "Add-Type -AssemblyName System.Security;",
+ `$b=[Convert]::FromBase64String('${protectedKey.toString("base64")}');`,
+ "$u=[System.Security.Cryptography.ProtectedData]::Unprotect($b,$null,",
+ "[System.Security.Cryptography.DataProtectionScope]::CurrentUser);",
+ "[Convert]::ToBase64String($u)",
+ ].join("");
+
+ const process = yield* ChildProcess.make("powershell.exe", [
+ "-NoProfile",
+ "-NonInteractive",
+ "-Command",
+ script,
+ ]);
+ const stdout = yield* process.stdout.pipe(Stream.decodeText(), Stream.mkString);
+ const exitCode = yield* process.exitCode;
+
+ // A failed `Unprotect` still exits, printing nothing. Without these checks
+ // the empty stdout decodes to a zero-length Buffer, which is truthy, so
+ // `gcmV10` would be populated with an unusable key: every record then fails
+ // to decrypt and the import reports success having written nothing.
+ if (exitCode !== 0) {
+ return yield* new ChromiumKeyError({
+ reason: "keychainItemMissing",
+ cause: new Error(`powershell.exe exited with ${exitCode}`),
+ });
+ }
+ const unwrapped = Buffer.from(stdout.trim(), "base64");
+ if (unwrapped.length === 0) {
+ return yield* new ChromiumKeyError({
+ reason: "keychainItemMissing",
+ cause: new Error("powershell.exe returned no key material"),
+ });
+ }
+ return unwrapped;
+});
+
+/** The slice of `Local State` that carries the wrapped keys. */
+const LocalState = Schema.Struct({
+ os_crypt: Schema.optional(
+ Schema.Struct({
+ encrypted_key: Schema.optional(Schema.String),
+ app_bound_encrypted_key: Schema.optional(Schema.String),
+ }),
+ ),
+});
+const decodeLocalState = Schema.decodeUnknownEffect(Schema.fromJsonString(LocalState));
+
+/**
+ * Reads the metadata file that carries the wrapped Windows keys.
+ *
+ * Failures are reported rather than flattened into an empty document: a
+ * missing, unreadable or corrupt `Local State` is a different problem from a
+ * browser that has no key yet, and telling the user the latter sends them
+ * looking in the wrong place.
+ */
+const readLocalState = Effect.fn("ChromiumKeys.readLocalState")(function* (localStatePath: string) {
+ const fileSystem = yield* FileSystem.FileSystem;
+ return yield* fileSystem.readFileString(localStatePath).pipe(
+ Effect.flatMap(decodeLocalState),
+ Effect.mapError((cause) => new ChromiumKeyError({ reason: "readFailed", cause })),
+ );
+});
+
+/**
+ * Windows stores the key base64-encoded with a literal `DPAPI` marker in
+ * front, which has to come off before unwrapping.
+ */
+const DPAPI_MARKER = "DPAPI";
+
+export function stripDpapiMarker(encodedKey: string): Buffer {
+ const raw = Buffer.from(encodedKey, "base64");
+ return raw.subarray(0, DPAPI_MARKER.length).toString("latin1") === DPAPI_MARKER
+ ? raw.subarray(DPAPI_MARKER.length)
+ : raw;
+}
+
+export interface ChromiumKeyRequest {
+ readonly platform: NodeJS.Platform;
+ readonly keychainService: string | undefined;
+ readonly keychainAccount: string | undefined;
+ readonly localStatePath: string;
+}
+
+export const resolveChromiumKeys = Effect.fn("ChromiumKeys.resolveChromiumKeys")(function* (
+ request: ChromiumKeyRequest,
+): Effect.fn.Return<
+ ChromiumKeyMaterial,
+ ChromiumKeyError,
+ FileSystem.FileSystem | 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`. Failing to
+ // reach the keyring is not fatal — it just leaves those records skipped.
+ const keyringSecret =
+ request.keychainService && request.keychainAccount
+ ? yield* readKeychainSecret(request.keychainService, request.keychainAccount).pipe(
+ // No Secret Service, locked keyring, or a differently-keyed entry.
+ Effect.orElseSucceed(() => undefined),
+ )
+ : undefined;
+ return {
+ cbcV10: derive(LINUX_FALLBACK_PASSPHRASE, LINUX_KEY_ITERATIONS),
+ ...(keyringSecret ? { cbcV11: derive(keyringSecret, LINUX_KEY_ITERATIONS) } : {}),
+ };
+ }
+
+ if (request.platform === "win32") {
+ const localState = yield* readLocalState(request.localStatePath);
+ const encodedKey = localState.os_crypt?.encrypted_key;
+ if (!encodedKey) {
+ // An app-bound key with no legacy key means every record is `v20`.
+ return yield* new ChromiumKeyError({
+ reason: localState.os_crypt?.app_bound_encrypted_key
+ ? "appBoundEncryption"
+ : "keychainItemMissing",
+ });App-bound import looks successful
High Severity
appBoundEncryption is raised only when encrypted_key is missing. Upgraded Chrome still keeps a legacy DPAPI encrypted_key beside app_bound_encrypted_key, so key resolution succeeds, every v20 cookie is skipped, and the import returns success with little or nothing imported and no user-facing app-bound reason.
Additional Locations (2)
Reviewed by Cursor Bugbot for commit 0ad3689596c830e05d8bdf0a0d16bdf564fb7ab2. Configure here.
apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts · Earlier revision 57d2beaf · right 39
Original diff
@@ -0,0 +1,145 @@ +// @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 is not supported: since Chrome 127 its cookies are encrypted to the + * browser's own identity (App-Bound Encryption), unreadable by any other app. + * + * @module ChromiumKeys + */ +import * as Keyring from "@napi-rs/keyring"; +import * as NodeCrypto from "node:crypto"; + +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +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", + "unsupportedPlatform", + /** The key store itself could not be read, as opposed to holding no key. */ + "readFailed",
BrowserImport/ChromiumKeys.ts:39
readFailed is never constructed in this module — resolveChromiumKeys/readKeychainSecret only raise keychainItemMissing, needsKeychainApproval, and unsupportedPlatform. ChromiumCookies spreads ChromiumKeyFailure.literals (ChromiumCookies.ts:49-50) specifically to pick it up for its own snapshot/SQL failures, so this failure set advertises a reason ChromiumKeyError cannot carry, and the cookie module's reason set depends on a literal that belongs to it rather than to the key store.
Consider dropping readFailed here and listing it explicitly in ChromiumCookieReadReason alongside browserRunning, so each error's reason set describes only the failures that error can actually represent.
No longer relevant as of c188be9758bab40e0a9d93176c917030b2936b4b
The earlier unused-reason observation was valid for that implementation. The replacement Linux Secret Service implementation now constructs readFailed for real subprocess/backend failures, so removing the reason would be incorrect at the updated head. Its error paths are covered by ChromiumKeys tests.
apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts · Earlier revision 2f2f7d0c · right 237
Original diff
@@ -232,7 +234,7 @@ export const readChromiumCookies = Effect.fn("ChromiumCookies.readChromiumCookie
const cookies: ChromiumCookie[] = [];
for (const row of rows) {
- const value = decryptValue(row.encrypted_value, key, row.host_key);
+ const value = decryptChromiumValue(row.encrypted_value, keys, row.host_key);Decrypt skips never counted
Medium Severity
Records skipped for missing keys or v20 are dropped with continue and never reach BrowserImportResult.skipped, which only tracks session.cookies.set failures. Callers therefore cannot distinguish an empty database from thousands of intentionally undecryptable cookies, despite the module promising an honestly reported partial result.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 2f2f7d0c2f8bf17bdb9872e5975a8ea937fe2ba5. Configure here.
apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts · Earlier revision 6a2afea3 · right 158–159
Original diff
@@ -0,0 +1,234 @@
+// @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** wraps an AES-256-GCM key in DPAPI inside `Local State`. Since
+ * Chrome 127 it *also* keeps an app-bound key that only the browser binary
+ * can unwrap, and cookies written under it carry a `v20` prefix. Those are
+ * not readable by anything else, by design, and this module does not try.
+ *
+ * @module ChromiumKeys
+ */
+import * as Keyring from "@napi-rs/keyring";
+import * as NodeCrypto from "node:crypto";
+
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Schema from "effect/Schema";
+import * as Stream from "effect/Stream";
+import * as ChildProcess from "effect/unstable/process/ChildProcess";
+import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
+
+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",
+ "appBoundEncryption",
+ "unsupportedPlatform",
+]);
+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 Linux. */
+ readonly cbcV10?: Buffer;
+ /** AES-128-CBC, Linux keyring-derived. */
+ readonly cbcV11?: Buffer;
+ /** AES-256-GCM on Windows. */
+ readonly gcmV10?: Buffer;
+}
+
+const derive = (passphrase: string, iterations: number) =>
+ NodeCrypto.pbkdf2Sync(passphrase, KEY_SALT, iterations, KEY_LENGTH, "sha1");
+
+/**
+ * Reads the OSCrypt key from the macOS 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;
+});
+
+/**
+ * Unwraps the Windows DPAPI-protected key via PowerShell.
+ *
+ * Shelling out is fine here in a way it is not on macOS: DPAPI is transparent
+ * to any process running as the user, so there is no consent prompt to
+ * misattribute and no ACL entry to write against the wrong binary.
+ */
+const unprotectWithDpapi = Effect.fn("ChromiumKeys.unprotectWithDpapi")(function* (
+ protectedKey: Buffer,
+) {
+ const script = [
+ "Add-Type -AssemblyName System.Security;",
+ `$b=[Convert]::FromBase64String('${protectedKey.toString("base64")}');`,
+ "$u=[System.Security.Cryptography.ProtectedData]::Unprotect($b,$null,",
+ "[System.Security.Cryptography.DataProtectionScope]::CurrentUser);",
+ "[Convert]::ToBase64String($u)",
+ ].join("");
+
+ const process = yield* ChildProcess.make("powershell.exe", [
+ "-NoProfile",
+ "-NonInteractive",
+ "-Command",
+ script,
+ ]);
+ const stdout = yield* process.stdout.pipe(Stream.decodeText(), Stream.mkString);
+ return Buffer.from(stdout.trim(), "base64");
+});
+
+/** The slice of `Local State` that carries the wrapped keys. */
+const LocalState = Schema.Struct({
+ os_crypt: Schema.optional(
+ Schema.Struct({
+ encrypted_key: Schema.optional(Schema.String),
+ app_bound_encrypted_key: Schema.optional(Schema.String),
+ }),
+ ),
+});
+const decodeLocalState = Schema.decodeUnknownEffect(Schema.fromJsonString(LocalState));
+
+const readLocalState = Effect.fn("ChromiumKeys.readLocalState")(function* (localStatePath: string) {
+ const fileSystem = yield* FileSystem.FileSystem;
+ return yield* fileSystem.readFileString(localStatePath).pipe(
+ Effect.flatMap(decodeLocalState),
+ Effect.orElseSucceed(() => ({}) as typeof LocalState.Type),
+ );apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts · right 158–159
🟡 Medium BrowserImport/ChromiumKeys.ts:158
readLocalState turns missing, unreadable, and corrupt Local State files into {}, so Windows reports these failures as keychainItemMissing instead of surfacing the metadata read/decoding error. Remove the Effect.orElseSucceed fallback so the caller can report readFailed or an equivalent non-keychain error.
✅ Resolved in 65f945a4fe6761b0856dc6a95989079abc100bb0
apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts · Earlier revision ce7944de · right 159
Original diff
@@ -0,0 +1,263 @@
+// @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** wraps an AES-256-GCM key in DPAPI inside `Local State`. Since
+ * Chrome 127 it *also* keeps an app-bound key that only the browser binary
+ * can unwrap, and cookies written under it carry a `v20` prefix. Those are
+ * not readable by anything else, by design, and this module does not try.
+ *
+ * @module ChromiumKeys
+ */
+import * as Keyring from "@napi-rs/keyring";
+import * as NodeCrypto from "node:crypto";
+
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Schema from "effect/Schema";
+import * as Stream from "effect/Stream";
+import * as ChildProcess from "effect/unstable/process/ChildProcess";
+import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
+
+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",
+ "appBoundEncryption",
+ "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 Linux. */
+ readonly cbcV10?: Buffer;
+ /** AES-128-CBC, Linux keyring-derived. */
+ readonly cbcV11?: Buffer;
+ /** AES-256-GCM on Windows. */
+ readonly gcmV10?: Buffer;
+}
+
+const derive = (passphrase: string, iterations: number) =>
+ NodeCrypto.pbkdf2Sync(passphrase, KEY_SALT, iterations, KEY_LENGTH, "sha1");
+
+/**
+ * Reads the OSCrypt key from the macOS 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;
+});
+
+/**
+ * Unwraps the Windows DPAPI-protected key via PowerShell.
+ *
+ * Shelling out is fine here in a way it is not on macOS: DPAPI is transparent
+ * to any process running as the user, so there is no consent prompt to
+ * misattribute and no ACL entry to write against the wrong binary.
+ */
+const unprotectWithDpapi = Effect.fn("ChromiumKeys.unprotectWithDpapi")(function* (
+ protectedKey: Buffer,
+) {
+ const script = [
+ "Add-Type -AssemblyName System.Security;",
+ `$b=[Convert]::FromBase64String('${protectedKey.toString("base64")}');`,
+ "$u=[System.Security.Cryptography.ProtectedData]::Unprotect($b,$null,",
+ "[System.Security.Cryptography.DataProtectionScope]::CurrentUser);",
+ "[Convert]::ToBase64String($u)",
+ ].join("");
+
+ const process = yield* ChildProcess.make("powershell.exe", [
+ "-NoProfile",
+ "-NonInteractive",
+ "-Command",
+ script,
+ ]);
+ const stdout = yield* process.stdout.pipe(Stream.decodeText(), Stream.mkString);
+ const exitCode = yield* process.exitCode;
+
+ // A failed `Unprotect` still exits, printing nothing. Without these checks
+ // the empty stdout decodes to a zero-length Buffer, which is truthy, so
+ // `gcmV10` would be populated with an unusable key: every record then fails
+ // to decrypt and the import reports success having written nothing.
+ if (exitCode !== 0) {
+ return yield* new ChromiumKeyError({
+ reason: "keychainItemMissing",
+ cause: new Error(`powershell.exe exited with ${exitCode}`),
+ });
+ }
+ const unwrapped = Buffer.from(stdout.trim(), "base64");
+ if (unwrapped.length === 0) {
+ return yield* new ChromiumKeyError({
+ reason: "keychainItemMissing",
+ cause: new Error("powershell.exe returned no key material"),
+ });Wrong Windows failure copy
Medium Severity
DPAPI / PowerShell failures are mapped to keychainItemMissing, whose user copy talks about Keychain and signing into the browser. On Windows that message is misleading for a DPAPI unwrap or Local State problem and steers users toward the wrong fix.
Additional Locations (2)
Reviewed by Cursor Bugbot for commit ce7944dedd00e2013d48fedf6b869b6daf1e147b. Configure here.
apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts · Earlier revision cfbd7b79 · right 238
Original diff
@@ -232,7 +234,7 @@ export const readChromiumCookies = Effect.fn("ChromiumCookies.readChromiumCookie
const cookies: ChromiumCookie[] = [];
for (const row of rows) {
- const value = decryptValue(row.encrypted_value, key, row.host_key);
+ const value = decryptChromiumValue(row.encrypted_value, keys, row.host_key);
if (value === null) continue;🟡 Medium BrowserImport/ChromiumCookies.ts:238
readChromiumCookies silently omits every row for which decryptChromiumValue returns null, so Linux v11 and Windows v20 cookies are excluded while the import still reports them as successfully handled (skipped: 0). Track these undecryptable rows in the skipped count (or propagate that count) so partial imports are reported accurately.
✅ Resolved in 8f80c3c6e5aefaab66ab38824050cf361e8f52ae
apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts · Earlier revision 6a2afea3 · right 206
Original diff
@@ -0,0 +1,234 @@
+// @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** wraps an AES-256-GCM key in DPAPI inside `Local State`. Since
+ * Chrome 127 it *also* keeps an app-bound key that only the browser binary
+ * can unwrap, and cookies written under it carry a `v20` prefix. Those are
+ * not readable by anything else, by design, and this module does not try.
+ *
+ * @module ChromiumKeys
+ */
+import * as Keyring from "@napi-rs/keyring";
+import * as NodeCrypto from "node:crypto";
+
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Schema from "effect/Schema";
+import * as Stream from "effect/Stream";
+import * as ChildProcess from "effect/unstable/process/ChildProcess";
+import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
+
+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",
+ "appBoundEncryption",
+ "unsupportedPlatform",
+]);
+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 Linux. */
+ readonly cbcV10?: Buffer;
+ /** AES-128-CBC, Linux keyring-derived. */
+ readonly cbcV11?: Buffer;
+ /** AES-256-GCM on Windows. */
+ readonly gcmV10?: Buffer;
+}
+
+const derive = (passphrase: string, iterations: number) =>
+ NodeCrypto.pbkdf2Sync(passphrase, KEY_SALT, iterations, KEY_LENGTH, "sha1");
+
+/**
+ * Reads the OSCrypt key from the macOS 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;
+});
+
+/**
+ * Unwraps the Windows DPAPI-protected key via PowerShell.
+ *
+ * Shelling out is fine here in a way it is not on macOS: DPAPI is transparent
+ * to any process running as the user, so there is no consent prompt to
+ * misattribute and no ACL entry to write against the wrong binary.
+ */
+const unprotectWithDpapi = Effect.fn("ChromiumKeys.unprotectWithDpapi")(function* (
+ protectedKey: Buffer,
+) {
+ const script = [
+ "Add-Type -AssemblyName System.Security;",
+ `$b=[Convert]::FromBase64String('${protectedKey.toString("base64")}');`,
+ "$u=[System.Security.Cryptography.ProtectedData]::Unprotect($b,$null,",
+ "[System.Security.Cryptography.DataProtectionScope]::CurrentUser);",
+ "[Convert]::ToBase64String($u)",
+ ].join("");
+
+ const process = yield* ChildProcess.make("powershell.exe", [
+ "-NoProfile",
+ "-NonInteractive",
+ "-Command",
+ script,
+ ]);
+ const stdout = yield* process.stdout.pipe(Stream.decodeText(), Stream.mkString);
+ return Buffer.from(stdout.trim(), "base64");
+});
+
+/** The slice of `Local State` that carries the wrapped keys. */
+const LocalState = Schema.Struct({
+ os_crypt: Schema.optional(
+ Schema.Struct({
+ encrypted_key: Schema.optional(Schema.String),
+ app_bound_encrypted_key: Schema.optional(Schema.String),
+ }),
+ ),
+});
+const decodeLocalState = Schema.decodeUnknownEffect(Schema.fromJsonString(LocalState));
+
+const readLocalState = Effect.fn("ChromiumKeys.readLocalState")(function* (localStatePath: string) {
+ const fileSystem = yield* FileSystem.FileSystem;
+ return yield* fileSystem.readFileString(localStatePath).pipe(
+ Effect.flatMap(decodeLocalState),
+ Effect.orElseSucceed(() => ({}) as typeof LocalState.Type),
+ );
+});
+
+/**
+ * Windows stores the key base64-encoded with a literal `DPAPI` marker in
+ * front, which has to come off before unwrapping.
+ */
+const DPAPI_MARKER = "DPAPI";
+
+export function stripDpapiMarker(encodedKey: string): Buffer {
+ const raw = Buffer.from(encodedKey, "base64");
+ return raw.subarray(0, DPAPI_MARKER.length).toString("latin1") === DPAPI_MARKER
+ ? raw.subarray(DPAPI_MARKER.length)
+ : raw;
+}
+
+export interface ChromiumKeyRequest {
+ readonly platform: NodeJS.Platform;
+ readonly keychainService: string | undefined;
+ readonly keychainAccount: string | undefined;
+ readonly localStatePath: string;
+}
+
+export const resolveChromiumKeys = Effect.fn("ChromiumKeys.resolveChromiumKeys")(function* (
+ request: ChromiumKeyRequest,
+): Effect.fn.Return<
+ ChromiumKeyMaterial,
+ ChromiumKeyError,
+ FileSystem.FileSystem | 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`. Failing to
+ // reach the keyring is not fatal — it just leaves those records skipped.
+ const keyringSecret =
+ request.keychainService && request.keychainAccount
+ ? yield* readKeychainSecret(request.keychainService, request.keychainAccount).pipe(
+ // No Secret Service, locked keyring, or a differently-keyed entry.
+ Effect.orElseSucceed(() => undefined),
+ )Linux keyring lookup uses wrong attributes
High Severity
Linux v11 key resolution reuses macOS keychainService / keychainAccount with @napi-rs/keyring, which looks up Secret Service by service + username. Chromium stores the OSCrypt secret under schema chrome_libsecret_os_crypt_password_v2 with attribute application (e.g. chrome, msedge). The lookup misses, orElseSucceed drops the error, and only the peanuts v10 key remains—so normal keyring-backed Linux profiles import no v11 cookies.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 6a2afea3ed6cc28722026fb50b2134b11a590dad. Configure here.
apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts · Earlier revision 032f5da6 · right 187
Original diff
@@ -0,0 +1,263 @@
+// @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** wraps an AES-256-GCM key in DPAPI inside `Local State`. Since
+ * Chrome 127 it *also* keeps an app-bound key that only the browser binary
+ * can unwrap, and cookies written under it carry a `v20` prefix. Those are
+ * not readable by anything else, by design, and this module does not try.
+ *
+ * @module ChromiumKeys
+ */
+import * as Keyring from "@napi-rs/keyring";
+import * as NodeCrypto from "node:crypto";
+
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Schema from "effect/Schema";
+import * as Stream from "effect/Stream";
+import * as ChildProcess from "effect/unstable/process/ChildProcess";
+import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
+
+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",
+ "appBoundEncryption",
+ "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 Linux. */
+ readonly cbcV10?: Buffer;
+ /** AES-128-CBC, Linux keyring-derived. */
+ readonly cbcV11?: Buffer;
+ /** AES-256-GCM on Windows. */
+ readonly gcmV10?: Buffer;
+}
+
+const derive = (passphrase: string, iterations: number) =>
+ NodeCrypto.pbkdf2Sync(passphrase, KEY_SALT, iterations, KEY_LENGTH, "sha1");
+
+/**
+ * Reads the OSCrypt key from the macOS 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;
+});
+
+/**
+ * Unwraps the Windows DPAPI-protected key via PowerShell.
+ *
+ * Shelling out is fine here in a way it is not on macOS: DPAPI is transparent
+ * to any process running as the user, so there is no consent prompt to
+ * misattribute and no ACL entry to write against the wrong binary.
+ */
+const unprotectWithDpapi = Effect.fn("ChromiumKeys.unprotectWithDpapi")(function* (
+ protectedKey: Buffer,
+) {
+ const script = [
+ "Add-Type -AssemblyName System.Security;",
+ `$b=[Convert]::FromBase64String('${protectedKey.toString("base64")}');`,
+ "$u=[System.Security.Cryptography.ProtectedData]::Unprotect($b,$null,",
+ "[System.Security.Cryptography.DataProtectionScope]::CurrentUser);",
+ "[Convert]::ToBase64String($u)",
+ ].join("");
+
+ const process = yield* ChildProcess.make("powershell.exe", [
+ "-NoProfile",
+ "-NonInteractive",
+ "-Command",
+ script,
+ ]);
+ const stdout = yield* process.stdout.pipe(Stream.decodeText(), Stream.mkString);
+ const exitCode = yield* process.exitCode;
+
+ // A failed `Unprotect` still exits, printing nothing. Without these checks
+ // the empty stdout decodes to a zero-length Buffer, which is truthy, so
+ // `gcmV10` would be populated with an unusable key: every record then fails
+ // to decrypt and the import reports success having written nothing.
+ if (exitCode !== 0) {
+ return yield* new ChromiumKeyError({
+ reason: "keychainItemMissing",
+ cause: new Error(`powershell.exe exited with ${exitCode}`),
+ });
+ }
+ const unwrapped = Buffer.from(stdout.trim(), "base64");
+ if (unwrapped.length === 0) {
+ return yield* new ChromiumKeyError({
+ reason: "keychainItemMissing",
+ cause: new Error("powershell.exe returned no key material"),
+ });
+ }
+ return unwrapped;
+});
+
+/** The slice of `Local State` that carries the wrapped keys. */
+const LocalState = Schema.Struct({
+ os_crypt: Schema.optional(
+ Schema.Struct({
+ encrypted_key: Schema.optional(Schema.String),
+ app_bound_encrypted_key: Schema.optional(Schema.String),
+ }),
+ ),
+});
+const decodeLocalState = Schema.decodeUnknownEffect(Schema.fromJsonString(LocalState));
+
+/**
+ * Reads the metadata file that carries the wrapped Windows keys.
+ *
+ * Failures are reported rather than flattened into an empty document: a
+ * missing, unreadable or corrupt `Local State` is a different problem from a
+ * browser that has no key yet, and telling the user the latter sends them
+ * looking in the wrong place.
+ */
+const readLocalState = Effect.fn("ChromiumKeys.readLocalState")(function* (localStatePath: string) {
+ const fileSystem = yield* FileSystem.FileSystem;
+ return yield* fileSystem.readFileString(localStatePath).pipe(
+ Effect.flatMap(decodeLocalState),
+ Effect.mapError((cause) => new ChromiumKeyError({ reason: "readFailed", cause })),BrowserImport/ChromiumKeys.ts:187
readFailed is raised here without the path that failed, so the only path that survives to the log is cookieDatabasePath, attached by the wrapper in ChromiumCookies — a corrupt or unreadable Local State is then reported against the cookie database instead. localStatePath is in scope at this wrapping site; consider adding a path attribute to ChromiumKeyError and setting it here so the failing resource is identifiable.
No longer relevant as of 1961e3fff8f9c682c259404e9477593109e221ee
scripts/build-desktop-artifact.ts · Earlier revision 296acd56 · right 2230–2230
Original diff
@@ -2196,6 +2212,31 @@ export const stageResourceMonitor = Effect.fn("stageResourceMonitor")(function*
}
});
+export const stageBrowserSecret = Effect.fn("stageBrowserSecret")(function* (input: {
+ readonly repoRoot: string;
+ readonly stageResourcesDir: string;
+ readonly platform: typeof BuildPlatform.Type;
+ readonly arch: typeof BuildArch.Type;
+ readonly verbose: boolean;
+}) {
+ if (input.platform !== "linux") return;
+ const path = yield* Path.Path;
+ yield* runCommand(
+ ChildProcess.make(
+ "node",
+ [
+ path.join(input.repoRoot, "apps/desktop/scripts/build-browser-secret.mjs"),
+ "--arch",
+ input.arch,scripts/build-desktop-artifact.ts · right 2230–2230
🟠 High scripts/build-desktop-artifact.ts:2230
Linux builds requested with arch: "universal" fail while staging stageBrowserSecret, because build-browser-secret.mjs rejects that value even though BuildArch accepts it. Map "universal" to the concrete helper architecture used by the Linux resource monitor (x64), or reject the option before spawning the helper.
| 1 | input.arch === "arm64" ? "arm64" : "x64", |
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
| 1 | In file @scripts/build-desktop-artifact.ts around line 2230: |
| 2 | |
| 3 | Linux builds requested with `arch: "universal"` fail while staging `stageBrowserSecret`, because `build-browser-secret.mjs` rejects that value even though `BuildArch` accepts it. Map `"universal"` to the concrete helper architecture used by the Linux resource monitor (x64), or reject the option before spawning the helper. |
Addressed in #9516 (766885b5df): universal maps to x64 before the helper is invoked, mirroring the Linux resource-monitor target resolution; test added.
apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts · Earlier revision 6a2afea3 · right 156–159
Original diff
@@ -0,0 +1,234 @@
+// @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** wraps an AES-256-GCM key in DPAPI inside `Local State`. Since
+ * Chrome 127 it *also* keeps an app-bound key that only the browser binary
+ * can unwrap, and cookies written under it carry a `v20` prefix. Those are
+ * not readable by anything else, by design, and this module does not try.
+ *
+ * @module ChromiumKeys
+ */
+import * as Keyring from "@napi-rs/keyring";
+import * as NodeCrypto from "node:crypto";
+
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Schema from "effect/Schema";
+import * as Stream from "effect/Stream";
+import * as ChildProcess from "effect/unstable/process/ChildProcess";
+import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
+
+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",
+ "appBoundEncryption",
+ "unsupportedPlatform",
+]);
+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 Linux. */
+ readonly cbcV10?: Buffer;
+ /** AES-128-CBC, Linux keyring-derived. */
+ readonly cbcV11?: Buffer;
+ /** AES-256-GCM on Windows. */
+ readonly gcmV10?: Buffer;
+}
+
+const derive = (passphrase: string, iterations: number) =>
+ NodeCrypto.pbkdf2Sync(passphrase, KEY_SALT, iterations, KEY_LENGTH, "sha1");
+
+/**
+ * Reads the OSCrypt key from the macOS 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;
+});
+
+/**
+ * Unwraps the Windows DPAPI-protected key via PowerShell.
+ *
+ * Shelling out is fine here in a way it is not on macOS: DPAPI is transparent
+ * to any process running as the user, so there is no consent prompt to
+ * misattribute and no ACL entry to write against the wrong binary.
+ */
+const unprotectWithDpapi = Effect.fn("ChromiumKeys.unprotectWithDpapi")(function* (
+ protectedKey: Buffer,
+) {
+ const script = [
+ "Add-Type -AssemblyName System.Security;",
+ `$b=[Convert]::FromBase64String('${protectedKey.toString("base64")}');`,
+ "$u=[System.Security.Cryptography.ProtectedData]::Unprotect($b,$null,",
+ "[System.Security.Cryptography.DataProtectionScope]::CurrentUser);",
+ "[Convert]::ToBase64String($u)",
+ ].join("");
+
+ const process = yield* ChildProcess.make("powershell.exe", [
+ "-NoProfile",
+ "-NonInteractive",
+ "-Command",
+ script,
+ ]);
+ const stdout = yield* process.stdout.pipe(Stream.decodeText(), Stream.mkString);
+ return Buffer.from(stdout.trim(), "base64");
+});
+
+/** The slice of `Local State` that carries the wrapped keys. */
+const LocalState = Schema.Struct({
+ os_crypt: Schema.optional(
+ Schema.Struct({
+ encrypted_key: Schema.optional(Schema.String),
+ app_bound_encrypted_key: Schema.optional(Schema.String),
+ }),
+ ),
+});
+const decodeLocalState = Schema.decodeUnknownEffect(Schema.fromJsonString(LocalState));
+
+const readLocalState = Effect.fn("ChromiumKeys.readLocalState")(function* (localStatePath: string) {
+ const fileSystem = yield* FileSystem.FileSystem;
+ return yield* fileSystem.readFileString(localStatePath).pipe(
+ Effect.flatMap(decodeLocalState),
+ Effect.orElseSucceed(() => ({}) as typeof LocalState.Type),
+ );apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts · right 156–159
BrowserImport/ChromiumKeys.ts:159
readLocalState swallows both the file-read and the JSON/schema decode failure, so a missing, unreadable, or malformed Local State reaches the caller as an empty object and is then reported as ChromiumKeyError({ reason: "keychainItemMissing" }) with no cause and no path context. Consider mapping the failure into the error instead, so the underlying error chain survives and a genuinely absent os_crypt.encrypted_key stays distinguishable from an unreadable file.
| 1 | return yield* fileSystem.readFileString(localStatePath).pipe( |
| 2 | Effect.flatMap(decodeLocalState), |
| 3 | Effect.mapError((cause) => new ChromiumKeyError({ reason: "keychainItemMissing", cause })), |
| 4 | ); |
No longer relevant as of 65f945a4fe6761b0856dc6a95989079abc100bb0
apps/desktop/src/main.ts · Earlier revision 296acd56 · right 156
Original diff
@@ -152,7 +153,7 @@ const desktopServerExposureLayer = DesktopServerExposure.layer.pipe( 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), + Layer.provideMerge(BrowserImport.layer.pipe(Layer.provide(LinuxBrowserSecret.layer))),
Helper path dropped at runtime
High Severity
Layer.provide(LinuxBrowserSecret.layer) only makes LinuxBrowserSecretPath available while BrowserImport is constructed, and make never reads it. readLinuxSecret later uses the tag's default undefined, so the bundled helper is never launched and v11 cookies cannot be decrypted.
Additional Locations (2)
Reviewed by Cursor Bugbot for commit 296acd56139acca917c5c1fa9e193b4775906994. Configure here.
apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts · Earlier revision 8860a7f8 · right 143–143
Original diff
@@ -0,0 +1,222 @@
+// @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 is not supported: since Chrome 127 its cookies are encrypted to the
+ * browser's own identity (App-Bound Encryption), unreadable by any other app.
+ *
+ * @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 Schema from "effect/Schema";
+import * as Stream from "effect/Stream";
+import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
+
+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",
+ "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;
+ /**
+ * 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;
+}
+
+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;
+});
+
+const secretToolFailure = (stderr: string): ChromiumKeyFailure => {
+ if (stderr.trim() === "" || /no such secret|not found/i.test(stderr)) {
+ return "keychainItemMissing";
+ }
+ if (/denied|locked|cancel|dismiss|permission/i.test(stderr)) {
+ return "needsKeychainApproval";
+ }
+ return "readFailed";
+};
+
+/**
+ * Chromium's Linux backend uses a custom libsecret schema keyed by its
+ * `application` attribute. `secret-tool lookup` performs that same attribute
+ * search through Secret Service and keeps its normal unlock/consent prompt.
+ */
+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 handle = yield* spawner
+ .spawn(
+ ChildProcess.make("secret-tool", ["lookup", "application", application], {apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts · right 143–143
🟡 Medium BrowserImport/ChromiumKeys.ts:143
readLinuxSecret can select a non-Chromium Secret Service item, derive the wrong cbcV11, and silently skip every real v11 cookie. The lookup filters only on application; include xdg:schema=chrome_libsecret_os_crypt_password_v2 in the secret-tool predicates.
| 1 | ChildProcess.make("secret-tool", ["lookup", "xdg:schema", "chrome_libsecret_os_crypt_password_v2", "application", application], { |
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
| 1 | In file @apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts around line 143: |
| 2 | |
| 3 | `readLinuxSecret` can select a non-Chromium Secret Service item, derive the wrong `cbcV11`, and silently skip every real v11 cookie. The lookup filters only on `application`; include `xdg:schema=chrome_libsecret_os_crypt_password_v2` in the `secret-tool` predicates. |
Confirmed and fixed in 3c1d584a94: the lookup now passes xdg:schema chrome_libsecret_os_crypt_password_v2 alongside application, so only Chromium's own item can match. Test updated.
Confirmed—the schema predicate resolves the concern. Thanks!
apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts · Earlier revision 1d228da0 · right 153–155
Original diff
@@ -71,86 +70,91 @@ const toUnixSeconds = (webkitSeconds: number): number | undefined => {
};
/**
- * Reads the OSCrypt key from the login keychain.
- *
- * 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".
+ * Chromium >= 127 prefixes the plaintext with SHA-256 of the host key, binding
+ * a cookie to its domain. Strip it when present.
*/
-async function readMacKeychainPassword(service: string, account: string): Promise<string> {
- let password: string | null;
+const stripDomainBinding = (plaintext: Buffer, domain: string): Buffer => {
+ const domainHash = NodeCrypto.createHash("sha256").update(domain).digest();
+ return plaintext.length >= 32 && plaintext.subarray(0, 32).equals(domainHash)
+ ? plaintext.subarray(32)
+ : plaintext;
+};
+
+const decryptCbc = (payload: Buffer, key: Buffer, domain: string): string | null => {
try {
- password = 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.
- if (/no (matching )?entry|not found/i.test(message)) {
- throw new ChromiumCookieReadError({ reason: "keychainItemMissing" });
- }
- throw new ChromiumCookieReadError({ reason: "needsKeychainApproval" });
- }
- if (password === null || password === "") {
- throw new ChromiumCookieReadError({ reason: "keychainItemMissing" });
+ const decipher = NodeCrypto.createDecipheriv("aes-128-cbc", key, AES_CBC_IV);
+ decipher.setAutoPadding(true);
+ const plaintext = Buffer.concat([decipher.update(payload), decipher.final()]);
+ return stripDomainBinding(plaintext, domain).toString("utf8");
+ } catch {
+ return null;
}
- return password;
-}
-
-const decryptValue = (encrypted: Uint8Array, key: Buffer, domain: string): string | null => {
- const buffer = Buffer.from(encrypted);
- if (buffer.length === 0) return "";
- if (buffer.subarray(0, 3).toString("latin1") !== V10_PREFIX) return null;
+};
+const decryptGcm = (payload: Buffer, key: Buffer, domain: string): string | null => {
try {
- const decipher = NodeCrypto.createDecipheriv("aes-128-cbc", key, AES_IV);
- decipher.setAutoPadding(true);
- let plaintext = Buffer.concat([decipher.update(buffer.subarray(3)), decipher.final()]);
- // Chromium >= 127 prefixes the plaintext with SHA-256 of the host key to
- // bind a cookie to its domain; strip it when present.
- const domainHash = NodeCrypto.createHash("sha256").update(domain).digest();
- if (plaintext.length >= 32 && plaintext.subarray(0, 32).equals(domainHash)) {
- plaintext = plaintext.subarray(32);
- }
- return plaintext.toString("utf8");
+ const nonce = payload.subarray(0, GCM_NONCE_LENGTH);
+ const tag = payload.subarray(payload.length - GCM_TAG_LENGTH);
+ const body = payload.subarray(GCM_NONCE_LENGTH, payload.length - GCM_TAG_LENGTH);
+ const decipher = NodeCrypto.createDecipheriv("aes-256-gcm", key, nonce);
+ decipher.setAuthTag(tag);
+ const plaintext = Buffer.concat([decipher.update(body), decipher.final()]);
+ return stripDomainBinding(plaintext, domain).toString("utf8");
} catch {
return null;
}
};
+/**
+ * Decrypts one stored value, choosing the scheme from its prefix. Returns null
+ * when no key covers that scheme — including `v20`, which is app-bound and
+ * deliberately unreadable outside the browser.
+ */
+export function decryptChromiumValue(
+ encrypted: Uint8Array,
+ keys: ChromiumKeyMaterial,
+ domain: string,
+): 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);
+
+ if (prefix === "v10") {
+ if (keys.gcmV10) return decryptGcm(payload, keys.gcmV10, domain);
+ return keys.cbcV10 ? decryptCbc(payload, keys.cbcV10, domain) : null;
+ }
+ if (prefix === "v11") {
+ return keys.cbcV11 ? decryptCbc(payload, keys.cbcV11, domain) : null;
+ }
+ return null;
+}
+
export interface ChromiumCookieSource {
readonly cookieDatabasePath: string;
- readonly keychainService: string;
- readonly keychainAccount: string;
- /** Supplied by the caller from `HostProcessPlatform` rather than read here. */
+ readonly localStatePath: string;
+ readonly keychainService: string | undefined;
+ readonly keychainAccount: string | undefined;
readonly platform: NodeJS.Platform;
}
export async function readChromiumCookies(
source: ChromiumCookieSource,
): Promise<ReadonlyArray<ChromiumCookie>> {
- 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.
- throw new ChromiumCookieReadError({ reason: "unsupportedPlatform" });
+ let keys: ChromiumKeyMaterial;
+ try {
+ keys = await resolveChromiumKeys({
+ platform: source.platform,
+ keychainService: source.keychainService,
+ keychainAccount: source.keychainAccount,
+ localStatePath: source.localStatePath,
+ });
+ } catch (cause) {
+ throw new ChromiumCookieReadError({
+ reason: cause instanceof ChromiumKeyError ? cause.reason : "unsupportedPlatform",
+ });apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts · right 153–155
🟡 Medium BrowserImport/ChromiumCookies.ts:153
readChromiumCookies reports unsupportedPlatform for every non-ChromiumKeyError thrown by resolveChromiumKeys, so Windows failures such as DPAPI rejection or malformed key output are misclassified on a supported platform instead of reaching BrowserImport as readFailed. Preserve the original error for unexpected failures and only wrap ChromiumKeyError instances.
✅ Resolved in 6a2afea3ed6cc28722026fb50b2134b11a590dad
apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts · Earlier revision 7f41045e · right 152
Original diff
@@ -0,0 +1,276 @@
+// @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** wraps an AES-256-GCM key in DPAPI inside `Local State`. Since
+ * Chrome 127 it *also* keeps an app-bound key that only the browser binary
+ * can unwrap, and cookies written under it carry a `v20` prefix. Those are
+ * not readable by anything else, by design, and this module does not try.
+ *
+ * @module ChromiumKeys
+ */
+import * as Keyring from "@napi-rs/keyring";
+import * as NodeCrypto from "node:crypto";
+
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Schema from "effect/Schema";
+import * as Stream from "effect/Stream";
+import * as ChildProcess from "effect/unstable/process/ChildProcess";
+import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
+
+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",
+ "appBoundEncryption",
+ "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,
+ /**
+ * The metadata file the failure is about, when it is about one. Without it
+ * an unreadable `Local State` is reported against the cookie database the
+ * caller names instead.
+ */
+ localStatePath: Schema.optional(Schema.String),
+ /** Exit status of the helper that was asked to unwrap the key. */
+ exitCode: Schema.optional(Schema.Number),
+ /** Kept for the log; never surfaced to the user. */
+ cause: Schema.optional(Schema.Defect()),
+ },
+) {
+ override get message(): string {
+ const where = this.localStatePath === undefined ? "" : ` at ${this.localStatePath}`;
+ const status = this.exitCode === undefined ? "" : ` (helper exited with ${this.exitCode})`;
+ return `Could not obtain the Chromium cookie key${where}: ${this.reason}${status}.`;
+ }
+}
+
+/**
+ * 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 Linux. */
+ readonly cbcV10?: Buffer;
+ /** AES-128-CBC, Linux keyring-derived. */
+ readonly cbcV11?: Buffer;
+ /** AES-256-GCM on Windows. */
+ readonly gcmV10?: Buffer;
+}
+
+const derive = (passphrase: string, iterations: number) =>
+ NodeCrypto.pbkdf2Sync(passphrase, KEY_SALT, iterations, KEY_LENGTH, "sha1");
+
+/**
+ * Reads the OSCrypt key from the macOS 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;
+});
+
+/**
+ * Unwraps the Windows DPAPI-protected key via PowerShell.
+ *
+ * Shelling out is fine here in a way it is not on macOS: DPAPI is transparent
+ * to any process running as the user, so there is no consent prompt to
+ * misattribute and no ACL entry to write against the wrong binary.
+ */
+const unprotectWithDpapi = Effect.fn("ChromiumKeys.unprotectWithDpapi")(function* (
+ protectedKey: Buffer,
+) {
+ const script = [
+ "Add-Type -AssemblyName System.Security;",
+ `$b=[Convert]::FromBase64String('${protectedKey.toString("base64")}');`,
+ "$u=[System.Security.Cryptography.ProtectedData]::Unprotect($b,$null,",
+ "[System.Security.Cryptography.DataProtectionScope]::CurrentUser);",
+ "[Convert]::ToBase64String($u)",
+ ].join("");
+
+ const process = yield* ChildProcess.make("powershell.exe", [
+ "-NoProfile",
+ "-NonInteractive",
+ "-Command",
+ script,
+ ]);
+ const stdout = yield* process.stdout.pipe(Stream.decodeText(), Stream.mkString);
+ const exitCode = yield* process.exitCode;PowerShell may hang on stdin
Medium Severity
ChildProcess.make for DPAPI leaves default stdin: "pipe" and never closes or ignores it. Elsewhere in the desktop app, fire-and-forget spawns set stdin: "ignore". An open stdin pipe can leave powershell.exe blocked, so Windows key unwrap never finishes and cookie import hangs.
Reviewed by Cursor Bugbot for commit 7f41045e6ac1f2ef3af6a0f6ef1b824e0a553487. Configure here.
apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts · Earlier revision 6a2afea3 · right 139–140
Original diff
@@ -0,0 +1,234 @@
+// @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** wraps an AES-256-GCM key in DPAPI inside `Local State`. Since
+ * Chrome 127 it *also* keeps an app-bound key that only the browser binary
+ * can unwrap, and cookies written under it carry a `v20` prefix. Those are
+ * not readable by anything else, by design, and this module does not try.
+ *
+ * @module ChromiumKeys
+ */
+import * as Keyring from "@napi-rs/keyring";
+import * as NodeCrypto from "node:crypto";
+
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Schema from "effect/Schema";
+import * as Stream from "effect/Stream";
+import * as ChildProcess from "effect/unstable/process/ChildProcess";
+import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
+
+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",
+ "appBoundEncryption",
+ "unsupportedPlatform",
+]);
+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 Linux. */
+ readonly cbcV10?: Buffer;
+ /** AES-128-CBC, Linux keyring-derived. */
+ readonly cbcV11?: Buffer;
+ /** AES-256-GCM on Windows. */
+ readonly gcmV10?: Buffer;
+}
+
+const derive = (passphrase: string, iterations: number) =>
+ NodeCrypto.pbkdf2Sync(passphrase, KEY_SALT, iterations, KEY_LENGTH, "sha1");
+
+/**
+ * Reads the OSCrypt key from the macOS 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;
+});
+
+/**
+ * Unwraps the Windows DPAPI-protected key via PowerShell.
+ *
+ * Shelling out is fine here in a way it is not on macOS: DPAPI is transparent
+ * to any process running as the user, so there is no consent prompt to
+ * misattribute and no ACL entry to write against the wrong binary.
+ */
+const unprotectWithDpapi = Effect.fn("ChromiumKeys.unprotectWithDpapi")(function* (
+ protectedKey: Buffer,
+) {
+ const script = [
+ "Add-Type -AssemblyName System.Security;",
+ `$b=[Convert]::FromBase64String('${protectedKey.toString("base64")}');`,
+ "$u=[System.Security.Cryptography.ProtectedData]::Unprotect($b,$null,",
+ "[System.Security.Cryptography.DataProtectionScope]::CurrentUser);",
+ "[Convert]::ToBase64String($u)",
+ ].join("");
+
+ const process = yield* ChildProcess.make("powershell.exe", [
+ "-NoProfile",
+ "-NonInteractive",
+ "-Command",
+ script,
+ ]);
+ const stdout = yield* process.stdout.pipe(Stream.decodeText(), Stream.mkString);
+ return Buffer.from(stdout.trim(), "base64");apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts · right 139–140
BrowserImport/ChromiumKeys.ts:140
The PowerShell exit code is never inspected, so a failed Unprotect (or a missing powershell.exe path that still exits) yields empty stdout and this returns a zero-length Buffer. That buffer is truthy, so gcmV10 is populated with an unusable key, every GCM record fails to decrypt in decryptChromiumValue, and the import silently reports zero cookies instead of a typed failure. Consider surfacing it as a ChromiumKeyError (the repo checks exit codes this way in DesktopLinuxUrlHandler.setDefaultHandler):
| 1 | const stdout = yield* process.stdout.pipe(Stream.decodeText(), Stream.mkString); |
| 2 | const exitCode = yield* process.exitCode; |
| 3 | if (Number(exitCode) !== 0 || stdout.trim() === "") { |
| 4 | return yield* new ChromiumKeyError({ reason: "keychainItemMissing" }); |
| 5 | } |
| 6 | return Buffer.from(stdout.trim(), "base64"); |
If you adopt this, the mapper at line 228 should pass an existing ChromiumKeyError through rather than re-wrapping it. Separately, that mapper classifies every spawn/unwrap failure as keychainItemMissing, which renders as "No encryption key in your Keychain — sign in to that browser once, then retry" on Windows; a reason that structurally identifies the unwrap failure (e.g. a readFailed-style literal) would keep the caller-visible copy accurate.
No longer relevant as of 65f945a4fe6761b0856dc6a95989079abc100bb0
apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts · Earlier revision eb74f8fa · right 115
Original diff
@@ -0,0 +1,211 @@
+// @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 is not supported: since Chrome 127 its cookies are encrypted to the
+ * browser's own identity (App-Bound Encryption), unreadable by any other app.
+ *
+ * @module ChromiumKeys
+ */
+import * as Keyring from "@napi-rs/keyring";
+import * as NodeCrypto from "node:crypto";
+
+import * as Effect from "effect/Effect";
+import * as Schema from "effect/Schema";
+import * as Stream from "effect/Stream";
+import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
+
+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",
+ "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;
+}
+
+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;
+});
+
+const secretToolFailure = (stderr: string): ChromiumKeyFailure => {
+ if (stderr.trim() === "" || /no such secret|not found/i.test(stderr)) {
+ return "keychainItemMissing";
+ }
+ if (/denied|locked|cancel|dismiss|permission/i.test(stderr)) {🟡 Medium BrowserImport/ChromiumKeys.ts:115
A localized keyring denial is classified as readFailed by secretToolFailure, so resolveChromiumKeys silently falls back to v10-only import even after the user denies access. The classification depends on English words in stderr; force secret-tool to use a stable locale or use a locale-independent result to preserve the needsKeychainApproval path.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
| 1 | In file @apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts around line 115: |
| 2 | |
| 3 | A localized keyring denial is classified as `readFailed` by `secretToolFailure`, so `resolveChromiumKeys` silently falls back to v10-only import even after the user denies access. The classification depends on English words in `stderr`; force `secret-tool` to use a stable locale or use a locale-independent result to preserve the `needsKeychainApproval` path. |
Fixed in bae4ae9fb414e14abe8a7e1ea6ea8eecc5bcacdc. secret-tool inherits the existing session environment with LC_ALL=C, giving denial classification stable diagnostics without changing the consent flow. The focused test checks that the command preserves inherited environment values and that a denied request remains needsKeychainApproval instead of taking the fallback path. ChromiumKeys tests and desktop typecheck pass.
apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts · Earlier revision 2f2f7d0c · right 116–118
Original diff
@@ -102,100 +108,96 @@ const toUnixSeconds = (webkitSeconds: number): number | undefined => {
};
/**
- * Reads the OSCrypt key from the login keychain.
- *
- * 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".
+ * Chromium >= 127 prefixes the plaintext with SHA-256 of the host key, binding
+ * a cookie to its domain. Strip it when present.
*/
-const readMacKeychainPassword = Effect.fn("ChromiumCookies.readMacKeychainPassword")(function* (
- service: string,
- account: string,
- cookieDatabasePath: string,
-) {
- 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 decryptValue = (encrypted: Uint8Array, key: Buffer, domain: string): string | null => {
- const buffer = Buffer.from(encrypted);
- if (buffer.length === 0) return "";
- if (buffer.subarray(0, 3).toString("latin1") !== V10_PREFIX) return null;
+const stripDomainBinding = (plaintext: Buffer, domain: string): Buffer => {
+ const domainHash = NodeCrypto.createHash("sha256").update(domain).digest();
+ return plaintext.length >= 32 && plaintext.subarray(0, 32).equals(domainHash)
+ ? plaintext.subarray(32)
+ : plaintext;apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts · right 116–118
🟡 Medium BrowserImport/ChromiumCookies.ts:116
A record with a domain hash that does not match domain is returned as plaintext, so the 32-byte hash is decoded into the cookie value and the corrupted or moved cookie is imported instead of rejected. Make stripDomainBinding reject mismatched hashes; the surrounding decryptors will then return null.
✅ Resolved in c188be9758bab40e0a9d93176c917030b2936b4b
Fixed downstack and retained in #7261. Domain binding is required according to meta.version >=24; mismatched bound records are skipped instead of imported as corrupted values. Older long plaintext is preserved. Fixtures use TEXT-affinity version metadata as Chromium does.
apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts · Earlier revision 2eff79bb · right 169–174
Original diff
@@ -116,136 +118,105 @@ const toUnixSeconds = (webkitSeconds: number): number | undefined => {
};
/**
- * Reads the OSCrypt key from the login keychain.
- *
- * 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".
+ * Chromium >= 127 prefixes the plaintext with SHA-256 of the host key, binding
+ * a cookie to its domain. Strip it when present.
*/
-const readMacKeychainPassword = Effect.fn("ChromiumCookies.readMacKeychainPassword")(function* (
- service: string,
- account: string,
- cookieDatabasePath: string,
-) {
- 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 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 decryptValue = (
- encrypted: Uint8Array,
+const decryptCbc = (
+ payload: Buffer,
key: Buffer,
domain: string,
schemaVersion: number,
- platform: NodeJS.Platform,
): string | null => {
- const buffer = Buffer.from(encrypted);
- 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;
- }
- plaintext = plaintext.subarray(32);
- }
- return plaintext.toString("utf8");
+ const decipher = NodeCrypto.createDecipheriv("aes-128-cbc", key, AES_CBC_IV);
+ decipher.setAutoPadding(true);
+ const plaintext = Buffer.concat([decipher.update(payload), decipher.final()]);
+ return stripDomainBinding(plaintext, domain, schemaVersion)?.toString("utf8") ?? null;
} 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);
+
+ if (prefix === "v10") {
+ return keys.cbcV10 ? decryptCbc(payload, keys.cbcV10, domain, schemaVersion) : null;
+ }
+ if (prefix === "v11") {
+ return keys.cbcV11 ? decryptCbc(payload, keys.cbcV11, domain, schemaVersion) : null;
+ }apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts · right 169–174
This prefix dispatch is the behavior change this PR is about (a Linux database mixing v10/v11, with unkeyed records skipped rather than failing the import), but no test exercises it: ChromiumKeys.test.ts only asserts which keys get derived, and every ChromiumCookies.test.ts case still uses a v10 fixture with { cbcV10 }. Consider adding a focused case to ChromiumCookies.test.ts — a database holding one v10 and one v11 record read with { cbcV10, cbcV11 } (both decrypt), and the same database read with { cbcV10 } only (the v11 row lands in undecryptable/undecryptableHosts instead of failing the read). The existing encryptV10 helper generalises by parameterising the prefix and key.
Posted via Macroscope — Effect Service Conventions
apps/desktop/src/preview/BrowserImport/ChromiumKeys.test.ts · Earlier revision 506b87e3 · right 1–2
Original diff
@@ -0,0 +1,180 @@
+// @effect-diagnostics nodeBuiltinImport:off
+import { describe, expect, it } from "@effect/vitest";apps/desktop/src/preview/BrowserImport/ChromiumKeys.test.ts · right 1–2
This new suppression directive has no adjacent explanation, unlike every sibling in this directory (ChromiumCookies.test.ts, Sources.test.ts, FirefoxCookies.test.ts, ChromiumKeys.ts all state why). It also looks unnecessary here: the file imports no node: builtin and uses no Node global, so the diagnostic it disables cannot fire. Consider dropping the line; if it is needed for something I am missing, please add the reason next to it.
| 1 | import { describe, expect, it } from "@effect/vitest"; |
Posted via Macroscope — Effect Service Conventions
apps/desktop/undefined/Profiles/gx7x7fqx.default-release/cookies.sqlite · Earlier revision 3a26a9dc · right 1
Original diff
@@ -0,0 +1 @@ +db
Accidental profile fixture committed
Low Severity
A dummy cookies.sqlite was committed under apps/desktop/undefined/Profiles/. That undefined segment is a leftover from a run whose home directory was unset, and nothing in the import code or tests reads this path.
Reviewed by Cursor Bugbot for commit 3a26a9dc586bfe8e1253a2d25ff619edaa9262db. Configure here.
apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts · Earlier revision 4a75fb24 · right 237
Original diff
@@ -254,12 +234,13 @@ export const readChromiumCookies = Effect.fn("ChromiumCookies.readChromiumCookie
let undecryptable = 0;
const undecryptableHosts = new Set<string>();
for (const row of rows) {
- const value = decryptValue(row.encrypted_value, key, row.host_key);
+ const value = decryptChromiumValue(row.encrypted_value, keys, row.host_key);🟠 High BrowserImport/ChromiumCookies.ts:237
Linux imports blank out plaintext cookies: when encrypted_value is empty, decryptChromiumValue returns "" even though Chromium stores the real value in the value column. The query does not select that column, so use row.value for empty encrypted blobs (while decrypting non-empty blobs) and include it in CookieRow.
✅ Resolved in c188be9758bab40e0a9d93176c917030b2936b4b