Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 11 additions & 11 deletions apps/desktop/src/preview/BrowserImport/BrowserImport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -34,6 +35,7 @@ import {
isSourceInstalled,
isSourceRunning,
listSourceProfiles,
localStatePath,
sourcePathContext,
type BrowserImportPathContext,
type BrowserImportSourceDefinition,
Expand Down Expand Up @@ -73,11 +75,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";
// 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;
Expand All @@ -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<FileSystem.FileSystem | Path.Path>();
const platformServices = yield* Effect.context<
FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner
>();
const pathContext = yield* sourcePathContext;

const listSources: Effect.Effect<ReadonlyArray<BrowserImportSource>> = Effect.forEach(
Expand Down Expand Up @@ -179,18 +178,19 @@ 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(
Effect.map((cookies) => ({ cookies, undecryptable: 0, undecryptableHosts: [] })),
)
: 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,
});

Expand Down
146 changes: 129 additions & 17 deletions apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading
Loading