slack-term 1.35.0 → 1.37.0

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/ts/slack-app.ts CHANGED
@@ -2,11 +2,12 @@
2
2
  // Reads raw .ldb/.log files with regex — works even while Slack is running (no exclusive lock).
3
3
  // Also extracts the xoxd session cookie from the Slack Cookies SQLite database (macOS only).
4
4
 
5
- import { readdirSync, readFileSync, existsSync, copyFileSync, unlinkSync } from "node:fs";
5
+ import { readdirSync, readFileSync, existsSync, copyFileSync, mkdtempSync, rmSync } from "node:fs";
6
6
  import { homedir, tmpdir } from "node:os";
7
7
  import { join, dirname } from "node:path";
8
- import { pbkdf2Sync, createDecipheriv } from "node:crypto";
9
- import { execSync } from "node:child_process";
8
+ import { pbkdf2Sync, createDecipheriv, createHash, timingSafeEqual } from "node:crypto";
9
+ import { execSync, execFileSync } from "node:child_process";
10
+ import { ldbDecompressedBytes } from "./leveldb.ts";
10
11
 
11
12
  export type SlackAppSession = {
12
13
  token: string; // xoxc-...
@@ -16,23 +17,27 @@ export type SlackAppSession = {
16
17
  url?: string;
17
18
  };
18
19
 
19
- function leveldbPath(): string {
20
- const home = homedir();
20
+ function leveldbPaths(): string[] {
21
+ const home = process.env.HOME ?? homedir();
21
22
  if (process.platform === "darwin") {
22
- return join(home, "Library", "Application Support", "Slack", "Local Storage", "leveldb");
23
+ return [join(home, "Library", "Application Support", "Slack", "Local Storage", "leveldb")];
23
24
  }
24
25
  if (process.platform === "linux") {
25
- return join(home, ".config", "Slack", "Local Storage", "leveldb");
26
+ return [
27
+ join(home, ".config", "Slack", "Local Storage", "leveldb"),
28
+ join(home, "snap", "slack", "current", ".config", "Slack", "Local Storage", "leveldb"),
29
+ join(home, ".var", "app", "com.slack.Slack", "config", "Slack", "Local Storage", "leveldb"),
30
+ ];
26
31
  }
27
32
  if (process.platform === "win32") {
28
33
  const appData = process.env.APPDATA ?? join(home, "AppData", "Roaming");
29
- return join(appData, "Slack", "Local Storage", "leveldb");
34
+ return [join(appData, "Slack", "Local Storage", "leveldb")];
30
35
  }
31
36
  throw new Error(`Unsupported platform: ${process.platform}`);
32
37
  }
33
38
 
34
39
  function cookiesDbPath(): string {
35
- const home = homedir();
40
+ const home = process.env.HOME ?? homedir();
36
41
  if (process.platform === "darwin") {
37
42
  return join(home, "Library", "Application Support", "Slack", "Cookies");
38
43
  }
@@ -91,88 +96,61 @@ export function extractXoxd(): string | undefined {
91
96
  }
92
97
  }
93
98
 
94
- // Scan raw LevelDB files for xoxc- tokens without opening the DB exclusively.
95
- // Works while Slack is running.
96
- //
97
- // Strategy:
98
- // 1. .log files (write-ahead log): values are stored as readable JSON strings.
99
- // Scan for "token":"xoxc-..." to get complete, clean tokens + workspace URL.
100
- // 2. .ldb files (sorted tables): values are length-prefixed with binary framing
101
- // bytes that can split the token mid-segment. Use gap-bridging as fallback.
102
- export async function extractSessions(): Promise<SlackAppSession[]> {
103
- const dbPath = leveldbPath();
104
- if (!existsSync(dbPath)) {
105
- throw new Error(
106
- `Slack desktop app LevelDB not found at:\n ${dbPath}\nIs Slack installed and opened at least once?`,
107
- );
108
- }
99
+ // Scan a LevelDB directory for xoxc- tokens without opening the DB exclusively
100
+ // (works while the owning app is running). Reads the raw .log/.ldb files, then
101
+ // pulls "token":"xoxc-…" plus nearby workspace url/name out of the values. .ldb
102
+ // data blocks are Snappy-compressed, so they are decompressed first.
103
+ function scanLevelDbSessions(dbPath: string): SlackAppSession[] {
104
+ if (!existsSync(dbPath)) return [];
109
105
 
110
106
  const files = readdirSync(dbPath).filter((f) => f.endsWith(".ldb") || f.endsWith(".log"));
111
- if (files.length === 0) throw new Error(`No LevelDB data files found in ${dbPath}`);
112
-
113
- const sessions = new Map<string, SlackAppSession>();
114
-
115
- for (const file of files) {
116
- let content: string;
107
+ if (files.length === 0) return [];
108
+
109
+ // Readable text per file: .ldb data blocks are Snappy-compressed (a copy-op can
110
+ // split "token":"xoxc-…" apart), so decompress them; .log (write-ahead log) values
111
+ // are already plaintext JSON. Memoized — both passes below reuse it.
112
+ const textCache = new Map<string, string>();
113
+ const fileText = (file: string): string => {
114
+ const cached = textCache.get(file);
115
+ if (cached !== undefined) return cached;
116
+ let text = "";
117
117
  try {
118
- content = readFileSync(join(dbPath, file), "latin1");
118
+ const raw = readFileSync(join(dbPath, file));
119
+ text = file.endsWith(".ldb")
120
+ ? (ldbDecompressedBytes(raw).toString("latin1") || raw.toString("latin1"))
121
+ : raw.toString("latin1");
119
122
  } catch {
120
- continue;
123
+ text = "";
121
124
  }
125
+ textCache.set(file, text);
126
+ return text;
127
+ };
122
128
 
123
- if (file.endsWith(".log")) {
124
- // .log files store JSON values verbatim — extract complete token + URL + name in one pass.
125
- for (const m of content.matchAll(/"token":"(xoxc-[^"]+)"/g)) {
126
- const token = m[1]!;
127
- const teamId = token.split("-")[1] ?? "";
128
- if (!teamId || token.length < 40) continue;
129
-
130
- // Look for workspace metadata near this token (within ±2KB)
131
- const start = Math.max(0, m.index! - 2000);
132
- const end = Math.min(content.length, m.index! + 2000);
133
- const ctx = content.slice(start, end);
134
- const urlMatch = ctx.match(/"url":"(https:\/\/[a-z0-9-]+\.slack\.com\/)"/);
135
- const nameMatch = ctx.match(/"(?:team_name|name)":"([^"]{2,60})"/);
136
-
137
- const existing = sessions.get(teamId);
138
- if (!existing || token.length > existing.token.length) {
139
- const entry: SlackAppSession = { token, teamId };
140
- const url = urlMatch?.[1] ?? existing?.url;
141
- const teamName = nameMatch?.[1] ?? existing?.teamName;
142
- if (url) entry.url = url;
143
- if (teamName) entry.teamName = teamName;
144
- sessions.set(teamId, entry);
145
- }
146
- }
147
- } else {
148
- // .ldb files: binary framing bytes split the token mid-segment.
149
- // Match the first 3 numeric segments, then bridge over binary bytes to find the rest.
150
- for (const m of content.matchAll(/xoxc-(\d+)-(\d+)-(\d+)/g)) {
151
- const teamId = m[1]!;
152
- // Skip if already found in a .log file (prefer clean .log data)
153
- if (sessions.has(teamId)) continue;
154
-
155
- let i = m.index! + m[0].length;
156
- const limit = Math.min(i + 10, content.length);
157
- while (i < limit && (content.charCodeAt(i) < 0x30 || content.charCodeAt(i) > 0x39)) i++;
158
-
159
- let seg3tail = "";
160
- while (i < content.length && content.charCodeAt(i) >= 0x30 && content.charCodeAt(i) <= 0x39) {
161
- seg3tail += content[i++];
162
- }
163
- if (content[i] !== "-") continue;
164
- i++;
165
- let hex = "";
166
- while (i < content.length) {
167
- const code = content.charCodeAt(i);
168
- if ((code >= 0x30 && code <= 0x39) || (code >= 0x61 && code <= 0x66)) {
169
- hex += content[i++];
170
- } else break;
171
- }
172
- if (hex.length < 20) continue;
129
+ const sessions = new Map<string, SlackAppSession>();
173
130
 
174
- const token = `${m[0]}${seg3tail}-${hex}`;
175
- sessions.set(teamId, { token, teamId });
131
+ for (const file of files) {
132
+ const content = fileText(file);
133
+ // Values are verbatim JSON (after decompression) — extract token + URL + name in one pass.
134
+ for (const m of content.matchAll(/"token":"(xoxc-[^"]+)"/g)) {
135
+ const token = m[1]!;
136
+ const teamId = token.split("-")[1] ?? "";
137
+ if (!teamId || token.length < 40) continue;
138
+
139
+ // Look for workspace metadata near this token (within ±2KB)
140
+ const start = Math.max(0, m.index! - 2000);
141
+ const end = Math.min(content.length, m.index! + 2000);
142
+ const ctx = content.slice(start, end);
143
+ const urlMatch = ctx.match(/"url":"(https:\/\/[a-z0-9-]+\.slack\.com\/)"/);
144
+ const nameMatch = ctx.match(/"(?:team_name|name)":"([^"]{2,60})"/);
145
+
146
+ const existing = sessions.get(teamId);
147
+ if (!existing || token.length > existing.token.length) {
148
+ const entry: SlackAppSession = { token, teamId };
149
+ const url = urlMatch?.[1] ?? existing?.url;
150
+ const teamName = nameMatch?.[1] ?? existing?.teamName;
151
+ if (url) entry.url = url;
152
+ if (teamName) entry.teamName = teamName;
153
+ sessions.set(teamId, entry);
176
154
  }
177
155
  }
178
156
  }
@@ -180,17 +158,7 @@ export async function extractSessions(): Promise<SlackAppSession[]> {
180
158
  // Second pass: for sessions still missing a name, search all file content
181
159
  // (printable-transformed) for team_name/name near the numeric team ID, then
182
160
  // fall back to a title-cased URL slug.
183
- const allFiles = readdirSync(dbPath).filter((f) => f.endsWith(".ldb") || f.endsWith(".log"));
184
- const allContent = allFiles
185
- .map((f) => {
186
- try {
187
- // Replace non-printable bytes with spaces to expose readable text in binary ldb frames.
188
- return readFileSync(join(dbPath, f), "latin1").replace(/[^\x20-\x7e]/g, " ");
189
- } catch {
190
- return "";
191
- }
192
- })
193
- .join(" ");
161
+ const allContent = files.map((f) => fileText(f).replace(/[^\x20-\x7e]/g, " ")).join(" ");
194
162
 
195
163
  for (const session of sessions.values()) {
196
164
  if (session.teamName) continue;
@@ -233,36 +201,104 @@ export async function extractSessions(): Promise<SlackAppSession[]> {
233
201
  }
234
202
  }
235
203
 
204
+ return [...sessions.values()];
205
+ }
206
+
207
+ /**
208
+ * Import sessions from a LevelDB store. Defaults to the Slack desktop app (all
209
+ * platforms for the xoxc token; the shared xoxd cookie is attached on macOS only).
210
+ * `leveldbOverride` points it at another store (e.g. a Chrome profile's LocalStorage,
211
+ * used by extractChromeSessions) — no desktop xoxd cookie is attached in that case.
212
+ * .ldb data blocks are Snappy-compressed, so scanLevelDbSessions decompresses them.
213
+ */
214
+ export async function extractSessions(leveldbOverride?: string): Promise<SlackAppSession[]> {
215
+ const dbPath = leveldbOverride ?? leveldbPaths().find(existsSync);
216
+ if (!dbPath) {
217
+ throw new Error(
218
+ `Slack desktop app LevelDB not found at:\n ${leveldbPaths().join("\n ")}\nIs Slack installed and opened at least once?`,
219
+ );
220
+ }
221
+ const result = scanLevelDbSessions(dbPath);
236
222
  // Attach xoxd cookie to all sessions (shared — one Slack desktop app, one cookie jar)
237
- const xoxd = extractXoxd();
238
- const result = [...sessions.values()];
223
+ const xoxd = leveldbOverride ? undefined : extractXoxd();
239
224
  if (xoxd) {
240
225
  for (const s of result) s.cookie = xoxd;
241
226
  }
242
227
  return result;
243
228
  }
244
229
 
230
+ /** Account email for a Chrome profile from Local State → info_cache (never the gaia real name). */
231
+ function chromeProfileEmail(userDataDir: string, profileDir: string): string | undefined {
232
+ try {
233
+ const localState = JSON.parse(
234
+ readFileSync(join(userDataDir, "Local State"), "utf8"),
235
+ ) as { profile?: { info_cache?: Record<string, { user_name?: string }> } };
236
+ const email = localState.profile?.info_cache?.[profileDir]?.user_name;
237
+ return email || undefined;
238
+ } catch {
239
+ return undefined;
240
+ }
241
+ }
242
+
243
+ export type ChromeSessionProfile = {
244
+ profileDir: string; // "Default", "Profile 3", …
245
+ email?: string; // account email from Local State, if known
246
+ label: string; // display name (nickname + email)
247
+ sessions: SlackAppSession[]; // xoxc tokens found in this profile's LocalStorage (no cookie)
248
+ };
249
+
250
+ /**
251
+ * Discover Slack web sessions in Chrome by scanning each profile's LocalStorage
252
+ * LevelDB for xoxc- tokens. LocalStorage is plaintext, so this works on all
253
+ * platforms and needs no keychain/DPAPI access — it yields tokens only. The
254
+ * xoxd cookie is fetched separately (macOS at-rest via discoverChromeCookies,
255
+ * or a live CDP read on other platforms).
256
+ */
257
+ export function discoverChromeSessions(): ChromeSessionProfile[] {
258
+ const userDataDir = chromeUserDataDir();
259
+ if (!userDataDir || !existsSync(userDataDir)) return [];
260
+
261
+ const profileDirs = ["Default", ...readdirSync(userDataDir).filter((d) => d.startsWith("Profile "))];
262
+ const out: ChromeSessionProfile[] = [];
263
+ for (const profileDir of profileDirs) {
264
+ const dbPath = join(userDataDir, profileDir, "Local Storage", "leveldb");
265
+ const sessions = scanLevelDbSessions(dbPath);
266
+ if (sessions.length === 0) continue;
267
+ const email = chromeProfileEmail(userDataDir, profileDir);
268
+ out.push({ profileDir, ...(email ? { email } : {}), label: chromeProfileName(userDataDir, profileDir), sessions });
269
+ }
270
+ return out;
271
+ }
272
+
245
273
  /**
246
- * Try both AES-128-CBC variants used across Chromium versions to decrypt a v10 cookie.
274
+ * Try both AES-128-CBC layouts for a versioned Chromium cookie.
247
275
  * Returns the decrypted string if it starts with "xoxd-", otherwise undefined.
248
276
  *
249
277
  * Variant A (older Chrome): IV = 16 spaces, ciphertext = enc[3:]
250
278
  * Variant B (newer Chrome 127+): IV = enc[19:35], ciphertext = enc[35:]
251
279
  */
252
- function decryptV10Cookie(enc: Buffer, aesKey: Buffer): string | undefined {
280
+ function decryptV10Cookie(enc: Buffer, aesKey: Buffer, hostKey: string, allowEmbedded = false): string | undefined {
253
281
  const tryDecrypt = (iv: Buffer, ciphertext: Buffer): string | undefined => {
254
282
  try {
255
283
  const decipher = createDecipheriv("aes-128-cbc", aesKey, iv);
256
284
  decipher.setAutoPadding(true);
257
- const result = Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
258
- return result.startsWith("xoxd-") ? result : undefined;
285
+ const result = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
286
+ if (result.subarray(0, 5).toString() === "xoxd-") return result.toString("utf8");
287
+ if (result.length >= 37) {
288
+ const hostHash = createHash("sha256").update(hostKey).digest();
289
+ if (timingSafeEqual(result.subarray(0, 32), hostHash)) {
290
+ const value = result.subarray(32).toString("utf8");
291
+ if (value.startsWith("xoxd-")) return value;
292
+ }
293
+ }
294
+ return undefined;
259
295
  } catch {
260
296
  return undefined;
261
297
  }
262
298
  };
263
299
 
264
300
  return tryDecrypt(Buffer.alloc(16, 32), enc.slice(3))
265
- ?? (enc.length >= 35 ? tryDecrypt(enc.slice(19, 35), enc.slice(35)) : undefined);
301
+ ?? (allowEmbedded && enc.length >= 35 ? tryDecrypt(enc.slice(19, 35), enc.slice(35)) : undefined);
266
302
  }
267
303
 
268
304
  export type ChromeCookieCandidate = {
@@ -309,101 +345,168 @@ function chromeProfileName(userDataDir: string, profileDir: string): string {
309
345
  }
310
346
  }
311
347
 
312
- /**
313
- * Discover all Chrome browser profiles that have a Slack xoxd cookie (macOS only).
314
- *
315
- * Requires the Chrome Safe Storage key from the system keychain. When called from an
316
- * interactive terminal, macOS will show a dialog asking for the login password.
317
- * Throws on v20 (app-bound encryption). Returns [] if keychain is inaccessible.
318
- */
348
+ /** Discover Slack cookies in Chrome profiles on macOS and Linux. */
319
349
  export type ChromeDiscoveryResult = {
320
350
  candidates: ChromeCookieCandidate[];
321
- totalProfiles: number; // how many Chrome profile dirs were scanned
351
+ totalProfiles: number;
322
352
  };
323
353
 
324
- export function discoverChromeCookies(): ChromeDiscoveryResult {
325
- if (process.platform !== "darwin") return { candidates: [], totalProfiles: 0 };
326
-
327
- const userDataDir = join(homedir(), "Library", "Application Support", "Google", "Chrome");
328
- if (!existsSync(userDataDir)) return { candidates: [], totalProfiles: 0 };
354
+ function chromeUserDataDir(): string {
355
+ const home = process.env.HOME ?? homedir();
356
+ if (process.platform === "darwin") return join(home, "Library", "Application Support", "Google", "Chrome");
357
+ if (process.platform === "linux") return join(process.env.XDG_CONFIG_HOME ?? join(home, ".config"), "google-chrome");
358
+ return "";
359
+ }
329
360
 
330
- let keychainPw: string;
361
+ function linuxChromePassword(): string | undefined {
331
362
  try {
332
- keychainPw = execSync(
333
- `security find-generic-password -a Chrome -s "Chrome Safe Storage" -w`,
334
- { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] },
335
- ).trimEnd();
363
+ const password = execFileSync("secret-tool", ["lookup", "application", "chrome"], {
364
+ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
365
+ }).trimEnd();
366
+ return password || undefined;
336
367
  } catch {
368
+ return undefined;
369
+ }
370
+ }
371
+
372
+ export function discoverChromeCookies(): ChromeDiscoveryResult {
373
+ if (process.platform !== "darwin" && process.platform !== "linux") {
337
374
  return { candidates: [], totalProfiles: 0 };
338
375
  }
339
- if (!keychainPw) return { candidates: [], totalProfiles: 0 };
376
+ const userDataDir = chromeUserDataDir();
377
+ if (!existsSync(userDataDir)) return { candidates: [], totalProfiles: 0 };
340
378
 
341
- const aesKey = pbkdf2Sync(keychainPw, "saltysalt", 1003, 16, "sha1");
379
+ let macKey: Buffer | undefined;
380
+ if (process.platform === "darwin") {
381
+ let keychainPw: string;
382
+ try {
383
+ keychainPw = execSync(
384
+ `security find-generic-password -a Chrome -s "Chrome Safe Storage" -w`,
385
+ { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] },
386
+ ).trimEnd();
387
+ } catch {
388
+ return { candidates: [], totalProfiles: 0 };
389
+ }
390
+ if (!keychainPw) return { candidates: [], totalProfiles: 0 };
391
+ macKey = pbkdf2Sync(keychainPw, "saltysalt", 1003, 16, "sha1");
392
+ }
342
393
 
343
394
  const profileDirs = ["Default", ...readdirSync(userDataDir).filter((d) => d.startsWith("Profile "))];
344
395
  const candidates: ChromeCookieCandidate[] = [];
396
+ let linuxV11Key: Buffer | undefined;
397
+ let missingLinuxKey = false;
345
398
 
346
399
  for (const profileDir of profileDirs) {
347
- const dbPath = join(userDataDir, profileDir, "Cookies");
348
- if (!existsSync(dbPath)) continue;
400
+ const dbPath = [join(userDataDir, profileDir, "Network", "Cookies"), join(userDataDir, profileDir, "Cookies")]
401
+ .find(existsSync);
402
+ if (!dbPath) continue;
349
403
 
350
- const tmp = join(tmpdir(), `slack-chrome-cookies-${Date.now()}-${profileDir}.db`);
404
+ const tmpDir = mkdtempSync(join(tmpdir(), "slack-chrome-cookies-"));
405
+ const tmp = join(tmpDir, "Cookies");
351
406
  try {
352
407
  copyFileSync(dbPath, tmp);
408
+ if (existsSync(`${dbPath}-wal`)) copyFileSync(`${dbPath}-wal`, `${tmp}-wal`);
353
409
  const { default: Database } = require("bun:sqlite") as typeof import("bun:sqlite");
354
410
  const db = new Database(tmp, { readonly: true });
355
- const row = db
356
- .prepare("SELECT encrypted_value FROM cookies WHERE name='d' AND host_key LIKE '%slack%'")
357
- .get() as { encrypted_value: Uint8Array } | null;
358
- db.close();
411
+ let row: { encrypted_value: Uint8Array; value: string; host_key: string } | null;
412
+ try {
413
+ row = db.prepare(
414
+ "SELECT encrypted_value, value, host_key FROM cookies WHERE name='d' AND (host_key='slack.com' OR host_key LIKE '%.slack.com') LIMIT 1",
415
+ ).get() as typeof row;
416
+ } finally {
417
+ db.close();
418
+ }
359
419
  if (!row) continue;
360
-
361
420
  const enc = Buffer.from(row.encrypted_value);
362
- const prefix = enc.slice(0, 3).toString();
363
- if (prefix === "v10") {
364
- // Try both AES-128-CBC variants used by different Chromium versions:
365
- // 1. Standard (older Chrome): IV = 16 spaces, ciphertext = enc[3:]
366
- // 2. Embedded (newer Chrome/Electron): IV = enc[19:35], ciphertext = enc[35:]
367
- const cookie = decryptV10Cookie(enc, aesKey);
368
- if (!cookie) continue; // neither format produced a valid xoxd- value
369
- candidates.push({ profileDir, profileName: chromeProfileName(userDataDir, profileDir), cookie });
370
- } else if (prefix === "v20") {
371
- throw new Error(
372
- `Chrome cookie uses v20 (app-bound AES-256-GCM) which is not supported yet. ` +
373
- `Prefix found: ${enc.slice(0, 4).toString("hex")}`,
374
- );
375
- } else {
376
- throw new Error(`Unknown cookie encryption prefix: ${enc.slice(0, 4).toString("hex")}`);
421
+ const prefix = enc.subarray(0, 3).toString();
422
+ let cookie: string | undefined;
423
+ if (prefix === "v20") {
424
+ throw new Error("Chrome cookie uses unsupported v20 app-bound encryption.");
425
+ }
426
+ if (process.platform === "linux") {
427
+ if (!enc.length && row.value?.startsWith("xoxd-")) cookie = row.value;
428
+ else if (prefix === "v10") {
429
+ cookie = decryptV10Cookie(enc, pbkdf2Sync("peanuts", "saltysalt", 1, 16, "sha1"), row.host_key);
430
+ } else if (prefix === "v11") {
431
+ if (!linuxV11Key) {
432
+ const password = linuxChromePassword();
433
+ if (password) linuxV11Key = pbkdf2Sync(password, "saltysalt", 1, 16, "sha1");
434
+ }
435
+ if (linuxV11Key) cookie = decryptV10Cookie(enc, linuxV11Key, row.host_key);
436
+ else missingLinuxKey = true;
437
+ }
438
+ } else if (prefix === "v10" && macKey) {
439
+ cookie = decryptV10Cookie(enc, macKey, row.host_key, true);
440
+ } else if (prefix !== "v10") {
441
+ throw new Error("Unknown cookie encryption prefix.");
377
442
  }
443
+ if (cookie) candidates.push({ profileDir, profileName: chromeProfileName(userDataDir, profileDir), cookie });
378
444
  } catch (e: unknown) {
379
- if (e instanceof Error && (e.message.includes("app-bound") || e.message.includes("Unknown cookie"))) throw e;
380
- // Otherwise skip this profile
445
+ if (e instanceof Error && (e.message.includes("unsupported v20") || e.message.includes("Unknown cookie"))) throw e;
446
+ // Skip unreadable profiles without exposing cookie or key material.
381
447
  } finally {
382
- try { unlinkSync(tmp); } catch { /* ignore */ }
448
+ rmSync(tmpDir, { recursive: true, force: true });
383
449
  }
384
450
  }
451
+ if (missingLinuxKey && candidates.length === 0) {
452
+ throw new Error("Chrome v11 cookie key is unavailable. Install secret-tool and unlock the GNOME keyring, or use slack auth firefox.");
453
+ }
385
454
  return { candidates, totalProfiles: profileDirs.length };
386
455
  }
387
456
 
457
+ /** Read xoxc tokens from Chrome's Slack local storage and pair cookies from the same profile. */
458
+ export async function extractChromeSessions(): Promise<SlackAppSession[]> {
459
+ if (process.platform !== "linux") throw new Error("Chrome session import is currently supported on Linux only.");
460
+ const userDataDir = chromeUserDataDir();
461
+ if (!existsSync(userDataDir)) throw new Error("Chrome profile directory was not found.");
462
+ const cookies = new Map(discoverChromeCookies().candidates.map((c) => [c.profileDir, c.cookie]));
463
+ const sessions = new Map<string, SlackAppSession>();
464
+ const profileDirs = ["Default", ...readdirSync(userDataDir).filter((d) => d.startsWith("Profile "))];
465
+ for (const profileDir of profileDirs) {
466
+ const leveldb = join(userDataDir, profileDir, "Local Storage", "leveldb");
467
+ if (!existsSync(leveldb)) continue;
468
+ let found: SlackAppSession[];
469
+ try {
470
+ found = await extractSessions(leveldb);
471
+ } catch {
472
+ continue;
473
+ }
474
+ for (const session of found) {
475
+ if (sessions.has(session.teamId)) {
476
+ throw new Error("More than one Chrome profile has a token for the same workspace. Select one browser profile first.");
477
+ }
478
+ const cookie = cookies.get(profileDir);
479
+ if (cookie) session.cookie = cookie;
480
+ sessions.set(session.teamId, session);
481
+ }
482
+ }
483
+ return [...sessions.values()];
484
+ }
485
+
388
486
  export type FirefoxCookieCandidate = {
389
487
  profileDir: string; // e.g. "abc123.default-release"
390
488
  profileName: string; // display name from profiles.ini, or the dir name
391
489
  cookie: string; // plaintext xoxd-... (Firefox stores cookies unencrypted)
392
490
  };
393
491
 
394
- function firefoxProfilesDir(): string {
395
- const home = homedir();
396
- if (process.platform === "darwin") return join(home, "Library", "Application Support", "Firefox", "Profiles");
397
- if (process.platform === "linux") return join(home, ".mozilla", "firefox");
492
+ function firefoxProfilesDirs(): string[] {
493
+ const home = process.env.HOME ?? homedir();
494
+ if (process.platform === "darwin") return [join(home, "Library", "Application Support", "Firefox", "Profiles")];
495
+ if (process.platform === "linux") return [
496
+ join(home, ".mozilla", "firefox"),
497
+ join(home, "snap", "firefox", "common", ".mozilla", "firefox"),
498
+ join(home, ".var", "app", "org.mozilla.firefox", ".mozilla", "firefox"),
499
+ ];
398
500
  if (process.platform === "win32") {
399
- return join(process.env.APPDATA ?? join(home, "AppData", "Roaming"), "Mozilla", "Firefox", "Profiles");
501
+ return [join(process.env.APPDATA ?? join(home, "AppData", "Roaming"), "Mozilla", "Firefox", "Profiles")];
400
502
  }
401
- return "";
503
+ return [];
402
504
  }
403
505
 
404
506
  /** Read Firefox profiles.ini to map profile dir names to display names. */
405
507
  function firefoxProfileNames(profilesDir: string): Record<string, string> {
406
- const iniPath = join(dirname(profilesDir), "profiles.ini");
508
+ const localIni = join(profilesDir, "profiles.ini");
509
+ const iniPath = existsSync(localIni) ? localIni : join(dirname(profilesDir), "profiles.ini");
407
510
  const map: Record<string, string> = {};
408
511
  if (!existsSync(iniPath)) return map;
409
512
  try {
@@ -431,39 +534,38 @@ function firefoxProfileNames(profilesDir: string): Record<string, string> {
431
534
  * Returns [] if Firefox is not installed or has no Slack session.
432
535
  */
433
536
  export function discoverFirefoxCookies(): FirefoxCookieCandidate[] {
434
- const profilesDir = firefoxProfilesDir();
435
- if (!profilesDir || !existsSync(profilesDir)) return [];
436
-
437
- const nameMap = firefoxProfileNames(profilesDir);
438
537
  const candidates: FirefoxCookieCandidate[] = [];
439
-
440
- let profileDirs: string[];
441
- try {
442
- profileDirs = readdirSync(profilesDir);
443
- } catch {
444
- return [];
445
- }
446
-
447
- for (const profileDir of profileDirs) {
448
- const dbPath = join(profilesDir, profileDir, "cookies.sqlite");
449
- if (!existsSync(dbPath)) continue;
450
-
451
- const tmp = join(tmpdir(), `slack-firefox-cookies-${Date.now()}-${profileDir}.db`);
538
+ for (const profilesDir of firefoxProfilesDirs().filter(existsSync)) {
539
+ const nameMap = firefoxProfileNames(profilesDir);
540
+ let profileDirs: string[];
452
541
  try {
453
- copyFileSync(dbPath, tmp);
454
- const { default: Database } = require("bun:sqlite") as typeof import("bun:sqlite");
455
- const db = new Database(tmp, { readonly: true });
456
- const row = db
457
- .prepare("SELECT value FROM moz_cookies WHERE name='d' AND host LIKE '%slack%' LIMIT 1")
458
- .get() as { value: string } | null;
459
- db.close();
460
- if (!row?.value || !row.value.startsWith("xoxd-")) continue;
461
- const profileName = nameMap[profileDir] ?? profileDir;
462
- candidates.push({ profileDir, profileName, cookie: row.value });
542
+ profileDirs = readdirSync(profilesDir);
463
543
  } catch {
464
- // skip this profile
465
- } finally {
466
- try { unlinkSync(tmp); } catch { /* ignore */ }
544
+ continue;
545
+ }
546
+
547
+ for (const profileDir of profileDirs) {
548
+ const dbPath = join(profilesDir, profileDir, "cookies.sqlite");
549
+ if (!existsSync(dbPath)) continue;
550
+
551
+ const tmpDir = mkdtempSync(join(tmpdir(), "slack-firefox-cookies-"));
552
+ const tmp = join(tmpDir, "cookies.sqlite");
553
+ try {
554
+ copyFileSync(dbPath, tmp);
555
+ const { default: Database } = require("bun:sqlite") as typeof import("bun:sqlite");
556
+ const db = new Database(tmp, { readonly: true });
557
+ const row = db
558
+ .prepare("SELECT value FROM moz_cookies WHERE name='d' AND (host='slack.com' OR host LIKE '%.slack.com') LIMIT 1")
559
+ .get() as { value: string } | null;
560
+ db.close();
561
+ if (!row?.value || !row.value.startsWith("xoxd-")) continue;
562
+ const profileName = nameMap[profileDir] ?? profileDir;
563
+ candidates.push({ profileDir, profileName, cookie: row.value });
564
+ } catch {
565
+ // skip this profile
566
+ } finally {
567
+ rmSync(tmpDir, { recursive: true, force: true });
568
+ }
467
569
  }
468
570
  }
469
571
  return candidates;