|
1 | 1 | // Desktop auth extraction approach inspired by: |
2 | 2 | // - slacktokens: https://github.com/hraftery/slacktokens |
3 | 3 | import { cp, mkdir, rm, unlink } from "node:fs/promises"; |
4 | | -import { |
5 | | - existsSync, |
6 | | - readFileSync, |
7 | | - readdirSync, |
8 | | - copyFileSync, |
9 | | - writeFileSync, |
10 | | - unlinkSync, |
11 | | -} from "node:fs"; |
| 4 | +import { existsSync, readdirSync, copyFileSync, unlinkSync } from "node:fs"; |
12 | 5 | import { execFileSync } from "node:child_process"; |
13 | | -import { createDecipheriv, randomUUID } from "node:crypto"; |
14 | 6 | import { homedir, platform, tmpdir } from "node:os"; |
15 | 7 | import { join } from "node:path"; |
16 | 8 | import { findKeysContaining } from "../lib/leveldb-reader.js"; |
17 | 9 | import { isRecord } from "../lib/object-type-guards.ts"; |
18 | 10 | import { queryReadonlySqlite } from "./firefox-profile.ts"; |
19 | 11 | import { decryptChromiumCookieValue } from "./chromium-cookie.ts"; |
| 12 | +import { getSafeStoragePasswords, decryptCookieWindows } from "./desktop-crypto.ts"; |
20 | 13 |
|
21 | 14 | type DesktopTeam = { url: string; name?: string; token: string }; |
22 | 15 |
|
@@ -261,154 +254,6 @@ async function extractTeamsFromSlackLevelDb(leveldbDir: string): Promise<Desktop |
261 | 254 | } |
262 | 255 | } |
263 | 256 |
|
264 | | -function getSafeStoragePasswords(prefix: string): string[] { |
265 | | - if (IS_MACOS) { |
266 | | - // Electron ("Slack Key") and Mac App Store ("Slack App Store Key") builds |
267 | | - // store separate Safe Storage passwords under the same service name. |
268 | | - // Query each known account explicitly, then fall back to service-only |
269 | | - // lookups to catch unknown account names. |
270 | | - const keychainQueries: { service: string; account?: string }[] = [ |
271 | | - { service: "Slack Safe Storage", account: "Slack Key" }, |
272 | | - { service: "Slack Safe Storage", account: "Slack App Store Key" }, |
273 | | - { service: "Slack Safe Storage" }, |
274 | | - { service: "Chrome Safe Storage" }, |
275 | | - { service: "Chromium Safe Storage" }, |
276 | | - ]; |
277 | | - const passwords: string[] = []; |
278 | | - for (const q of keychainQueries) { |
279 | | - try { |
280 | | - const args = ["-w", "-s", q.service]; |
281 | | - if (q.account) { |
282 | | - args.push("-a", q.account); |
283 | | - } |
284 | | - const out = execFileSync("security", ["find-generic-password", ...args], { |
285 | | - encoding: "utf8", |
286 | | - stdio: ["ignore", "pipe", "ignore"], |
287 | | - }).trim(); |
288 | | - if (out) { |
289 | | - passwords.push(out); |
290 | | - } |
291 | | - } catch { |
292 | | - // continue |
293 | | - } |
294 | | - } |
295 | | - if (passwords.length > 0) { |
296 | | - return [...new Set(passwords)]; |
297 | | - } |
298 | | - } |
299 | | - |
300 | | - if (IS_LINUX) { |
301 | | - const attributes: string[][] = [ |
302 | | - ["application", "com.slack.Slack"], |
303 | | - ["application", "Slack"], |
304 | | - ["application", "slack"], |
305 | | - ["service", "Slack Safe Storage"], |
306 | | - ]; |
307 | | - const passwords: string[] = []; |
308 | | - for (const pair of attributes) { |
309 | | - try { |
310 | | - const out = execFileSync("secret-tool", ["lookup", ...pair], { |
311 | | - encoding: "utf8", |
312 | | - stdio: ["ignore", "pipe", "ignore"], |
313 | | - }).trim(); |
314 | | - if (out) { |
315 | | - passwords.push(out); |
316 | | - } |
317 | | - } catch { |
318 | | - // continue |
319 | | - } |
320 | | - } |
321 | | - |
322 | | - // Chromium Linux OSCrypt v10 fallback password (see os_crypt_linux.cc). |
323 | | - if (prefix === "v11") { |
324 | | - passwords.push(""); |
325 | | - } |
326 | | - passwords.push("peanuts"); |
327 | | - |
328 | | - return [...new Set(passwords)]; |
329 | | - } |
330 | | - |
331 | | - throw new Error("Could not read Safe Storage password from desktop keychain."); |
332 | | -} |
333 | | - |
334 | | -/** |
335 | | - * Decrypt a Chromium cookie on Windows using DPAPI + AES-256-GCM. |
336 | | - * |
337 | | - * On Windows (Chromium v80+), cookies are encrypted as: |
338 | | - * v10/v11 + 12-byte nonce + AES-256-GCM ciphertext + 16-byte auth tag |
339 | | - * |
340 | | - * The AES key is stored DPAPI-encrypted in the "Local State" file under |
341 | | - * os_crypt.encrypted_key (base64, prefixed with "DPAPI"). |
342 | | - */ |
343 | | -function decryptCookieWindows(encrypted: Buffer, slackDataDir: string): string { |
344 | | - // Read the DPAPI-protected AES key from Local State |
345 | | - const localStatePath = join(slackDataDir, "Local State"); |
346 | | - if (!existsSync(localStatePath)) { |
347 | | - throw new Error(`Local State file not found: ${localStatePath}`); |
348 | | - } |
349 | | - let localState: unknown; |
350 | | - try { |
351 | | - localState = JSON.parse(readFileSync(localStatePath, "utf8")); |
352 | | - } catch (error) { |
353 | | - throw new Error(`Failed to parse Local State file: ${localStatePath}`, { cause: error }); |
354 | | - } |
355 | | - const osCrypt = isRecord(localState) ? localState.os_crypt : undefined; |
356 | | - if (!isRecord(osCrypt) || typeof osCrypt.encrypted_key !== "string") { |
357 | | - throw new Error("No os_crypt.encrypted_key in Local State"); |
358 | | - } |
359 | | - const encKeyFull = Buffer.from(osCrypt.encrypted_key as string, "base64"); |
360 | | - // Skip "DPAPI" prefix (5 bytes) |
361 | | - const encKeyBlob = encKeyFull.subarray(5); |
362 | | - |
363 | | - // Decrypt AES key via Windows DPAPI using PowerShell |
364 | | - const id = randomUUID(); |
365 | | - const encKeyFile = join(tmpdir(), `as-key-enc-${id}.bin`); |
366 | | - const decKeyFile = join(tmpdir(), `as-key-dec-${id}.bin`); |
367 | | - writeFileSync(encKeyFile, encKeyBlob, { mode: 0o600 }); |
368 | | - try { |
369 | | - // Escape single quotes for PowerShell single-quoted strings (' → '') |
370 | | - const psEncKeyFile = encKeyFile.replaceAll("'", "''"); |
371 | | - const psDecKeyFile = decKeyFile.replaceAll("'", "''"); |
372 | | - const psCmd = [ |
373 | | - "Add-Type -AssemblyName System.Security", |
374 | | - `$e=[System.IO.File]::ReadAllBytes('${psEncKeyFile}')`, |
375 | | - "$d=[System.Security.Cryptography.ProtectedData]::Unprotect($e,$null,[System.Security.Cryptography.DataProtectionScope]::CurrentUser)", |
376 | | - `[System.IO.File]::WriteAllBytes('${psDecKeyFile}',$d)`, |
377 | | - ].join("; "); |
378 | | - execFileSync("powershell", ["-ExecutionPolicy", "Bypass", "-Command", psCmd], { |
379 | | - stdio: "pipe", |
380 | | - }); |
381 | | - if (!existsSync(decKeyFile)) { |
382 | | - throw new Error("DPAPI decryption failed: PowerShell did not produce the decrypted key file"); |
383 | | - } |
384 | | - const aesKey = readFileSync(decKeyFile); |
385 | | - |
386 | | - // AES-256-GCM: v10(3) + nonce(12) + ciphertext(N-16) + tag(16) |
387 | | - const nonce = encrypted.subarray(3, 15); |
388 | | - const ciphertextWithTag = encrypted.subarray(15); |
389 | | - const tag = ciphertextWithTag.subarray(-16); |
390 | | - const ciphertext = ciphertextWithTag.subarray(0, -16); |
391 | | - |
392 | | - const decipher = createDecipheriv("aes-256-gcm", aesKey, nonce); |
393 | | - decipher.setAuthTag(tag); |
394 | | - let decrypted = decipher.update(ciphertext, undefined, "utf8"); |
395 | | - decrypted += decipher.final("utf8"); |
396 | | - |
397 | | - return decrypted; |
398 | | - } finally { |
399 | | - try { |
400 | | - unlinkSync(encKeyFile); |
401 | | - } catch { |
402 | | - /* ignore */ |
403 | | - } |
404 | | - try { |
405 | | - unlinkSync(decKeyFile); |
406 | | - } catch { |
407 | | - /* ignore */ |
408 | | - } |
409 | | - } |
410 | | -} |
411 | | - |
412 | 257 | async function extractCookieDFromSlackCookiesDb( |
413 | 258 | cookiesPath: string, |
414 | 259 | slackDataDir: string, |
@@ -482,7 +327,10 @@ async function extractCookieDFromSlackCookiesDb( |
482 | 327 |
|
483 | 328 | for (const password of passwords) { |
484 | 329 | try { |
485 | | - const decrypted = decryptChromiumCookieValue(data, password, IS_LINUX ? 1 : 1003); |
| 330 | + const decrypted = decryptChromiumCookieValue(data, { |
| 331 | + password, |
| 332 | + iterations: IS_LINUX ? 1 : 1003, |
| 333 | + }); |
486 | 334 | const match = decrypted.match(/xoxd-[A-Za-z0-9%/+_=.-]+/); |
487 | 335 | if (match) { |
488 | 336 | return match[0]!; |
|
0 commit comments