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/README.md +35 -3
- package/SKILL.md +27 -9
- package/dist/cli.js +277 -141
- package/package.json +1 -1
- package/ts/ask.ts +14 -3
- package/ts/auth.ts +383 -54
- package/ts/chromeCookieWin.ts +230 -0
- package/ts/cli.ts +252 -42
- package/ts/leveldb.ts +154 -0
- package/ts/profiles.ts +73 -19
- package/ts/sentlog.ts +356 -0
- package/ts/slack-app.ts +287 -185
- package/ts/slack.ts +31 -5
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,
|
|
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
|
|
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
|
|
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
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
|
|
99
|
-
|
|
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)
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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
|
-
|
|
123
|
+
text = "";
|
|
121
124
|
}
|
|
125
|
+
textCache.set(file, text);
|
|
126
|
+
return text;
|
|
127
|
+
};
|
|
122
128
|
|
|
123
|
-
|
|
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
|
-
|
|
175
|
-
|
|
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
|
|
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
|
|
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()])
|
|
258
|
-
|
|
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;
|
|
351
|
+
totalProfiles: number;
|
|
322
352
|
};
|
|
323
353
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
-
|
|
361
|
+
function linuxChromePassword(): string | undefined {
|
|
331
362
|
try {
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
-
|
|
376
|
+
const userDataDir = chromeUserDataDir();
|
|
377
|
+
if (!existsSync(userDataDir)) return { candidates: [], totalProfiles: 0 };
|
|
340
378
|
|
|
341
|
-
|
|
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
|
-
|
|
400
|
+
const dbPath = [join(userDataDir, profileDir, "Network", "Cookies"), join(userDataDir, profileDir, "Cookies")]
|
|
401
|
+
.find(existsSync);
|
|
402
|
+
if (!dbPath) continue;
|
|
349
403
|
|
|
350
|
-
const
|
|
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
|
-
|
|
356
|
-
|
|
357
|
-
.
|
|
358
|
-
|
|
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.
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
if (!
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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("
|
|
380
|
-
//
|
|
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
|
-
|
|
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
|
|
395
|
-
const home = homedir();
|
|
396
|
-
if (process.platform === "darwin") return join(home, "Library", "Application Support", "Firefox", "Profiles");
|
|
397
|
-
if (process.platform === "linux") return
|
|
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
|
|
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
|
-
|
|
441
|
-
|
|
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
|
-
|
|
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
|
-
|
|
465
|
-
}
|
|
466
|
-
|
|
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;
|