| 1 | import assert from "node:assert/strict"; |
| 2 | import { createCipheriv, createHash, pbkdf2Sync, randomBytes } from "node:crypto"; |
| 3 | import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; |
| 4 | import { tmpdir } from "node:os"; |
| 5 | import { join } from "node:path"; |
| 6 | import { DatabaseSync } from "node:sqlite"; |
| 7 | import { test } from "node:test"; |
| 8 | import { |
| 9 | ChromeImportError, |
| 10 | chromeExpiryToUnixSeconds, |
| 11 | chromeProfileRoot, |
| 12 | chromeSameSite, |
| 13 | cookieURL, |
| 14 | decryptCBC, |
| 15 | decryptGCM, |
| 16 | findChromeProfiles, |
| 17 | importChromeCookies, |
| 18 | readChromeSafeStoragePassword, |
| 19 | stripDomainHash, |
| 20 | windowsMasterKeyBlob, |
| 21 | type ChromeCookie, |
| 22 | type ChromeImportDeps, |
| 23 | } from "./chromeImport.js"; |
| 24 | |
| 25 | const CHROME_EPOCH_OFFSET_SECONDS = 11_644_473_600; |
| 26 | const PASSWORD = "test-safe-storage"; |
| 27 | |
| 28 | // Chrome 96+ writes SHA-256(host_key) ahead of the value, so fixtures use the |
| 29 | // same layout the real profile does. |
| 30 | function encryptCBC(value: string, hostKey = "", prefix = "v10"): Buffer { |
| 31 | const key = pbkdf2Sync(PASSWORD, "saltysalt", 1003, 16, "sha1"); |
| 32 | const cipher = createCipheriv("aes-128-cbc", key, Buffer.alloc(16, 0x20)); |
| 33 | const head = hostKey === "" ? Buffer.alloc(0) : createHash("sha256").update(hostKey).digest(); |
| 34 | return Buffer.concat([Buffer.from(prefix), cipher.update(Buffer.concat([head, Buffer.from(value, "utf8")])), cipher.final()]); |
| 35 | } |
| 36 | |
| 37 | function encryptGCM(value: string, key: Buffer): Buffer { |
| 38 | const nonce = randomBytes(12); |
| 39 | const cipher = createCipheriv("aes-256-gcm", key, nonce); |
| 40 | const body = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]); |
| 41 | return Buffer.concat([Buffer.from("v10"), nonce, body, cipher.getAuthTag()]); |
| 42 | } |
| 43 | |
| 44 | function writeCookiesDatabase(root: string, profile: string, rows: Record<string, unknown>[]): string { |
| 45 | mkdirSync(join(root, profile), { recursive: true }); |
| 46 | const path = join(root, profile, "Cookies"); |
| 47 | const database = new DatabaseSync(path); |
| 48 | database.exec(`CREATE TABLE cookies ( |
| 49 | host_key TEXT, name TEXT, encrypted_value BLOB, path TEXT, |
| 50 | expires_utc INTEGER, is_secure INTEGER, is_httponly INTEGER, samesite INTEGER)`); |
| 51 | const insert = database.prepare("INSERT INTO cookies VALUES (?, ?, ?, ?, ?, ?, ?, ?)"); |
| 52 | for (const row of rows) { |
| 53 | insert.run( |
| 54 | row.host_key as string, |
| 55 | row.name as string, |
| 56 | row.encrypted_value as Uint8Array, |
| 57 | row.path as string, |
| 58 | row.expires_utc as number, |
| 59 | row.is_secure as number, |
| 60 | row.is_httponly as number, |
| 61 | row.samesite as number, |
| 62 | ); |
| 63 | } |
| 64 | database.close(); |
| 65 | return path; |
| 66 | } |
| 67 | |
| 68 | function deps(seen: ChromeCookie[], overrides: Partial<ChromeImportDeps> = {}): ChromeImportDeps { |
| 69 | return { |
| 70 | platform: "darwin", |
| 71 | home: "/Users/example", |
| 72 | env: {}, |
| 73 | cookies: { set: async (cookie) => void seen.push(cookie) }, |
| 74 | run: async () => PASSWORD, |
| 75 | ...overrides, |
| 76 | }; |
| 77 | } |
| 78 | |
| 79 | const FUTURE = ((BigInt(Math.floor(Date.now() / 1000)) + 86_400n + BigInt(CHROME_EPOCH_OFFSET_SECONDS)) * 1_000_000n).toString(); |
| 80 | const PAST = ((BigInt(Math.floor(Date.now() / 1000)) - 86_400n + BigInt(CHROME_EPOCH_OFFSET_SECONDS)) * 1_000_000n).toString(); |
| 81 | |
| 82 | test("profile roots follow the platform conventions", () => { |
| 83 | assert.equal(chromeProfileRoot("darwin", "/Users/example", {}), "/Users/example/Library/Application Support/Google/Chrome"); |
| 84 | assert.equal(chromeProfileRoot("win32", "C:\\Users\\example", { LOCALAPPDATA: "C:\\Users\\example\\AppData\\Local" }), join("C:\\Users\\example\\AppData\\Local", "Google", "Chrome", "User Data")); |
| 85 | assert.equal(chromeProfileRoot("linux", "/home/example", {}), "/home/example/.config/google-chrome"); |
| 86 | }); |
| 87 | |
| 88 | test("expiry converts from Chrome's 1601 epoch and treats zero as a session cookie", () => { |
| 89 | assert.equal(chromeExpiryToUnixSeconds(0), undefined); |
| 90 | assert.equal(chromeExpiryToUnixSeconds("0"), undefined); |
| 91 | assert.equal(chromeExpiryToUnixSeconds(""), undefined); |
| 92 | // Chrome's microsecond stamps exceed Number.MAX_SAFE_INTEGER, so the exact |
| 93 | // string form must survive the conversion. |
| 94 | assert.equal(chromeExpiryToUnixSeconds("11644473600000000"), 0); |
| 95 | assert.equal(chromeExpiryToUnixSeconds("11644473660000000"), 60); |
| 96 | assert.equal(chromeExpiryToUnixSeconds(11_644_473_660_000_000n), 60); |
| 97 | }); |
| 98 | |
| 99 | test("cookie fields map to Electron's vocabulary", () => { |
| 100 | assert.equal(chromeSameSite(-1), "unspecified"); |
| 101 | assert.equal(chromeSameSite(0), "no_restriction"); |
| 102 | assert.equal(chromeSameSite(1), "lax"); |
| 103 | assert.equal(chromeSameSite(2), "strict"); |
| 104 | assert.equal(chromeSameSite(9), "unspecified"); |
| 105 | assert.equal(cookieURL(".example.test", "/app", true), "https://example.test/app"); |
| 106 | assert.equal(cookieURL("example.test", "app", false), "http://example.test/app"); |
| 107 | }); |
| 108 | |
| 109 | test("CBC and GCM payloads round-trip", () => { |
| 110 | assert.equal(decryptCBC(encryptCBC("session-token"), PASSWORD, 1003).toString("utf8"), "session-token"); |
| 111 | const key = randomBytes(32); |
| 112 | assert.equal(decryptGCM(encryptGCM("session-token", key), key).toString("utf8"), "session-token"); |
| 113 | }); |
| 114 | |
| 115 | test("the domain hash Chrome binds to each value is stripped", () => { |
| 116 | const digest = createHash("sha256").update(".example.test").digest(); |
| 117 | const bound = Buffer.concat([digest, Buffer.from("token", "utf8")]); |
| 118 | assert.equal(stripDomainHash(bound, ".example.test").toString("utf8"), "token"); |
| 119 | // A value bound to another host, a short value and an unbound value all |
| 120 | // survive untouched. |
| 121 | assert.deepEqual(stripDomainHash(bound, ".other.test"), bound); |
| 122 | assert.equal(stripDomainHash(Buffer.from("short"), ".example.test").toString("utf8"), "short"); |
| 123 | assert.equal(stripDomainHash(decryptCBC(encryptCBC("unbound"), PASSWORD, 1003), ".example.test").toString("utf8"), "unbound"); |
| 124 | }); |
| 125 | |
| 126 | test("windows master key strips the DPAPI prefix", () => { |
| 127 | const blob = Buffer.concat([Buffer.from("DPAPI"), Buffer.from("secret")]); |
| 128 | assert.equal(windowsMasterKeyBlob({ os_crypt: { encrypted_key: blob.toString("base64") } }).toString(), "secret"); |
| 129 | assert.throws(() => windowsMasterKeyBlob({ os_crypt: {} }), (error: unknown) => error instanceof ChromeImportError && error.code === "safe-storage-unavailable"); |
| 130 | }); |
| 131 | |
| 132 | test("the newest profile with cookies wins", () => { |
| 133 | const root = mkdtempSync(join(tmpdir(), "reasonix-chrome-")); |
| 134 | writeCookiesDatabase(root, "Default", []); |
| 135 | writeCookiesDatabase(root, "Profile 1", []); |
| 136 | mkdirSync(join(root, "Crashpad"), { recursive: true }); |
| 137 | const profiles = findChromeProfiles(root, (path) => ["Default", "Profile 1", "Crashpad"].filter((name) => name !== "" && (name === "Crashpad" || path === root))); |
| 138 | assert.deepEqual(profiles.map((profile) => profile.name).sort(), ["Default", "Profile 1"]); |
| 139 | assert.deepEqual(findChromeProfiles(join(root, "missing"), () => []), []); |
| 140 | }); |
| 141 | |
| 142 | test("imports decryptable cookies and skips the ones it cannot use", async () => { |
| 143 | const root = mkdtempSync(join(tmpdir(), "reasonix-chrome-")); |
| 144 | writeCookiesDatabase(root, "Default", [ |
| 145 | { host_key: ".example.test", name: "sid", encrypted_value: encryptCBC("token-1", ".example.test"), path: "/", expires_utc: FUTURE, is_secure: 1, is_httponly: 1, samesite: 1 }, |
| 146 | { host_key: "expired.test", name: "old", encrypted_value: encryptCBC("token-2", "expired.test"), path: "/", expires_utc: PAST, is_secure: 0, is_httponly: 0, samesite: -1 }, |
| 147 | { host_key: "bound.test", name: "appbound", encrypted_value: encryptCBC("token-3", "bound.test", "v20"), path: "/", expires_utc: FUTURE, is_secure: 1, is_httponly: 0, samesite: 0 }, |
| 148 | { host_key: "broken.test", name: "garbled", encrypted_value: Buffer.from("v10notreallyencrypted"), path: "/", expires_utc: FUTURE, is_secure: 0, is_httponly: 0, samesite: -1 }, |
| 149 | ]); |
| 150 | const seen: ChromeCookie[] = []; |
| 151 | const profiles = findChromeProfiles(root, () => ["Default"]); |
| 152 | const summary = await importChromeCookies(deps(seen), root, profiles); |
| 153 | assert.deepEqual(summary, { profile: "Default", cookies: 1, skipped: 3 }); |
| 154 | assert.deepEqual(seen, [ |
| 155 | { |
| 156 | url: "https://example.test/", |
| 157 | name: "sid", |
| 158 | value: "token-1", |
| 159 | domain: ".example.test", |
| 160 | path: "/", |
| 161 | secure: true, |
| 162 | httpOnly: true, |
| 163 | sameSite: "lax", |
| 164 | expirationDate: Number(BigInt(FUTURE) / 1_000_000n) - CHROME_EPOCH_OFFSET_SECONDS, |
| 165 | }, |
| 166 | ]); |
| 167 | }); |
| 168 | |
| 169 | test("a rejected cookie is skipped instead of failing the import", async () => { |
| 170 | const root = mkdtempSync(join(tmpdir(), "reasonix-chrome-")); |
| 171 | writeCookiesDatabase(root, "Default", [ |
| 172 | { host_key: "example.test", name: "sid", encrypted_value: encryptCBC("token", "example.test"), path: "/", expires_utc: FUTURE, is_secure: 1, is_httponly: 0, samesite: -1 }, |
| 173 | ]); |
| 174 | const summary = await importChromeCookies( |
| 175 | deps([], { cookies: { set: async () => Promise.reject(new Error("invalid cookie")) } }), |
| 176 | root, |
| 177 | findChromeProfiles(root, () => ["Default"]), |
| 178 | ); |
| 179 | assert.deepEqual(summary, { profile: "Default", cookies: 0, skipped: 1 }); |
| 180 | }); |
| 181 | |
| 182 | test("import failures surface as typed codes", async () => { |
| 183 | const root = mkdtempSync(join(tmpdir(), "reasonix-chrome-")); |
| 184 | await assert.rejects( |
| 185 | () => importChromeCookies(deps([]), root, []), |
| 186 | (error: unknown) => error instanceof ChromeImportError && error.code === "profile-not-found", |
| 187 | ); |
| 188 | writeCookiesDatabase(root, "Default", []); |
| 189 | writeFileSync(join(root, "Default", "Cookies"), "not a database"); |
| 190 | await assert.rejects( |
| 191 | () => importChromeCookies(deps([]), root, findChromeProfiles(root, () => ["Default"])), |
| 192 | (error: unknown) => error instanceof ChromeImportError && error.code === "cookies-unreadable", |
| 193 | ); |
| 194 | }); |
| 195 | |
| 196 | test("a denied keychain prompt is distinguishable from a missing key", async () => { |
| 197 | await assert.rejects( |
| 198 | () => readChromeSafeStoragePassword({ platform: "darwin", run: async () => Promise.reject(new Error("User interaction is not allowed.")) }), |
| 199 | (error: unknown) => error instanceof ChromeImportError && error.code === "safe-storage-denied", |
| 200 | ); |
| 201 | await assert.rejects( |
| 202 | () => readChromeSafeStoragePassword({ platform: "darwin", run: async () => "" }), |
| 203 | (error: unknown) => error instanceof ChromeImportError && error.code === "safe-storage-unavailable", |
| 204 | ); |
| 205 | assert.equal(await readChromeSafeStoragePassword({ platform: "linux", run: async () => "" }), "peanuts"); |
| 206 | await assert.rejects( |
| 207 | () => readChromeSafeStoragePassword({ platform: "win32", run: async () => "" }), |
| 208 | (error: unknown) => error instanceof ChromeImportError && error.code === "unsupported-platform", |
| 209 | ); |
| 210 | }); |
| 211 |