diff --git a/src/auth/chrome.ts b/src/auth/chrome.ts index a31e9d6..73ee196 100644 --- a/src/auth/chrome.ts +++ b/src/auth/chrome.ts @@ -1,5 +1,10 @@ -import { execSync } from "node:child_process"; -import { platform } from "node:os"; +import { execFileSync, execSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { readdir } from "node:fs/promises"; +import { homedir, platform } from "node:os"; +import { join } from "node:path"; +import { decryptChromiumCookieValue } from "./chromium-cookie.ts"; +import { copySqliteForRead, queryReadonlySqlite } from "./firefox-profile.ts"; type ChromeExtractedTeam = { url: string; name?: string; token: string }; @@ -9,6 +14,7 @@ export type ChromeExtracted = { }; const IS_MACOS = platform() === "darwin"; +const CHROME_SUPPORT_DIR = join(homedir(), "Library", "Application Support", "Google", "Chrome"); function escapeOsaScript(script: string): string { // osascript -e '...' @@ -38,6 +44,100 @@ function cookieScript(): string { `; } +function getSafeStoragePasswords(): string[] { + const services = ["Chrome Safe Storage", "Chromium Safe Storage"]; + const passwords: string[] = []; + for (const service of services) { + try { + const out = execFileSync("security", ["find-generic-password", "-w", "-s", service], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + if (out) { + passwords.push(out); + } + } catch { + // continue + } + } + return [...new Set(passwords)]; +} + +async function chromeCookieDbCandidates(): Promise { + if (!existsSync(CHROME_SUPPORT_DIR)) { + return []; + } + + const entries = await readdir(CHROME_SUPPORT_DIR, { withFileTypes: true }); + return entries + .filter((entry) => entry.isDirectory()) + .map((entry) => join(CHROME_SUPPORT_DIR, entry.name, "Cookies")) + .filter((path) => existsSync(path)) + .sort((a, b) => { + const aName = a.split("/").at(-2) ?? ""; + const bName = b.split("/").at(-2) ?? ""; + if (aName === "Default") { + return -1; + } + if (bName === "Default") { + return 1; + } + return aName.localeCompare(bName); + }); +} + +async function extractCookieDFromChromeDb(): Promise { + const passwords = getSafeStoragePasswords(); + if (passwords.length === 0) { + return ""; + } + + for (const dbPath of await chromeCookieDbCandidates()) { + const snapshot = await copySqliteForRead(dbPath); + try { + const rows = (await queryReadonlySqlite( + snapshot.copyPath, + "select host_key, name, value, encrypted_value from cookies where name = 'd' and host_key like '%slack.com' order by length(encrypted_value) desc", + )) as { + host_key: string; + name: string; + value: string; + encrypted_value: Uint8Array; + }[]; + + for (const row of rows) { + if (row.value && row.value.startsWith("xoxd-")) { + return row.value; + } + + const encrypted = Buffer.from(row.encrypted_value || []); + if (encrypted.length === 0) { + continue; + } + + const prefix = encrypted.subarray(0, 3).toString("utf8"); + const data = prefix === "v10" || prefix === "v11" ? encrypted.subarray(3) : encrypted; + + for (const password of passwords) { + try { + const decrypted = decryptChromiumCookieValue(data, { password, iterations: 1003 }); + const match = decrypted.match(/xoxd-[A-Za-z0-9%/+_=.-]+/); + if (match) { + return match[0]!; + } + } catch { + // continue + } + } + } + } finally { + await snapshot.cleanup(); + } + } + + return ""; +} + const TEAM_JSON_PATHS = [ // Current known storage "JSON.stringify(JSON.parse(localStorage.localConfig_v2).teams)", @@ -81,12 +181,15 @@ function teamsScript(): string { `; } -export function extractFromChrome(): ChromeExtracted | null { +export async function extractFromChrome(): Promise { if (!IS_MACOS) { return null; } try { - const cookie = osascript(cookieScript()); + let cookie = osascript(cookieScript()); + if (!cookie || !cookie.startsWith("xoxd-")) { + cookie = await extractCookieDFromChromeDb(); + } if (!cookie || !cookie.startsWith("xoxd-")) { return null; } diff --git a/src/cli/auth-command.ts b/src/cli/auth-command.ts index f4f3127..fecb0b4 100644 --- a/src/cli/auth-command.ts +++ b/src/cli/auth-command.ts @@ -78,7 +78,7 @@ export function registerAuthCommand(input: { program: Command; ctx: CliContext } .description("Import xoxc/xoxd from a logged-in Slack tab in Google Chrome (macOS)") .action(async () => { try { - const extracted = input.ctx.importChrome(); + const extracted = await input.ctx.importChrome(); if (!extracted) { throw new Error( "Could not extract tokens from Chrome. Open Slack in Chrome and ensure you're logged in.", diff --git a/src/cli/context-client-resolver.ts b/src/cli/context-client-resolver.ts index d9beb8b..8f7f193 100644 --- a/src/cli/context-client-resolver.ts +++ b/src/cli/context-client-resolver.ts @@ -140,7 +140,7 @@ export async function getClientForWorkspace(workspaceUrl?: string): Promise<{ cookie_d: string; teams: { url: string; name?: string; token: string }[]; }[] = []; - const chromeResult = extractFromChrome(); + const chromeResult = await extractFromChrome(); if (chromeResult && chromeResult.teams.length > 0) { browserSources.push(chromeResult); } diff --git a/test/channel-command.test.ts b/test/channel-command.test.ts index 2759edc..6984cef 100644 --- a/test/channel-command.test.ts +++ b/test/channel-command.test.ts @@ -55,7 +55,7 @@ function createContext() { teams: [], source: { leveldb_path: "", cookies_path: "" }, }), - importChrome: () => ({ cookie_d: "", teams: [] }), + importChrome: async () => ({ cookie_d: "", teams: [] }), importBrave: async () => null, importFirefox: async () => null, }; diff --git a/test/message-send.test.ts b/test/message-send.test.ts index 6dcf134..31357b5 100644 --- a/test/message-send.test.ts +++ b/test/message-send.test.ts @@ -87,7 +87,7 @@ function createContext(calls: { method: string; params: Record teams: [], source: { leveldb_path: "", cookies_path: "" }, }), - importChrome: () => ({ cookie_d: "", teams: [] }), + importChrome: async () => ({ cookie_d: "", teams: [] }), importBrave: async () => null, importFirefox: async () => null, } satisfies CliContext; diff --git a/test/search-command.test.ts b/test/search-command.test.ts index f918ce9..074e31a 100644 --- a/test/search-command.test.ts +++ b/test/search-command.test.ts @@ -100,7 +100,7 @@ function createContext(calls: ApiCall[]): CliContext { teams: [], source: { leveldb_path: "", cookies_path: "" }, }), - importChrome: () => ({ cookie_d: "", teams: [] }), + importChrome: async () => ({ cookie_d: "", teams: [] }), importBrave: async () => null, importFirefox: async () => null, };