diff --git a/apps/desktop/src/preview/BrowserImport/BrowserImport.ts b/apps/desktop/src/preview/BrowserImport/BrowserImport.ts index 243fd539a8f..169a48c3b62 100644 --- a/apps/desktop/src/preview/BrowserImport/BrowserImport.ts +++ b/apps/desktop/src/preview/BrowserImport/BrowserImport.ts @@ -18,6 +18,7 @@ import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import { HostProcessExecutablePath, HostProcessPlatform } from "@t3tools/shared/hostProcess"; @@ -34,6 +35,7 @@ import { isSourceInstalled, isSourceRunning, listSourceProfiles, + localStatePath, sourcePathContext, type BrowserImportPathContext, type BrowserImportSourceDefinition, @@ -73,11 +75,6 @@ const unavailableReason = Effect.fn("BrowserImport.unavailableReason")(function* context: BrowserImportPathContext, ): Effect.fn.Return { if (!definition.platforms.includes(context.platform)) return "unsupportedPlatform"; - // Chromium's key lives in an OS credential store, and only the macOS one is - // implemented; Firefox needs no key at all, so it works everywhere. - if (definition.engine === "chromium" && context.platform !== "darwin") { - return "unsupportedPlatform"; - } if (!(yield* isSourceInstalled(definition, context))) return "notInstalled"; if (yield* isSourceRunning(definition, context)) return "browserRunning"; return undefined; @@ -98,7 +95,9 @@ export const make = Effect.gen(function* BrowserImportMake() { const executablePath = yield* HostProcessExecutablePath; // Captured here so the service's methods stay free of a requirements // channel: the layer is built where NodeServices is already in scope. - const platformServices = yield* Effect.context(); + const platformServices = yield* Effect.context< + FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner + >(); const pathContext = yield* sourcePathContext; const listSources: Effect.Effect> = Effect.forEach( @@ -179,7 +178,7 @@ export const make = Effect.gen(function* BrowserImportMake() { const read: Effect.Effect< CookieReadResult, ChromiumCookieReadError | FirefoxCookieReadError, - FileSystem.FileSystem | Path.Path | Scope.Scope + FileSystem.FileSystem | Path.Path | Scope.Scope | ChildProcessSpawner.ChildProcessSpawner > = definition.engine === "firefox" ? readFirefoxCookies(databasePath).pipe( @@ -187,10 +186,11 @@ export const make = Effect.gen(function* BrowserImportMake() { ) : readChromiumCookies({ cookieDatabasePath: databasePath, - // Only reached on macOS: `unavailableReason` rejects Chromium - // elsewhere until those key stores are implemented. - keychainService: definition.keychainService ?? "", - keychainAccount: definition.keychainAccount ?? "", + // Only Windows reads it; the other platforms take their key from a + // credential store and ignore the path entirely. + localStatePath: localStatePath(definition, pathContext) ?? "", + keychainService: definition.keychainService, + keychainAccount: definition.keychainAccount, platform, }); diff --git a/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts index 0f2195c1586..ed5a9157e30 100644 --- a/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts +++ b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts @@ -1,26 +1,138 @@ +// @effect-diagnostics nodeBuiltinImport:off import { describe, expect, it } from "@effect/vitest"; +import * as NodeCrypto from "node:crypto"; -import { cookieScope } from "./CookieDatabase.ts"; +import { decryptChromiumValue } from "./ChromiumCookies.ts"; +import { stripDpapiMarker } from "./ChromiumKeys.ts"; -describe("cookieScope", () => { - it("keeps a host-only cookie host-only", () => { - // Chromium stores a host-only cookie without a leading dot. Passing any - // `domain` to Electron makes it a domain cookie and re-adds the dot, which - // would expose the cookie to every subdomain it was never scoped to. - expect(cookieScope("example.test", "/", true)).toEqual({ - url: "https://example.test/", - domain: undefined, - }); +const SALT = "saltysalt"; +const CBC_IV = Buffer.alloc(16, 0x20); + +const deriveCbcKey = (passphrase: string, iterations: number) => + NodeCrypto.pbkdf2Sync(passphrase, SALT, iterations, 16, "sha1"); + +/** Encrypts the way Chromium does on macOS/Linux, including the v127+ domain binding. */ +function encryptCbc(prefix: string, value: string, key: Buffer, domain?: string): Buffer { + const body = domain + ? Buffer.concat([NodeCrypto.createHash("sha256").update(domain).digest(), Buffer.from(value)]) + : Buffer.from(value); + const cipher = NodeCrypto.createCipheriv("aes-128-cbc", key, CBC_IV); + return Buffer.concat([Buffer.from(prefix, "latin1"), cipher.update(body), cipher.final()]); +} + +/** Encrypts the way Chromium does on Windows: v10 + 12-byte nonce + body + 16-byte tag. */ +function encryptGcm(value: string, key: Buffer, domain?: string): Buffer { + const nonce = NodeCrypto.randomBytes(12); + const cipher = NodeCrypto.createCipheriv("aes-256-gcm", key, nonce); + const body = domain + ? Buffer.concat([NodeCrypto.createHash("sha256").update(domain).digest(), Buffer.from(value)]) + : Buffer.from(value); + const encrypted = Buffer.concat([cipher.update(body), cipher.final()]); + return Buffer.concat([Buffer.from("v10", "latin1"), nonce, encrypted, cipher.getAuthTag()]); +} + +describe("decryptChromiumValue", () => { + it("decrypts macOS records, stripping the domain binding", () => { + // macOS stretches the keychain secret over 1003 iterations. + const key = deriveCbcKey("mac-keychain-secret", 1003); + + expect( + decryptChromiumValue( + encryptCbc("v10", "abc", key, ".github.com"), + { cbcV10: key }, + ".github.com", + ), + ).toBe("abc"); + // Pre-127 records carry no domain hash. + expect( + decryptChromiumValue(encryptCbc("v10", "abc", key), { cbcV10: key }, ".github.com"), + ).toBe("abc"); + }); + + it("decrypts Linux v10 with the fallback passphrase and v11 with the keyring secret", () => { + // Both schemes can appear in one database, so both keys are held and the + // record's prefix picks between them. + const fallback = deriveCbcKey("peanuts", 1); + const keyring = deriveCbcKey("keyring-secret", 1); + const keys = { cbcV10: fallback, cbcV11: keyring }; + + expect(decryptChromiumValue(encryptCbc("v10", "no-keyring", fallback), keys, "a.test")).toBe( + "no-keyring", + ); + expect(decryptChromiumValue(encryptCbc("v11", "with-keyring", keyring), keys, "a.test")).toBe( + "with-keyring", + ); + }); + + it("skips v11 records when no keyring secret was obtainable", () => { + // A locked or absent Secret Service must degrade to a partial import + // rather than failing everything. + const fallback = deriveCbcKey("peanuts", 1); + const keyring = deriveCbcKey("keyring-secret", 1); + + expect( + decryptChromiumValue(encryptCbc("v11", "x", keyring), { cbcV10: fallback }, "a.test"), + ).toBeNull(); + expect( + decryptChromiumValue(encryptCbc("v10", "kept", fallback), { cbcV10: fallback }, "a.test"), + ).toBe("kept"); + }); + + it("decrypts Windows AES-GCM records", () => { + const key = NodeCrypto.randomBytes(32); + + expect( + decryptChromiumValue( + encryptGcm("win", key, ".example.test"), + { gcmV10: key }, + ".example.test", + ), + ).toBe("win"); }); - it("preserves a domain cookie's leading dot", () => { - expect(cookieScope(".example.test", "/app", true)).toEqual({ - url: "https://example.test/app", - domain: ".example.test", - }); + it("skips app-bound v20 records instead of failing", () => { + // v20 is bound to the browser binary and unreadable by design; the import + // reports it as skipped rather than erroring out. + const key = NodeCrypto.randomBytes(32); + const v20 = Buffer.concat([Buffer.from("v20", "latin1"), NodeCrypto.randomBytes(48)]); + + expect(decryptChromiumValue(v20, { gcmV10: key }, "a.test")).toBeNull(); + }); + + it("returns an empty value for an unencrypted empty record", () => { + expect(decryptChromiumValue(new Uint8Array(), {}, "a.test")).toBe(""); + }); + + it("returns null when a key is wrong rather than throwing", () => { + const right = deriveCbcKey("right", 1003); + const wrong = deriveCbcKey("wrong", 1003); + + expect( + decryptChromiumValue(encryptCbc("v10", "v", right), { cbcV10: wrong }, "a.test"), + ).toBeNull(); + }); +}); + +describe("stripDpapiMarker", () => { + it("removes the DPAPI prefix Windows writes in front of the key", () => { + const wrapped = Buffer.concat([Buffer.from("DPAPI", "latin1"), Buffer.from([1, 2, 3])]); + + expect([...stripDpapiMarker(wrapped.toString("base64"))]).toEqual([1, 2, 3]); }); - it("matches the scheme to the secure flag", () => { - expect(cookieScope("example.test", "/", false).url).toBe("http://example.test/"); + it("leaves a key without the marker untouched", () => { + expect([...stripDpapiMarker(Buffer.from([9, 8]).toString("base64"))]).toEqual([9, 8]); + }); +}); + +describe("decryptChromiumValue with unusable key material", () => { + it("returns null rather than a wrong plaintext for a zero-length GCM key", () => { + // A failed DPAPI unwrap used to produce an empty Buffer, which is truthy, + // so the key looked present and every record silently failed to decrypt. + // `resolveChromiumKeys` now refuses to hand one back; this pins the + // behaviour of the decrypt path if one ever reaches it. + const record = Buffer.concat([Buffer.from("v10", "latin1"), Buffer.alloc(40, 7)]); + + expect(decryptChromiumValue(record, { gcmV10: Buffer.alloc(0) }, "example.test")).toBeNull(); }); }); diff --git a/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts index cfbffeea978..e1c3e819d2b 100644 --- a/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts +++ b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts @@ -3,25 +3,34 @@ /** * Chromium cookie extraction. * - * Reads a Chromium-family browser's cookie database and decrypts it with the - * key the OS keychain hands us, which is the mechanism the browser itself - * uses. macOS mediates that with a per-app consent prompt, so the user - * explicitly approves T3 Code reading it. + * Reads a Chromium-family browser's cookie database and decrypts each record + * with the key its prefix calls for. Key acquisition — and the consent it + * needs — lives in `ChromiumKeys`. * - * Deliberately no fallback when the keychain says no: the alternative - * techniques exist to defeat that consent, and this feature is not worth - * shipping them. + * Records whose scheme we hold no key for are skipped rather than failing the + * whole import: a Linux database can mix `v10` and `v11`, and a Windows one + * can mix `v10` and app-bound `v20`. A partial result reported honestly is + * more useful than an all-or-nothing error. * * @module ChromiumCookies */ -import * as Keyring from "@napi-rs/keyring"; import * as NodeCrypto from "node:crypto"; import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import { + ChromiumKeyError, + ChromiumKeyFailure, + resolveChromiumKeys, + type ChromiumKeyMaterial, +} from "./ChromiumKeys.ts"; import { bareHost, cookieScope, @@ -29,22 +38,22 @@ import { type ImportedCookie, } from "./CookieDatabase.ts"; -/** macOS OSCrypt parameters. Chromium has used these since the feature landed. */ -const MAC_KEY_ITERATIONS = 1003; -const MAC_KEY_SALT = "saltysalt"; -const MAC_KEY_LENGTH = 16; -/** OSCrypt uses a fixed IV of 16 spaces rather than a per-record one. */ -const AES_IV = Buffer.alloc(16, 0x20); -const V10_PREFIX = "v10"; +/** OSCrypt's CBC mode uses a fixed IV of 16 spaces rather than a per-record one. */ +const AES_CBC_IV = Buffer.alloc(16, 0x20); +/** Windows AES-GCM records carry a 12-byte nonce and a 16-byte tag. */ +const GCM_NONCE_LENGTH = 12; +const GCM_TAG_LENGTH = 16; export type ChromiumCookie = ImportedCookie; +/** + * Every way the read can fail: the key failures, plus the ones this module + * raises itself. + */ export const ChromiumCookieReadReason = Schema.Literals([ - "needsKeychainApproval", - "keychainItemMissing", + // `readFailed` already comes from the key failures, so it is not repeated. + ...ChromiumKeyFailure.literals, "browserRunning", - "unsupportedPlatform", - "readFailed", ]); export type ChromiumCookieReadReason = typeof ChromiumCookieReadReason.Type; @@ -78,7 +87,6 @@ const CookieRow = Schema.Struct({ is_httponly: Schema.Number, samesite: Schema.Number, }); - const decodeCookieRows = Schema.decodeUnknownEffect(Schema.Array(CookieRow)); /** @@ -86,7 +94,7 @@ const decodeCookieRows = Schema.decodeUnknownEffect(Schema.Array(CookieRow)); * modern Chromium, so it maps there rather than to `no_restriction`, which * would widen the cookie's scope on import. */ -const sameSiteFromColumn = (value: number): ChromiumCookie["sameSite"] => { +const sameSiteFromColumn = (value: number): ImportedCookie["sameSite"] => { if (value === 0) return "no_restriction"; if (value === 2) return "strict"; return "lax"; @@ -94,11 +102,9 @@ const sameSiteFromColumn = (value: number): ChromiumCookie["sameSite"] => { /** * Chromium timestamps count microseconds from 1601-01-01; Electron wants - * seconds from the UNIX epoch. - * - * The microsecond value overflows JavaScript's safe integer range, and - * `node:sqlite` refuses to narrow it, so the division happens in SQL and this - * only ever sees seconds. + * seconds from the UNIX epoch. The microsecond value overflows JavaScript's + * safe integer range and `node:sqlite` refuses to narrow it, so the division + * happens in SQL and this only ever sees seconds. */ const WEBKIT_EPOCH_OFFSET_SECONDS = 11_644_473_600; const toUnixSeconds = (webkitSeconds: number): number | undefined => { @@ -107,64 +113,22 @@ 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; +}; +const decryptCbc = (payload: Buffer, key: Buffer, domain: string): string | null => { try { - const decipher = NodeCrypto.createDecipheriv("aes-128-cbc", key, AES_IV); + const decipher = NodeCrypto.createDecipheriv("aes-128-cbc", key, AES_CBC_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 plaintext = Buffer.concat([decipher.update(payload), decipher.final()]); + return stripDomainBinding(plaintext, domain).toString("utf8"); } catch { return null; } @@ -182,37 +146,75 @@ export interface CookieReadResult { readonly undecryptableHosts: ReadonlyArray; } +const decryptGcm = (payload: Buffer, key: Buffer, domain: string): string | null => { + try { + 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; + readonly localStatePath: string; + readonly keychainService: string | undefined; + readonly keychainAccount: string | undefined; /** Supplied by the caller from `HostProcessPlatform` rather than read here. */ readonly platform: NodeJS.Platform; } export const readChromiumCookies = Effect.fn("ChromiumCookies.readChromiumCookies")(function* ( source: ChromiumCookieSource, -) { - if (source.platform !== "darwin") { - // Linux (libsecret) and Windows (DPAPI, and App-Bound Encryption on - // current Chrome) each need their own key path; only macOS is implemented. - return yield* new ChromiumCookieReadError({ - reason: "unsupportedPlatform", - cookieDatabasePath: source.cookieDatabasePath, - }); - } - - const password = yield* readMacKeychainPassword( - source.keychainService, - source.keychainAccount, - source.cookieDatabasePath, - ); - const key = NodeCrypto.pbkdf2Sync( - password, - MAC_KEY_SALT, - MAC_KEY_ITERATIONS, - MAC_KEY_LENGTH, - "sha1", +): Effect.fn.Return< + CookieReadResult, + ChromiumCookieReadError, + FileSystem.FileSystem | ChildProcessSpawner.ChildProcessSpawner | Path.Path | Scope.Scope +> { + const keys = yield* resolveChromiumKeys({ + platform: source.platform, + keychainService: source.keychainService, + keychainAccount: source.keychainAccount, + localStatePath: source.localStatePath, + }).pipe( + Effect.mapError( + (cause: ChromiumKeyError) => + new ChromiumCookieReadError({ + reason: cause.reason, + cookieDatabasePath: source.cookieDatabasePath, + cause, + }), + ), ); const snapshotPath = yield* snapshotCookieDatabase(source.cookieDatabasePath).pipe( @@ -254,12 +256,13 @@ export const readChromiumCookies = Effect.fn("ChromiumCookies.readChromiumCookie let undecryptable = 0; const undecryptableHosts = new Set(); 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) { undecryptable += 1; undecryptableHosts.add(bareHost(row.host_key)); continue; } + const secure = row.is_secure === 1; const scope = cookieScope(row.host_key, row.path, secure); cookies.push({ diff --git a/apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts b/apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts new file mode 100644 index 00000000000..245ed26cf91 --- /dev/null +++ b/apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts @@ -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", + { + 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; + + // 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, + // Only the spawn failure is translated — no `powershell.exe`, or one + // that cannot be launched, is a read failure rather than a browser with + // no key. A `ChromiumKeyError` already says what went wrong and passes + // through untouched. + Effect.catchTags({ + PlatformError: (cause) => + Effect.fail(new ChromiumKeyError({ reason: "readFailed", cause })), + }), + ); + return { gcmV10: unwrapped }; + } + + return yield* new ChromiumKeyError({ reason: "unsupportedPlatform" }); +}); diff --git a/apps/desktop/src/preview/BrowserImport/CookieDatabase.test.ts b/apps/desktop/src/preview/BrowserImport/CookieDatabase.test.ts index da1392f0345..e99eeaaf3d4 100644 --- a/apps/desktop/src/preview/BrowserImport/CookieDatabase.test.ts +++ b/apps/desktop/src/preview/BrowserImport/CookieDatabase.test.ts @@ -1,9 +1,9 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, describe, it } from "@effect/vitest"; +import { assert, describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; -import { snapshotCookieDatabase } from "./CookieDatabase.ts"; +import { cookieScope, snapshotCookieDatabase } from "./CookieDatabase.ts"; const run = (effect: Effect.Effect) => effect; @@ -61,3 +61,26 @@ describe("snapshotCookieDatabase", () => { ), ); }); + +describe("cookieScope", () => { + it("keeps a host-only cookie host-only", () => { + // Both engines store a host-only cookie without a leading dot. Passing any + // `domain` to Electron makes it a domain cookie and re-adds the dot, which + // would expose the cookie to every subdomain it was never scoped to. + expect(cookieScope("example.test", "/", true)).toEqual({ + url: "https://example.test/", + domain: undefined, + }); + }); + + it("preserves a domain cookie's leading dot", () => { + expect(cookieScope(".example.test", "/app", true)).toEqual({ + url: "https://example.test/app", + domain: ".example.test", + }); + }); + + it("matches the scheme to the secure flag", () => { + expect(cookieScope("example.test", "/", false).url).toBe("http://example.test/"); + }); +}); diff --git a/apps/desktop/src/preview/BrowserImport/Sources.ts b/apps/desktop/src/preview/BrowserImport/Sources.ts index 20b954642c2..b35129bf0de 100644 --- a/apps/desktop/src/preview/BrowserImport/Sources.ts +++ b/apps/desktop/src/preview/BrowserImport/Sources.ts @@ -202,6 +202,15 @@ export const cookieDatabasePath = ( : context.path.join(root, profileDirectory, fileName); }; +/** Chromium keeps the DPAPI-wrapped Windows key in `Local State`. */ +export const localStatePath = ( + definition: BrowserImportSourceDefinition, + context: BrowserImportPathContext, +): string | undefined => { + const root = definition.userDataDirectory(context); + return root === undefined ? undefined : context.path.join(root, "Local State"); +}; + /** * Firefox records its profiles in `profiles.ini`. `Install*` sections point at * a default profile but do not describe one, so only `[ProfileN]` blocks diff --git a/packages/contracts/src/browserImport.ts b/packages/contracts/src/browserImport.ts index 70d7f8ae6b6..f9804ae94c8 100644 --- a/packages/contracts/src/browserImport.ts +++ b/packages/contracts/src/browserImport.ts @@ -42,6 +42,7 @@ export const BrowserImportUnavailableReason = Schema.Literals([ "notInstalled", "needsKeychainApproval", "keychainItemMissing", + "appBoundEncryption", "browserRunning", "unsupportedPlatform", ]); @@ -132,6 +133,7 @@ export const BROWSER_IMPORT_UNAVAILABLE_COPY: Readonly< needsKeychainApproval: "Needs Keychain access to read its cookies.", keychainItemMissing: "No encryption key in your Keychain — sign in to that browser once, then retry.", + appBoundEncryption: "This browser binds its cookie key to itself, so no other app can read it.", browserRunning: "Quit the browser first so its cookie database can be read.", unsupportedPlatform: "Importing from this browser isn't possible on this platform.", };