slack-term 1.36.0 → 1.37.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slack-term",
3
- "version": "1.36.0",
3
+ "version": "1.37.1",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/snomiao/slack-term.git"
package/ts/auth.ts CHANGED
@@ -2,9 +2,10 @@
2
2
  import { createInterface, type Interface } from "node:readline/promises";
3
3
  import { join, resolve } from "node:path";
4
4
  import { homedir } from "node:os";
5
- import { addProfile, listProfiles, setCookie, saveToEnvFile, resolveToken, resolveCookie, resolveBotToken } from "./profiles.ts";
5
+ import { addProfile, listProfiles, setCookie, setUserCookie, saveToEnvFile, resolveToken, resolveCookie, resolveBotToken, type Profile } from "./profiles.ts";
6
6
  import { authTest } from "./slack.ts";
7
- import { extractSessions, extractChromeSessions, discoverChromeCookies, discoverFirefoxCookies } from "./slack-app.ts";
7
+ import { extractSessions, extractChromeSessions, discoverChromeCookies, discoverChromeSessions, discoverFirefoxCookies, type ChromeSessionProfile } from "./slack-app.ts";
8
+ import { extractSlackXoxdWindows } from "./chromeCookieWin.ts";
8
9
 
9
10
  const USER_SCOPES = [
10
11
  "search:read",
@@ -264,9 +265,61 @@ async function loginNewApp(rl: Interface, mode: "user" | "bot"): Promise<void> {
264
265
  * When run interactively, macOS will show a system dialog asking for the login password
265
266
  * to grant access to the "Chrome Safe Storage" keychain item — click Allow.
266
267
  */
267
- export async function cmdAuthChrome(opts: { workspace?: string; yes?: boolean } = {}): Promise<void> {
268
- if (process.platform !== "darwin" && process.platform !== "linux") {
269
- console.error("Chrome cookie extraction is supported on macOS and Linux.");
268
+ /**
269
+ * Extract the Slack xoxd cookie from Chrome on Windows (app-bound "v20" scheme) for the
270
+ * selected workspace. Picks the Chrome profile that holds the workspace's session (else by
271
+ * --profile / prompt), then runs the elevated extraction (a UAC prompt appears).
272
+ */
273
+ async function pickChromeCookieWindows(selected: { name: string; profile: Profile }, profileOpt?: string): Promise<string> {
274
+ const found = discoverChromeSessions();
275
+ if (found.length === 0) {
276
+ console.error("No Slack session found in Chrome. Sign in to Slack in Chrome (app.slack.com) and retry.");
277
+ process.exit(1);
278
+ }
279
+ // The workspace's xoxc token embeds its numeric team id — match the Chrome profile that has it.
280
+ const numericTeamId = selected.profile.token.split("-")[1];
281
+ let chosen = found.find((p) => p.sessions.some((s) => s.teamId === numericTeamId));
282
+ if (!chosen && profileOpt) {
283
+ chosen = found.find((p) => p.email === profileOpt || p.profileDir === profileOpt || p.profileDir === `Profile ${profileOpt}`);
284
+ if (!chosen) {
285
+ console.error(`Chrome profile "${profileOpt}" has no Slack session. Available: ${found.map((p) => p.email ?? p.profileDir).join(", ")}`);
286
+ process.exit(1);
287
+ }
288
+ }
289
+ if (!chosen) {
290
+ if (found.length === 1) {
291
+ chosen = found[0]!;
292
+ } else if (process.stdin.isTTY) {
293
+ console.log("Multiple Chrome profiles have a Slack session:");
294
+ found.forEach((p, i) => console.log(` ${i + 1}) ${p.email ?? p.label} [${p.profileDir}]`));
295
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
296
+ const choice = (await rl.question(`Choice [1-${found.length}]: `)).trim();
297
+ rl.close();
298
+ const idx = parseInt(choice, 10) - 1;
299
+ if (isNaN(idx) || idx < 0 || idx >= found.length) { console.error("Invalid choice."); process.exit(1); }
300
+ chosen = found[idx]!;
301
+ } else {
302
+ console.error(`Multiple Chrome profiles have a Slack session — choose one with --profile:`);
303
+ found.forEach((p) => console.error(` slack auth cookie -w ${selected.name} --profile "${p.email ?? p.profileDir}"`));
304
+ process.exit(1);
305
+ }
306
+ }
307
+
308
+ console.log(`Using Chrome profile: ${chosen.email ?? chosen.label} [${chosen.profileDir}]`);
309
+ console.log("Chrome's cookie is app-bound encrypted; a Windows elevation (UAC) prompt will appear — approve it.");
310
+ try {
311
+ return await extractSlackXoxdWindows(chosen.profileDir);
312
+ } catch (e: unknown) {
313
+ console.error(`\nCould not extract the cookie automatically: ${e instanceof Error ? e.message : String(e)}`);
314
+ console.error("Fallback (manual): Chrome > DevTools (F12) > Application > Cookies > https://app.slack.com > copy the `d` value,");
315
+ console.error("then set it via an env file: SLACK_COOKIE=xoxd-... (in .slack-term/.env.local)");
316
+ process.exit(1);
317
+ }
318
+ }
319
+
320
+ export async function cmdAuthChrome(opts: { workspace?: string; profile?: string; yes?: boolean } = {}): Promise<void> {
321
+ if (process.platform !== "darwin" && process.platform !== "linux" && process.platform !== "win32") {
322
+ console.error("Chrome cookie extraction is supported on macOS, Linux, and Windows.");
270
323
  process.exit(1);
271
324
  }
272
325
 
@@ -330,66 +383,165 @@ export async function cmdAuthChrome(opts: { workspace?: string; yes?: boolean }
330
383
  process.exit(1);
331
384
  }
332
385
 
333
- if (!opts.yes && !process.stdin.isTTY) {
334
- console.error("Reading browser profiles requires confirmation. Re-run interactively or pass --yes.");
335
- process.exit(1);
336
- }
337
- const confirmRl = opts.yes ? undefined : createInterface({ input: process.stdin, output: process.stdout });
338
- try {
339
- if (!await allowBrowserProfileRead(confirmRl, opts.yes ?? false)) {
340
- console.log("Browser profile scan cancelled.");
341
- return;
386
+ let cookie: string;
387
+ if (process.platform === "win32") {
388
+ // Windows: app-bound (v20) extraction, elevated (UAC) — its own consent/flow.
389
+ cookie = await pickChromeCookieWindows(selected, opts.profile);
390
+ } else {
391
+ // Reading the browser's cookie store needs explicit consent (macOS keychain / Linux keyring).
392
+ if (!opts.yes && !process.stdin.isTTY) {
393
+ console.error("Reading browser profiles requires confirmation. Re-run interactively or pass --yes.");
394
+ process.exit(1);
342
395
  }
343
- } finally {
344
- confirmRl?.close();
345
- }
346
- console.log("Scanning Chrome profiles for Slack session...");
347
- if (process.platform === "darwin") console.log("macOS may show a dialog asking for your login password — click Allow.");
396
+ const confirmRl = opts.yes ? undefined : createInterface({ input: process.stdin, output: process.stdout });
397
+ try {
398
+ if (!await allowBrowserProfileRead(confirmRl, opts.yes ?? false)) {
399
+ console.log("Browser profile scan cancelled.");
400
+ return;
401
+ }
402
+ } finally {
403
+ confirmRl?.close();
404
+ }
405
+ console.log("Scanning Chrome profiles for Slack session...");
406
+ if (process.platform === "darwin") console.log("macOS may show a dialog asking for your login password — click Allow.");
348
407
 
349
- let candidates: import("./slack-app.ts").ChromeCookieCandidate[];
350
- let totalProfiles: number;
351
- try {
352
- ({ candidates, totalProfiles } = discoverChromeCookies());
353
- } catch (e: unknown) {
354
- console.error(`Failed: ${e instanceof Error ? e.message : String(e)}`);
355
- process.exit(1);
408
+ let candidates: import("./slack-app.ts").ChromeCookieCandidate[];
409
+ let totalProfiles: number;
410
+ try {
411
+ ({ candidates, totalProfiles } = discoverChromeCookies());
412
+ } catch (e: unknown) {
413
+ console.error(`Failed: ${e instanceof Error ? e.message : String(e)}`);
414
+ process.exit(1);
415
+ }
416
+
417
+ if (candidates.length === 0) {
418
+ console.error(`No Slack session found in Chrome (scanned ${totalProfiles} profile${totalProfiles !== 1 ? "s" : ""}). Possible reasons:`);
419
+ console.error(" - You denied the keychain dialog (try running again and click Allow)");
420
+ console.error(" - Chrome is not installed or has no Slack session");
421
+ console.error(" - You're not logged in to Slack in Chrome");
422
+ process.exit(1);
423
+ }
424
+
425
+ // Always prompt when multiple Chrome profiles exist, so users can confirm the right one.
426
+ if (candidates.length === 1 && totalProfiles <= 1) {
427
+ cookie = candidates[0]!.cookie;
428
+ console.log(`Found session in Chrome profile: ${candidates[0]!.profileName}`);
429
+ } else {
430
+ const label = candidates.length === 1
431
+ ? `Found 1 Slack session across ${totalProfiles} Chrome profiles:`
432
+ : `Found ${candidates.length} Slack sessions across ${totalProfiles} Chrome profiles:`;
433
+ console.log(label);
434
+ candidates.forEach((c, i) => console.log(` ${i + 1}) ${c.profileName} [${c.profileDir}]`));
435
+ console.log("");
436
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
437
+ const defaultChoice = candidates.length === 1 ? " [Enter=1]" : "";
438
+ const choice = (await rl.question(`Choice [1-${candidates.length}]${defaultChoice}: `)).trim();
439
+ rl.close();
440
+ const idx = choice === "" && candidates.length === 1 ? 0 : parseInt(choice, 10) - 1;
441
+ if (isNaN(idx) || idx < 0 || idx >= candidates.length) {
442
+ console.error("Invalid choice.");
443
+ process.exit(1);
444
+ }
445
+ cookie = candidates[idx]!.cookie;
446
+ }
356
447
  }
357
448
 
358
- if (candidates.length === 0) {
359
- console.error(`No Slack session found in Chrome (scanned ${totalProfiles} profile${totalProfiles !== 1 ? "s" : ""}). Possible reasons:`);
360
- if (process.platform === "darwin") console.error(" - You denied the keychain dialog (try running again and click Allow)");
361
- console.error(" - Chrome is not installed or has no Slack session");
362
- console.error(" - You're not logged in to Slack in Chrome");
449
+ setCookie(profileName, cookie);
450
+ console.log(`Saved xoxd cookie to workspace "${profileName}".`);
451
+ console.log(`RTM WebSocket mode is now available: slack tail @you`);
452
+ }
453
+
454
+ /**
455
+ * Log in from Chrome: read xoxc workspace tokens from a Chrome profile's (plaintext)
456
+ * LocalStorage and create a workspace profile for each, grouped under the profile's
457
+ * account email as the identity. Works on all platforms for the token.
458
+ *
459
+ * The xoxd cookie (needed for RTM/drafts/search fallback) is captured on macOS via
460
+ * the keychain; on other platforms it must be attached separately (e.g. a live CDP
461
+ * read via rech, or a manual paste with `slack auth cookie`).
462
+ */
463
+ export async function cmdAuthLoginChrome(opts: { profile?: string } = {}): Promise<void> {
464
+ console.error("Scanning Chrome profiles for Slack sessions...");
465
+ const found = discoverChromeSessions();
466
+ if (found.length === 0) {
467
+ console.error("No Slack session found in Chrome.");
468
+ console.error(" Open Slack in Chrome (https://app.slack.com) and sign in, then retry.");
363
469
  process.exit(1);
364
470
  }
365
471
 
366
- let cookie: string;
367
- // Always prompt when multiple Chrome profiles exist, so users can confirm the right one.
368
- if (candidates.length === 1 && totalProfiles <= 1) {
369
- cookie = candidates[0]!.cookie;
370
- console.log(`Found session in Chrome profile: ${candidates[0]!.profileName}`);
371
- } else {
372
- const label = candidates.length === 1
373
- ? `Found 1 Slack session across ${totalProfiles} Chrome profiles:`
374
- : `Found ${candidates.length} Slack sessions across ${totalProfiles} Chrome profiles:`;
375
- console.log(label);
376
- candidates.forEach((c, i) => console.log(` ${i + 1}) ${c.profileName} [${c.profileDir}]`));
377
- console.log("");
472
+ // Pick a Chrome profile.
473
+ let chosen: ChromeSessionProfile;
474
+ if (opts.profile) {
475
+ const key = opts.profile;
476
+ const match = found.find(
477
+ (p) => p.email === key || p.profileDir === key || p.profileDir === `Profile ${key}`,
478
+ );
479
+ if (!match) {
480
+ console.error(`Chrome profile "${key}" has no Slack session. Available:`);
481
+ found.forEach((p) => console.error(` - ${p.email ?? p.label} [${p.profileDir}] (${p.sessions.length} workspace${p.sessions.length !== 1 ? "s" : ""})`));
482
+ process.exit(1);
483
+ }
484
+ chosen = match;
485
+ } else if (found.length === 1) {
486
+ chosen = found[0]!;
487
+ } else if (process.stdin.isTTY) {
488
+ console.log("Found Slack sessions in multiple Chrome profiles:");
489
+ found.forEach((p, i) => console.log(` ${i + 1}) ${p.email ?? p.label} [${p.profileDir}] (${p.sessions.length} workspace${p.sessions.length !== 1 ? "s" : ""})`));
378
490
  const rl = createInterface({ input: process.stdin, output: process.stdout });
379
- const defaultChoice = candidates.length === 1 ? " [Enter=1]" : "";
380
- const choice = (await rl.question(`Choice [1-${candidates.length}]${defaultChoice}: `)).trim();
491
+ const choice = (await rl.question(`Choice [1-${found.length}]: `)).trim();
381
492
  rl.close();
382
- const idx = choice === "" && candidates.length === 1 ? 0 : parseInt(choice, 10) - 1;
383
- if (isNaN(idx) || idx < 0 || idx >= candidates.length) {
493
+ const idx = parseInt(choice, 10) - 1;
494
+ if (isNaN(idx) || idx < 0 || idx >= found.length) {
384
495
  console.error("Invalid choice.");
385
496
  process.exit(1);
386
497
  }
387
- cookie = candidates[idx]!.cookie;
498
+ chosen = found[idx]!;
499
+ } else {
500
+ console.error(`Multiple Chrome profiles have a Slack session — choose one with --profile:`);
501
+ found.forEach((p) => console.error(` slack auth login chrome --profile "${p.email ?? p.profileDir}"`));
502
+ process.exit(1);
388
503
  }
389
504
 
390
- setCookie(profileName, cookie);
391
- console.log(`Saved xoxd cookie to workspace "${profileName}".`);
392
- console.log(`RTM WebSocket mode is now available: slack tail @you`);
505
+ const identity = chosen.email ?? chosen.label ?? chosen.profileDir;
506
+
507
+ // Capture the shared xoxd cookie where possible (macOS keychain at rest).
508
+ let cookie: string | undefined;
509
+ if (process.platform === "darwin") {
510
+ try {
511
+ const { candidates } = discoverChromeCookies();
512
+ cookie = candidates.find((c) => c.profileDir === chosen.profileDir)?.cookie ?? candidates[0]?.cookie;
513
+ } catch {
514
+ // keychain denied or v20 — fall through to guidance below
515
+ }
516
+ }
517
+ if (cookie) setUserCookie(identity, cookie, `chrome:${chosen.profileDir}`);
518
+
519
+ for (const s of chosen.sessions) {
520
+ const teamLabel = s.teamName ?? s.teamId;
521
+ const name = slugify(teamLabel);
522
+ addProfile(name, {
523
+ token: s.token,
524
+ team: teamLabel,
525
+ teamId: s.teamId,
526
+ url: s.url ?? "",
527
+ user: "",
528
+ identity,
529
+ });
530
+ console.log(`Added workspace "${name}": ${teamLabel} (identity: ${identity})`);
531
+ }
532
+
533
+ console.log("");
534
+ if (cookie) {
535
+ console.log(`Captured xoxd cookie for ${identity} — shared across its ${chosen.sessions.length} workspace(s).`);
536
+ console.log(`RTM WebSocket mode is now available: slack tail @you`);
537
+ } else {
538
+ console.log("Note: no xoxd cookie captured (LocalStorage yields the token only; the cookie is");
539
+ console.log("encrypted at rest on this platform). Some features (RTM, drafts, search fallback) need it.");
540
+ console.log(" Attach it from your live browser — e.g. a CDP read via rech — or paste it:");
541
+ console.log(` slack auth cookie --workspace ${slugify(chosen.sessions[0]!.teamName ?? chosen.sessions[0]!.teamId)}`);
542
+ }
543
+ console.log("");
544
+ console.log("Select a workspace: slack auth use <name> (see: slack auth ls)");
393
545
  }
394
546
 
395
547
  /**
@@ -656,31 +808,39 @@ export async function cmdAuthLogin(opts: {
656
808
  console.log(" Reads the xoxc- token directly from the installed app.");
657
809
  console.log(" Token: all platforms | xoxd cookie: macOS, or Chrome/Firefox on Linux");
658
810
  console.log("");
659
- console.log(" 2) Connect existing Slack app [recommended if you have one]");
811
+ console.log(" 2) Chrome browser - import workspaces");
812
+ console.log(" Reads xoxc- tokens from Chrome, grouped by account.");
813
+ console.log(" Token: all platforms | xoxd cookie: macOS only");
814
+ console.log("");
815
+ console.log(" 3) Connect existing Slack app [recommended if you have one]");
660
816
  console.log(" Paste a token from an app you already created.");
661
817
  console.log("");
662
- console.log(" 3) Create new Slack app - user token (xoxp-)");
818
+ console.log(" 4) Create new Slack app - user token (xoxp-)");
663
819
  console.log(" Guided setup with manifest. Full access including search.");
664
820
  console.log("");
665
- console.log(" 4) Create new Slack app - bot token (xoxb-)");
821
+ console.log(" 5) Create new Slack app - bot token (xoxb-)");
666
822
  console.log(" Bot is invited to channels. Search and news unavailable.");
667
823
  console.log("");
668
824
 
669
825
  const rl = createInterface({ input: process.stdin, output: process.stdout });
670
826
  try {
671
- const choice = await ask(rl, "Choice [1/2/3/4]: ");
827
+ const choice = await ask(rl, "Choice [1/2/3/4/5]: ");
672
828
  console.log("");
673
829
 
674
830
  if (choice === "1") {
675
831
  await importFromDesktop(rl, opts.yes ?? false);
676
832
  } else if (choice === "2") {
677
- await loginExisting(rl);
833
+ rl.close();
834
+ await cmdAuthLoginChrome();
835
+ return;
678
836
  } else if (choice === "3") {
679
- await loginNewApp(rl, "user");
837
+ await loginExisting(rl);
680
838
  } else if (choice === "4") {
839
+ await loginNewApp(rl, "user");
840
+ } else if (choice === "5") {
681
841
  await loginNewApp(rl, "bot");
682
842
  } else {
683
- console.error(`Invalid choice: "${choice}". Enter 1, 2, 3, or 4.`);
843
+ console.error(`Invalid choice: "${choice}". Enter 1, 2, 3, 4, or 5.`);
684
844
  process.exit(1);
685
845
  }
686
846
  } finally {
@@ -0,0 +1,230 @@
1
+ // Windows Chrome cookie extraction (app-bound "v20" scheme).
2
+ //
3
+ // Chrome 127+ on Windows wraps the cookie key with app-bound encryption. Recovering it
4
+ // needs steps that only run with elevation, so this module orchestrates a single elevated
5
+ // PowerShell pass and then finishes the AES work here (Windows PowerShell 5.1 has DPAPI but
6
+ // no AES-GCM). The elevated helper produces, in a shared work dir:
7
+ // out.bin — app_bound key after SYSTEM-DPAPI then user-DPAPI
8
+ // cng.bin — (flag 3 only) NCryptDecrypt of the blob's inner key via "Google Chromekey1"
9
+ // Cookies — a VSS snapshot copy of the profile's (exclusively-locked) cookie DB
10
+ // We then derive the master key (flag 1 AES-GCM / flag 2 ChaCha20 / flag 3 CNG+XOR+AES-GCM)
11
+ // and decrypt the `d` cookie (v20: [v20|iv12|ct|tag16], plaintext has a 32-byte prefix).
12
+ //
13
+ // The whole path is Windows-only and best-effort: Chrome-version-specific flag/key changes
14
+ // can break it, in which case the caller falls back to a manual cookie paste.
15
+
16
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, rmSync } from "node:fs";
17
+ import { join } from "node:path";
18
+ import { createDecipheriv } from "node:crypto";
19
+ import { spawnSync } from "node:child_process";
20
+
21
+ // Flag-1 (AES-256-GCM) and flag-2 (ChaCha20-Poly1305) unwrap keys, and the flag-3 XOR mask,
22
+ // embedded in Chrome's elevation service. Stable across recent versions but not guaranteed.
23
+ // Lowercase hex on purpose: these are public Chrome crypto constants, not Slack IDs — lowercase
24
+ // keeps the repo's PII scanner (which matches uppercase C…/U…/D… id shapes) from false-positiving.
25
+ const FLAG1_AES_KEY = Buffer.from("b31c6e241ac846728da9c1fac4936651cffb944d143ab816276bcc6da0284787", "hex");
26
+ const FLAG2_CHACHA_KEY = Buffer.from("e98f37d7f4e1fa433d19304dc2258042090e2d1d7eea7670d41f738d08729660", "hex");
27
+ const FLAG3_XOR_KEY = Buffer.from("ccf8a1cec56605b8517552ba1a2d061c03a29e90274fb2fcf59ba4b75c392390", "hex");
28
+
29
+ const WORK_DIR = "C:\\ProgramData\\slack-term-abe";
30
+
31
+ function chromeUserDataDir(): string {
32
+ const localAppData = process.env.LOCALAPPDATA ?? join(process.env.USERPROFILE ?? "", "AppData", "Local");
33
+ return join(localAppData, "Google", "Chrome", "User Data");
34
+ }
35
+
36
+ // The two SYSTEM-task inner scripts are written to disk as their own files (paths hardcoded to
37
+ // WORK_DIR), so the outer script only references them — this avoids nested PowerShell here-strings
38
+ // and their escaping pitfalls. WORK_DIR is a fixed constant, so nothing needs interpolation here.
39
+ export const PS_INNER_DPAPI = String.raw`try {
40
+ Add-Type -AssemblyName System.Security
41
+ $d = [IO.File]::ReadAllBytes('C:\ProgramData\slack-term-abe\in.bin')
42
+ $o = [Security.Cryptography.ProtectedData]::Unprotect($d, $null, 'CurrentUser')
43
+ [IO.File]::WriteAllBytes('C:\ProgramData\slack-term-abe\sys.bin', $o)
44
+ } catch { Set-Content 'C:\ProgramData\slack-term-abe\dpapi.err' ($_.Exception.Message) }
45
+ `;
46
+
47
+ export const PS_INNER_CNG = String.raw`try {
48
+ Add-Type -TypeDefinition @'
49
+ using System; using System.Runtime.InteropServices;
50
+ public static class NC {
51
+ [DllImport("ncrypt.dll", CharSet=CharSet.Unicode)] public static extern int NCryptOpenStorageProvider(out IntPtr p, string n, uint f);
52
+ [DllImport("ncrypt.dll", CharSet=CharSet.Unicode)] public static extern int NCryptOpenKey(IntPtr p, out IntPtr k, string n, uint s, uint f);
53
+ [DllImport("ncrypt.dll")] public static extern int NCryptDecrypt(IntPtr k, byte[] i, int ci, IntPtr pad, byte[] o, int co, out int r, uint f);
54
+ public static byte[] Dec(byte[] input){ IntPtr p,k; int s;
55
+ s=NCryptOpenStorageProvider(out p,"Microsoft Software Key Storage Provider",0); if(s!=0) throw new Exception("prov "+s);
56
+ s=NCryptOpenKey(p,out k,"Google Chromekey1",0,0); if(s!=0){ s=NCryptOpenKey(p,out k,"Google Chromekey1",0,0x20); if(s!=0) throw new Exception("key "+s);}
57
+ int n; s=NCryptDecrypt(k,input,input.Length,IntPtr.Zero,null,0,out n,0x40); if(s!=0) throw new Exception("d1 "+s);
58
+ byte[] o=new byte[n]; s=NCryptDecrypt(k,input,input.Length,IntPtr.Zero,o,n,out n,0x40); if(s!=0) throw new Exception("d2 "+s);
59
+ byte[] r=new byte[n]; Array.Copy(o,r,n); return r; } }
60
+ '@
61
+ [IO.File]::WriteAllBytes('C:\ProgramData\slack-term-abe\cng.bin', [NC]::Dec([IO.File]::ReadAllBytes('C:\ProgramData\slack-term-abe\eak.bin')))
62
+ } catch { Set-Content 'C:\ProgramData\slack-term-abe\cng.err' ($_.Exception.Message) }
63
+ `;
64
+
65
+ /**
66
+ * The outer elevated PowerShell orchestrator. Profile paths are embedded as single-quoted
67
+ * literals (paths may contain spaces but never single quotes). It references the two inner
68
+ * scripts by path (written separately), so there are no nested here-strings to escape.
69
+ */
70
+ export function buildHelperPs(userDataDir: string, profileDir: string): string {
71
+ const q = (s: string) => `'${s.replace(/'/g, "''")}'`;
72
+ return String.raw`
73
+ $ErrorActionPreference = 'Stop'
74
+ Add-Type -AssemblyName System.Security
75
+ $work = 'C:\ProgramData\slack-term-abe'
76
+ New-Item -ItemType Directory -Force -Path $work | Out-Null
77
+ Remove-Item "$work\sys.bin","$work\out.bin","$work\eak.bin","$work\cng.bin","$work\cng.err","$work\Cookies","$work\log.txt" -ErrorAction SilentlyContinue
78
+ Start-Transcript -Path "$work\log.txt" -Force | Out-Null
79
+ trap { Write-Host ("ERROR: " + $_.Exception.Message); try { Stop-Transcript | Out-Null } catch {}; exit 1 }
80
+
81
+ function Run-SystemTask($name, $sp) {
82
+ # $sp is always under C:\ProgramData\slack-term-abe (no spaces), so no inner quoting is needed.
83
+ schtasks /Create /F /RU SYSTEM /SC ONCE /ST 00:00 /TN $name /TR "powershell -NonInteractive -ExecutionPolicy Bypass -File $sp" | Out-Null
84
+ schtasks /Run /TN $name | Out-Null
85
+ }
86
+
87
+ $userData = ` + q(userDataDir) + `
88
+ $profileDir = ` + q(profileDir) + String.raw`
89
+
90
+ # 1) app_bound key (strip APPB)
91
+ $ls = Get-Content "$userData\Local State" -Raw | ConvertFrom-Json
92
+ $appb = [Convert]::FromBase64String($ls.os_crypt.app_bound_encrypted_key)
93
+ if ([Text.Encoding]::ASCII.GetString($appb[0..3]) -ne 'APPB') { throw 'app_bound key is not APPB (unexpected Chrome build)' }
94
+ [IO.File]::WriteAllBytes("$work\in.bin", $appb[4..($appb.Length-1)])
95
+
96
+ # 2) SYSTEM DPAPI (inner_dpapi.ps1 written separately)
97
+ Run-SystemTask 'slack_term_abe_dpapi' "$work\inner_dpapi.ps1"
98
+ for ($i=0; $i -lt 100 -and -not (Test-Path "$work\sys.bin"); $i++) { Start-Sleep -Milliseconds 100 }
99
+ schtasks /Delete /F /TN 'slack_term_abe_dpapi' | Out-Null
100
+ if (-not (Test-Path "$work\sys.bin")) { throw 'SYSTEM DPAPI produced no output' }
101
+
102
+ # 3) user DPAPI (this elevated shell is the user)
103
+ $out = [Security.Cryptography.ProtectedData]::Unprotect([IO.File]::ReadAllBytes("$work\sys.bin"), $null, 'CurrentUser')
104
+ [IO.File]::WriteAllBytes("$work\out.bin", $out)
105
+
106
+ # 4) flag 3 -> SYSTEM NCryptDecrypt of the inner key (inner_cng.ps1 written separately)
107
+ $headerLen = [BitConverter]::ToUInt32($out, 0)
108
+ $flagOff = 4 + $headerLen + 4
109
+ if ($out[$flagOff] -eq 3) {
110
+ [IO.File]::WriteAllBytes("$work\eak.bin", $out[($flagOff+1)..($flagOff+32)])
111
+ Run-SystemTask 'slack_term_abe_cng' "$work\inner_cng.ps1"
112
+ for ($i=0; $i -lt 150 -and -not (Test-Path "$work\cng.bin"); $i++) { Start-Sleep -Milliseconds 100 }
113
+ schtasks /Delete /F /TN 'slack_term_abe_cng' | Out-Null
114
+ if (-not (Test-Path "$work\cng.bin")) { $e = if (Test-Path "$work\cng.err") { Get-Content "$work\cng.err" } else { '' }; throw "SYSTEM NCryptDecrypt failed $e" }
115
+ }
116
+
117
+ # 5) VSS snapshot copy of the (exclusively-locked) cookie DB
118
+ $rel = ($userData -replace '^[A-Za-z]:','') + "\$profileDir\Network\Cookies"
119
+ $shadowId = $null
120
+ try {
121
+ $r = (Get-WmiObject -List Win32_ShadowCopy).Create('C:\', 'ClientAccessible')
122
+ if ($r.ReturnValue -ne 0) { throw "VSS create $($r.ReturnValue)" }
123
+ $shadowId = $r.ShadowID
124
+ $dev = (Get-CimInstance Win32_ShadowCopy | Where-Object { $_.ID -eq $shadowId }).DeviceObject
125
+ [System.IO.File]::Copy("$dev$rel", "$work\Cookies", $true)
126
+ } finally {
127
+ if ($shadowId) { try { (Get-CimInstance Win32_ShadowCopy | Where-Object { $_.ID -eq $shadowId }).Delete() } catch {} }
128
+ }
129
+ if (-not (Test-Path "$work\Cookies")) { throw 'VSS cookie copy failed' }
130
+
131
+ Remove-Item "$work\in.bin","$work\sys.bin","$work\eak.bin","$work\inner_dpapi.ps1","$work\inner_cng.ps1" -ErrorAction SilentlyContinue
132
+ $u = $env:USERNAME
133
+ icacls "$work\out.bin" /grant "$($u):(R)" | Out-Null
134
+ icacls "$work\Cookies" /grant "$($u):(R)" | Out-Null
135
+ if (Test-Path "$work\cng.bin") { icacls "$work\cng.bin" /grant "$($u):(R)" | Out-Null }
136
+ Write-Host 'OK'
137
+ try { Stop-Transcript | Out-Null } catch {}
138
+ `;
139
+ }
140
+
141
+ /** Parse the double-DPAPI blob and derive the 32-byte cookie master key. */
142
+ export function deriveMasterKey(out: Buffer, cng?: Buffer): Buffer {
143
+ const headerLen = out.readUInt32LE(0);
144
+ const flagOff = 4 + headerLen + 4;
145
+ const flag = out[flagOff];
146
+ if (flag === 1 || flag === 2) {
147
+ // [flag(1)][iv(12)][ct(32)][tag(16)]
148
+ const iv = out.subarray(flagOff + 1, flagOff + 13);
149
+ const ct = out.subarray(flagOff + 13, flagOff + 45);
150
+ const tag = out.subarray(flagOff + 45, flagOff + 61);
151
+ const d = flag === 1
152
+ ? createDecipheriv("aes-256-gcm", FLAG1_AES_KEY, iv)
153
+ : createDecipheriv("chacha20-poly1305", FLAG2_CHACHA_KEY, iv, { authTagLength: 16 });
154
+ d.setAuthTag(tag);
155
+ return Buffer.concat([d.update(ct), d.final()]);
156
+ }
157
+ if (flag === 3) {
158
+ // [flag(1)][encrypted_aes_key(32)][iv(12)][ct(32)][tag(16)]; encrypted_aes_key is CNG-decrypted (cng)
159
+ if (!cng) throw new Error("flag 3 requires the CNG-decrypted key (cng.bin missing)");
160
+ const iv = out.subarray(flagOff + 33, flagOff + 45);
161
+ const ct = out.subarray(flagOff + 45, flagOff + 77);
162
+ const tag = out.subarray(flagOff + 77, flagOff + 93);
163
+ const xored = Buffer.from(cng.map((b, i) => b ^ FLAG3_XOR_KEY[i]!));
164
+ const d = createDecipheriv("aes-256-gcm", xored, iv);
165
+ d.setAuthTag(tag);
166
+ return Buffer.concat([d.update(ct), d.final()]);
167
+ }
168
+ throw new Error(`Unsupported app-bound key flag: ${flag}`);
169
+ }
170
+
171
+ /** Decrypt a v20 cookie value with the master key. Returns the cookie string (32-byte prefix stripped). */
172
+ export function decryptCookieV20(master: Buffer, encryptedValue: Buffer): string {
173
+ const iv = encryptedValue.subarray(3, 15);
174
+ const ct = encryptedValue.subarray(15, encryptedValue.length - 16);
175
+ const tag = encryptedValue.subarray(encryptedValue.length - 16);
176
+ const d = createDecipheriv("aes-256-gcm", master, iv);
177
+ d.setAuthTag(tag);
178
+ return Buffer.concat([d.update(ct), d.final()]).subarray(32).toString("utf8");
179
+ }
180
+
181
+ /**
182
+ * Extract the Slack `d` (xoxd-) cookie from a Chrome profile on Windows via the app-bound
183
+ * (v20) scheme. Triggers a single UAC elevation. Returns the cookie, or throws with a
184
+ * descriptive message the caller can turn into manual-paste guidance.
185
+ */
186
+ export async function extractSlackXoxdWindows(profileDir: string): Promise<string> {
187
+ if (process.platform !== "win32") throw new Error("Windows-only");
188
+ const userDataDir = chromeUserDataDir();
189
+ if (!existsSync(join(userDataDir, "Local State"))) throw new Error(`Chrome Local State not found at ${userDataDir}`);
190
+
191
+ mkdirSync(WORK_DIR, { recursive: true });
192
+ const scriptPath = join(WORK_DIR, "extract.ps1");
193
+ writeFileSync(scriptPath, buildHelperPs(userDataDir, profileDir));
194
+ writeFileSync(join(WORK_DIR, "inner_dpapi.ps1"), PS_INNER_DPAPI);
195
+ writeFileSync(join(WORK_DIR, "inner_cng.ps1"), PS_INNER_CNG);
196
+
197
+ // One elevated pass (UAC), run windowless and waited on.
198
+ const launch = `Start-Process powershell -Verb RunAs -Wait -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-WindowStyle','Hidden','-File','${scriptPath.replace(/'/g, "''")}')`;
199
+ const res = spawnSync("powershell", ["-NoProfile", "-Command", launch], { encoding: "utf8", windowsHide: true });
200
+ if (res.status !== 0 && res.error) throw new Error(`Failed to launch elevated helper: ${res.error.message}`);
201
+
202
+ const outPath = join(WORK_DIR, "out.bin");
203
+ const cookiesPath = join(WORK_DIR, "Cookies");
204
+ if (!existsSync(outPath) || !existsSync(cookiesPath)) {
205
+ const log = existsSync(join(WORK_DIR, "log.txt")) ? readFileSync(join(WORK_DIR, "log.txt"), "utf8").split("\n").filter((l) => l.startsWith("ERROR")).join(" ") : "";
206
+ throw new Error(`Elevated extraction did not complete${log ? ` (${log.trim()})` : " (UAC declined or Chrome build unsupported)"}`);
207
+ }
208
+
209
+ try {
210
+ const out = readFileSync(outPath);
211
+ const cng = existsSync(join(WORK_DIR, "cng.bin")) ? readFileSync(join(WORK_DIR, "cng.bin")) : undefined;
212
+ const master = deriveMasterKey(out, cng);
213
+
214
+ const { Database } = require("bun:sqlite") as typeof import("bun:sqlite");
215
+ const db = new Database(cookiesPath, { readonly: true });
216
+ const row = db
217
+ .query("SELECT encrypted_value v FROM cookies WHERE name = char(100) AND host_key LIKE char(37,115,108,97,99,107,37) LIMIT 1")
218
+ .get() as { v: Uint8Array } | null;
219
+ db.close();
220
+ if (!row) throw new Error("no Slack `d` cookie in that Chrome profile");
221
+ const value = Buffer.from(row.v);
222
+ if (value.subarray(0, 3).toString() !== "v20") throw new Error(`unexpected cookie version ${value.subarray(0, 3).toString("latin1")}`);
223
+ const cookie = decryptCookieV20(master, value);
224
+ if (!cookie.startsWith("xoxd-")) throw new Error("decrypted value is not an xoxd- cookie");
225
+ return cookie;
226
+ } finally {
227
+ // Never leave decrypted key material / a cookie DB copy on disk (SLACK_KEEP_ABE keeps it for debugging).
228
+ if (!process.env.SLACK_KEEP_ABE) rmSync(WORK_DIR, { recursive: true, force: true });
229
+ }
230
+ }