Skip to content

Commit a771eb6

Browse files
feat(desktop): import Helium cookies on Windows
1 parent 296acd5 commit a771eb6

8 files changed

Lines changed: 292 additions & 17 deletions

File tree

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,7 @@ export const make = Effect.gen(function* BrowserImportMake() {
254254
// identifiable and each tag is handled on its own below. The success side
255255
// is normalized to one shape too, so the skipped tally survives either
256256
// engine — Firefox stores plaintext, so nothing there is ever unreadable.
257+
const userDataDirectory = definition.userDataDirectory(pathContext);
257258
const read: Effect.Effect<
258259
CookieReadResult,
259260
ChromiumCookieReadError | FirefoxCookieReadError,
@@ -268,6 +269,11 @@ export const make = Effect.gen(function* BrowserImportMake() {
268269
keychainService: definition.keychainService,
269270
keychainAccount: definition.keychainAccount,
270271
linuxSecretApplication: definition.linuxSecretApplication,
272+
...(platform === "win32" && userDataDirectory !== undefined
273+
? {
274+
windowsLocalStatePath: pathContext.path.join(userDataDirectory, "Local State"),
275+
}
276+
: {}),
271277
platform,
272278
});
273279

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

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@ import * as Effect from "effect/Effect";
88
import * as FileSystem from "effect/FileSystem";
99
import * as SqlClient from "effect/unstable/sql/SqlClient";
1010

11-
import { readChromiumCookieDatabase, readChromiumCookies } from "./ChromiumCookies.ts";
11+
import {
12+
decryptChromiumValue,
13+
readChromiumCookieDatabase,
14+
readChromiumCookies,
15+
} from "./ChromiumCookies.ts";
1216
import { ChromiumKeyError } from "./ChromiumKeys.ts";
1317
import { LinuxBrowserSecretPath } from "./LinuxBrowserSecret.ts";
1418
import { cookieScope } from "./CookieDatabase.ts";
@@ -25,6 +29,13 @@ const encryptChromium = (
2529
const encryptV10 = (value: string | Buffer, key: Buffer): Uint8Array =>
2630
encryptChromium("v10", value, key);
2731

32+
const encryptWindowsV10 = (value: string | Buffer, key: Buffer): Uint8Array => {
33+
const nonce = Buffer.from("0123456789ab");
34+
const cipher = NodeCrypto.createCipheriv("aes-256-gcm", key, nonce);
35+
const encrypted = Buffer.concat([cipher.update(value), cipher.final()]);
36+
return Buffer.concat([Buffer.from("v10"), nonce, encrypted, cipher.getAuthTag()]);
37+
};
38+
2839
describe("cookieScope", () => {
2940
it("keeps a host-only cookie host-only", () => {
3041
// Chromium stores a host-only cookie without a leading dot. Passing any
@@ -60,6 +71,31 @@ describe("cookieScope", () => {
6071
});
6172

6273
describe("readChromiumCookieDatabase", () => {
74+
it("decrypts Windows v10 AES-GCM records and rejects app-bound v20 records", () => {
75+
const key = Buffer.from("0123456789abcdef0123456789abcdef");
76+
const host = ".example.test";
77+
const bound = Buffer.concat([
78+
NodeCrypto.createHash("sha256").update(host).digest(),
79+
Buffer.from("windows value"),
80+
]);
81+
82+
expect(
83+
decryptChromiumValue(encryptWindowsV10(bound, key), { gcmV10: key }, host, 24, "win32"),
84+
).toBe("windows value");
85+
expect(
86+
decryptChromiumValue(Buffer.from("v20app-bound"), { gcmV10: key }, host, 24, "win32"),
87+
).toBeNull();
88+
expect(
89+
decryptChromiumValue(
90+
encryptWindowsV10(bound, Buffer.alloc(32, 1)),
91+
{ gcmV10: key },
92+
host,
93+
24,
94+
"win32",
95+
),
96+
).toBeNull();
97+
});
98+
6399
it.effect(
64100
"reports the missing key when no cookies can be read, while preserving partial imports",
65101
() =>

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

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { ChildProcessSpawner } from "effect/unstable/process";
2727
import {
2828
ChromiumKeyError,
2929
ChromiumKeyFailure,
30+
readWindowsKey,
3031
resolveChromiumKeys,
3132
type ChromiumKeyMaterial,
3233
} from "./ChromiumKeys.ts";
@@ -40,6 +41,8 @@ import {
4041

4142
/** OSCrypt's CBC mode uses a fixed IV of 16 spaces rather than a per-record one. */
4243
const AES_CBC_IV = Buffer.alloc(16, 0x20);
44+
const AES_GCM_NONCE_LENGTH = 12;
45+
const AES_GCM_TAG_LENGTH = 16;
4346
const isChromiumKeyError = Schema.is(ChromiumKeyError);
4447

4548
/**
@@ -153,6 +156,26 @@ const decryptCbc = (
153156
}
154157
};
155158

159+
const decryptGcm = (
160+
payload: Buffer,
161+
key: Buffer,
162+
domain: string,
163+
schemaVersion: number,
164+
): string | null => {
165+
if (payload.length < AES_GCM_NONCE_LENGTH + AES_GCM_TAG_LENGTH) return null;
166+
try {
167+
const nonce = payload.subarray(0, AES_GCM_NONCE_LENGTH);
168+
const ciphertext = payload.subarray(AES_GCM_NONCE_LENGTH, -AES_GCM_TAG_LENGTH);
169+
const tag = payload.subarray(-AES_GCM_TAG_LENGTH);
170+
const decipher = NodeCrypto.createDecipheriv("aes-256-gcm", key, nonce);
171+
decipher.setAuthTag(tag);
172+
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
173+
return stripDomainBinding(plaintext, domain, schemaVersion)?.toString("utf8") ?? null;
174+
} catch {
175+
return null;
176+
}
177+
};
178+
156179
/**
157180
* Decrypts one stored value, choosing the scheme from its prefix. Returns null
158181
* when no key covers that scheme — including Windows' app-bound `v20`, which
@@ -170,6 +193,14 @@ export function decryptChromiumValue(
170193
const prefix = buffer.subarray(0, 3).toString("latin1");
171194
const payload = buffer.subarray(3);
172195

196+
// Windows' legacy v10 format is AES-256-GCM. App-bound records use v20 and
197+
// intentionally have no key here, so they fall through as undecryptable.
198+
if (platform === "win32") {
199+
return prefix === "v10" && keys.gcmV10
200+
? decryptGcm(payload, keys.gcmV10, domain, schemaVersion)
201+
: null;
202+
}
203+
173204
// Chromium retries a failed record with a key derived from an empty
174205
// passphrase, because some Linux clients wrote data that way
175206
// (crbug.com/1195256). A record whose own key is missing entirely stays
@@ -283,6 +314,7 @@ export interface ChromiumCookieSource {
283314
readonly keychainService: string | undefined;
284315
readonly keychainAccount: string | undefined;
285316
readonly linuxSecretApplication: string | undefined;
317+
readonly windowsLocalStatePath?: string;
286318
/** Supplied by the caller from `HostProcessPlatform` rather than read here. */
287319
readonly platform: NodeJS.Platform;
288320
}
@@ -294,12 +326,16 @@ export const readChromiumCookies = Effect.fn("ChromiumCookies.readChromiumCookie
294326
ChromiumCookieReadError,
295327
FileSystem.FileSystem | Path.Path | Scope.Scope | ChildProcessSpawner.ChildProcessSpawner
296328
> {
297-
const keys = yield* resolveChromiumKeys({
298-
platform: source.platform,
299-
keychainService: source.keychainService,
300-
keychainAccount: source.keychainAccount,
301-
linuxSecretApplication: source.linuxSecretApplication,
302-
}).pipe(
329+
const keys = yield* (
330+
source.platform === "win32" && source.windowsLocalStatePath
331+
? readWindowsKey(source.windowsLocalStatePath).pipe(Effect.map((gcmV10) => ({ gcmV10 })))
332+
: resolveChromiumKeys({
333+
platform: source.platform,
334+
keychainService: source.keychainService,
335+
keychainAccount: source.keychainAccount,
336+
linuxSecretApplication: source.linuxSecretApplication,
337+
})
338+
).pipe(
303339
Effect.mapError(
304340
(cause: ChromiumKeyError) =>
305341
new ChromiumCookieReadError({

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

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,13 @@ import * as Stream from "effect/Stream";
88
import * as PlatformError from "effect/PlatformError";
99
import { ChildProcessSpawner } from "effect/unstable/process";
1010

11-
import { ChromiumKeyError, readLinuxSecret, resolveChromiumKeys } from "./ChromiumKeys.ts";
11+
import {
12+
ChromiumKeyError,
13+
decodeWindowsWrappedKey,
14+
readLinuxSecret,
15+
resolveChromiumKeys,
16+
unwrapWindowsDpapiKey,
17+
} from "./ChromiumKeys.ts";
1218
import { LinuxBrowserSecretPath } from "./LinuxBrowserSecret.ts";
1319

1420
type CapturedCommand = {
@@ -226,3 +232,43 @@ describe("Linux Chromium secrets", () => {
226232
}).pipe(Effect.provide(helperLayer({ exitCode: 2 }))),
227233
);
228234
});
235+
236+
describe("Windows Chromium secrets", () => {
237+
it.effect("accepts only DPAPI-wrapped non-app-bound keys", () =>
238+
Effect.gen(function* () {
239+
const wrapped = Buffer.from("wrapped-key");
240+
const encoded = Buffer.concat([Buffer.from("DPAPI"), wrapped]).toString("base64");
241+
const localState = `{"os_crypt":{"encrypted_key":"${encoded}"}}`;
242+
expect(yield* decodeWindowsWrappedKey(localState)).toEqual(wrapped);
243+
244+
const appBound = yield* decodeWindowsWrappedKey(
245+
`{"os_crypt":{"encrypted_key":"${encoded}","app_bound_encrypted_key":"present"}}`,
246+
).pipe(Effect.flip);
247+
expect(appBound.reason).toBe("unsupportedPlatform");
248+
249+
const malformed = yield* decodeWindowsWrappedKey(
250+
`{"os_crypt":{"encrypted_key":"${wrapped.toString("base64")}"}}`,
251+
).pipe(Effect.flip);
252+
expect(malformed.reason).toBe("readFailed");
253+
}),
254+
);
255+
256+
it.effect("unwraps the binary key through PowerShell without placing it in argv", () => {
257+
let captured: CapturedCommand | undefined;
258+
const wrapped = Buffer.from("wrapped-key");
259+
const key = Buffer.from("0123456789abcdef0123456789abcdef");
260+
return Effect.gen(function* () {
261+
expect(yield* unwrapWindowsDpapiKey(wrapped)).toEqual(key);
262+
expect(captured?.command).toBe(
263+
"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe",
264+
);
265+
expect(captured?.args).toContain("-NonInteractive");
266+
expect(captured?.args.join(" ")).not.toContain(wrapped.toString("base64"));
267+
}).pipe(
268+
Effect.provide(
269+
helperLayer({ stdout: key.toString("base64"), capture: (value) => (captured = value) }),
270+
),
271+
Effect.provideService(HostProcessEnvironment, { SystemRoot: "C:\\Windows" }),
272+
);
273+
});
274+
});

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

Lines changed: 112 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@
1212
* can appear in the same database, so both are derived up front and chosen
1313
* per record.
1414
*
15-
* Windows is not supported: since Chrome 127 its cookies are encrypted to the
16-
* browser's own identity (App-Bound Encryption), unreadable by any other app.
15+
* - **Windows** legacy Chromium stores protect a random AES key with DPAPI.
16+
* App-Bound Encryption remains deliberately unsupported.
1717
*
1818
* @module ChromiumKeys
1919
*/
@@ -23,6 +23,8 @@ import * as NodeCrypto from "node:crypto";
2323
import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
2424

2525
import * as Effect from "effect/Effect";
26+
import * as Encoding from "effect/Encoding";
27+
import * as FileSystem from "effect/FileSystem";
2628
import * as Schema from "effect/Schema";
2729
import * as Stream from "effect/Stream";
2830
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
@@ -77,6 +79,8 @@ export interface ChromiumKeyMaterial {
7779
* after a record's own key fails.
7880
*/
7981
readonly cbcEmpty?: Buffer;
82+
/** AES-256-GCM key used by pre-App-Bound Chromium on Windows. */
83+
readonly gcmV10?: Buffer;
8084
}
8185

8286
const derive = (passphrase: string, iterations: number) =>
@@ -172,6 +176,112 @@ export const readLinuxSecret = Effect.fn("ChromiumKeys.readLinuxSecret")(functio
172176
);
173177
});
174178

179+
const WindowsLocalState = Schema.Struct({
180+
os_crypt: Schema.Struct({
181+
encrypted_key: Schema.String,
182+
app_bound_encrypted_key: Schema.optional(Schema.String),
183+
}),
184+
});
185+
const decodeWindowsLocalState = Schema.decodeUnknownEffect(
186+
Schema.fromJsonString(WindowsLocalState),
187+
);
188+
const DPAPI_PREFIX = Buffer.from("DPAPI");
189+
const WINDOWS_KEY_LENGTH = 32;
190+
const WINDOWS_DPAPI_SCRIPT =
191+
"Add-Type -AssemblyName System.Security;" +
192+
"$value=[Console]::In.ReadToEnd();" +
193+
"$encrypted=[Convert]::FromBase64String($value);" +
194+
"$plain=[Security.Cryptography.ProtectedData]::Unprotect($encrypted,$null,[Security.Cryptography.DataProtectionScope]::CurrentUser);" +
195+
"[Console]::Out.Write([Convert]::ToBase64String($plain))";
196+
197+
export const decodeWindowsWrappedKey = Effect.fn("ChromiumKeys.decodeWindowsWrappedKey")(function* (
198+
contents: string,
199+
) {
200+
const state = yield* decodeWindowsLocalState(contents).pipe(
201+
Effect.mapError((cause) => new ChromiumKeyError({ reason: "readFailed", cause })),
202+
);
203+
if (state.os_crypt.app_bound_encrypted_key !== undefined) {
204+
return yield* new ChromiumKeyError({ reason: "unsupportedPlatform" });
205+
}
206+
const wrapped = yield* Effect.fromResult(
207+
Encoding.decodeBase64(state.os_crypt.encrypted_key),
208+
).pipe(Effect.mapError((cause) => new ChromiumKeyError({ reason: "readFailed", cause })));
209+
const wrappedBuffer = Buffer.from(wrapped);
210+
if (!wrappedBuffer.subarray(0, DPAPI_PREFIX.length).equals(DPAPI_PREFIX)) {
211+
return yield* new ChromiumKeyError({ reason: "readFailed" });
212+
}
213+
return wrappedBuffer.subarray(DPAPI_PREFIX.length);
214+
});
215+
216+
/** Unwraps a key with the current Windows user's DPAPI identity. */
217+
export const unwrapWindowsDpapiKey = Effect.fn("ChromiumKeys.unwrapWindowsDpapiKey")(function* (
218+
wrapped: Buffer,
219+
) {
220+
const environment = yield* HostProcessEnvironment;
221+
return yield* Effect.scoped(
222+
Effect.gen(function* () {
223+
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
224+
const windowsRoot = environment.SystemRoot ?? environment.WINDIR;
225+
const powershell = windowsRoot
226+
? `${windowsRoot}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`
227+
: "powershell.exe";
228+
const handle = yield* spawner
229+
.spawn(
230+
ChildProcess.make(
231+
powershell,
232+
[
233+
"-NoLogo",
234+
"-NoProfile",
235+
"-NonInteractive",
236+
"-WindowStyle",
237+
"Hidden",
238+
"-Command",
239+
WINDOWS_DPAPI_SCRIPT,
240+
],
241+
{
242+
env: environment,
243+
stdin: Stream.encodeText(Stream.make(wrapped.toString("base64"))),
244+
},
245+
),
246+
)
247+
.pipe(
248+
Effect.mapError(
249+
(cause) => new ChromiumKeyError({ reason: "keychainUnavailable", cause }),
250+
),
251+
);
252+
const [plainEncoded, , exitCode] = yield* Effect.all(
253+
[
254+
handle.stdout.pipe(Stream.decodeText(), Stream.mkString),
255+
handle.stderr.pipe(Stream.runDrain),
256+
handle.exitCode,
257+
],
258+
{ concurrency: "unbounded" },
259+
).pipe(Effect.mapError((cause) => new ChromiumKeyError({ reason: "readFailed", cause })));
260+
if (Number(exitCode) !== 0) {
261+
return yield* new ChromiumKeyError({ reason: "readFailed" });
262+
}
263+
const plain = yield* Effect.fromResult(Encoding.decodeBase64(plainEncoded)).pipe(
264+
Effect.mapError((cause) => new ChromiumKeyError({ reason: "readFailed", cause })),
265+
);
266+
if (plain.length !== WINDOWS_KEY_LENGTH) {
267+
return yield* new ChromiumKeyError({ reason: "readFailed" });
268+
}
269+
return Buffer.from(plain);
270+
}),
271+
);
272+
});
273+
274+
/** Reads and unwraps a legacy Windows Chromium key without exposing it in argv. */
275+
export const readWindowsKey = Effect.fn("ChromiumKeys.readWindowsKey")(function* (
276+
localStatePath: string,
277+
) {
278+
const fileSystem = yield* FileSystem.FileSystem;
279+
const contents = yield* fileSystem
280+
.readFileString(localStatePath)
281+
.pipe(Effect.mapError((cause) => new ChromiumKeyError({ reason: "readFailed", cause })));
282+
return yield* unwrapWindowsDpapiKey(yield* decodeWindowsWrappedKey(contents));
283+
});
284+
175285
export interface ChromiumKeyRequest {
176286
readonly platform: NodeJS.Platform;
177287
readonly keychainService: string | undefined;

0 commit comments

Comments
 (0)