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/dist/cli.js +245 -155
- package/package.json +1 -1
- package/ts/auth.ts +218 -58
- package/ts/chromeCookieWin.ts +230 -0
- package/ts/cli.ts +47 -13
- package/ts/leveldb.ts +154 -0
- package/ts/profiles.ts +74 -20
- package/ts/slack-app.ts +111 -87
package/package.json
CHANGED
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
if (!
|
|
340
|
-
console.
|
|
341
|
-
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
-
|
|
367
|
-
|
|
368
|
-
if (
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
|
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 =
|
|
383
|
-
if (isNaN(idx) || idx < 0 || idx >=
|
|
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
|
-
|
|
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
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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)
|
|
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("
|
|
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("
|
|
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
|
-
|
|
833
|
+
rl.close();
|
|
834
|
+
await cmdAuthLoginChrome();
|
|
835
|
+
return;
|
|
678
836
|
} else if (choice === "3") {
|
|
679
|
-
await
|
|
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
|
|
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
|
+
}
|