Skip to content

Commit 8a3245e

Browse files
authored
Merge pull request #62 from stablyai/fix/eslint-warnings
Fix eslint warnings: max-params, unused imports, max-lines
2 parents b6094f9 + 1a7a043 commit 8a3245e

File tree

6 files changed

+168
-170
lines changed

6 files changed

+168
-170
lines changed

src/auth/brave.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ async function extractCookieDFromBrave(): Promise<string> {
148148

149149
for (const password of passwords) {
150150
try {
151-
const decrypted = decryptChromiumCookieValue(data, password, 1003);
151+
const decrypted = decryptChromiumCookieValue(data, { password, iterations: 1003 });
152152
const match = decrypted.match(/xoxd-[A-Za-z0-9%/+_=.-]+/);
153153
if (match) {
154154
return match[0]!;

src/auth/chromium-cookie.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,14 @@ import { pbkdf2Sync, createDecipheriv } from "node:crypto";
22

33
export function decryptChromiumCookieValue(
44
data: Buffer,
5-
password: string,
6-
iterations: number,
5+
options: { password: string; iterations: number },
76
): string {
87
if (!data || data.length === 0) {
98
return "";
109
}
1110

11+
const { password, iterations } = options;
12+
1213
if (iterations < 1) {
1314
throw new RangeError(`iterations must be >= 1, got ${iterations}`);
1415
}

src/auth/desktop-crypto.ts

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import { existsSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
2+
import { execFileSync } from "node:child_process";
3+
import { createDecipheriv, randomUUID } from "node:crypto";
4+
import { tmpdir } from "node:os";
5+
import { join } from "node:path";
6+
import { isRecord } from "../lib/object-type-guards.ts";
7+
8+
const IS_MACOS = process.platform === "darwin";
9+
const IS_LINUX = process.platform === "linux";
10+
11+
export function getSafeStoragePasswords(prefix: string): string[] {
12+
if (IS_MACOS) {
13+
// Electron ("Slack Key") and Mac App Store ("Slack App Store Key") builds
14+
// store separate Safe Storage passwords under the same service name.
15+
// Query each known account explicitly, then fall back to service-only
16+
// lookups to catch unknown account names.
17+
const keychainQueries: { service: string; account?: string }[] = [
18+
{ service: "Slack Safe Storage", account: "Slack Key" },
19+
{ service: "Slack Safe Storage", account: "Slack App Store Key" },
20+
{ service: "Slack Safe Storage" },
21+
{ service: "Chrome Safe Storage" },
22+
{ service: "Chromium Safe Storage" },
23+
];
24+
const passwords: string[] = [];
25+
for (const q of keychainQueries) {
26+
try {
27+
const args = ["-w", "-s", q.service];
28+
if (q.account) {
29+
args.push("-a", q.account);
30+
}
31+
const out = execFileSync("security", ["find-generic-password", ...args], {
32+
encoding: "utf8",
33+
stdio: ["ignore", "pipe", "ignore"],
34+
}).trim();
35+
if (out) {
36+
passwords.push(out);
37+
}
38+
} catch {
39+
// continue
40+
}
41+
}
42+
if (passwords.length > 0) {
43+
return [...new Set(passwords)];
44+
}
45+
}
46+
47+
if (IS_LINUX) {
48+
const attributes: string[][] = [
49+
["application", "com.slack.Slack"],
50+
["application", "Slack"],
51+
["application", "slack"],
52+
["service", "Slack Safe Storage"],
53+
];
54+
const passwords: string[] = [];
55+
for (const pair of attributes) {
56+
try {
57+
const out = execFileSync("secret-tool", ["lookup", ...pair], {
58+
encoding: "utf8",
59+
stdio: ["ignore", "pipe", "ignore"],
60+
}).trim();
61+
if (out) {
62+
passwords.push(out);
63+
}
64+
} catch {
65+
// continue
66+
}
67+
}
68+
69+
// Chromium Linux OSCrypt v10 fallback password (see os_crypt_linux.cc).
70+
if (prefix === "v11") {
71+
passwords.push("");
72+
}
73+
passwords.push("peanuts");
74+
75+
return [...new Set(passwords)];
76+
}
77+
78+
throw new Error("Could not read Safe Storage password from desktop keychain.");
79+
}
80+
81+
/**
82+
* Decrypt a Chromium cookie on Windows using DPAPI + AES-256-GCM.
83+
*
84+
* On Windows (Chromium v80+), cookies are encrypted as:
85+
* v10/v11 + 12-byte nonce + AES-256-GCM ciphertext + 16-byte auth tag
86+
*
87+
* The AES key is stored DPAPI-encrypted in the "Local State" file under
88+
* os_crypt.encrypted_key (base64, prefixed with "DPAPI").
89+
*/
90+
export function decryptCookieWindows(encrypted: Buffer, slackDataDir: string): string {
91+
// Read the DPAPI-protected AES key from Local State
92+
const localStatePath = join(slackDataDir, "Local State");
93+
if (!existsSync(localStatePath)) {
94+
throw new Error(`Local State file not found: ${localStatePath}`);
95+
}
96+
let localState: unknown;
97+
try {
98+
localState = JSON.parse(readFileSync(localStatePath, "utf8"));
99+
} catch (error) {
100+
throw new Error(`Failed to parse Local State file: ${localStatePath}`, { cause: error });
101+
}
102+
const osCrypt = isRecord(localState) ? localState.os_crypt : undefined;
103+
if (!isRecord(osCrypt) || typeof osCrypt.encrypted_key !== "string") {
104+
throw new Error("No os_crypt.encrypted_key in Local State");
105+
}
106+
const encKeyFull = Buffer.from(osCrypt.encrypted_key as string, "base64");
107+
// Skip "DPAPI" prefix (5 bytes)
108+
const encKeyBlob = encKeyFull.subarray(5);
109+
110+
// Decrypt AES key via Windows DPAPI using PowerShell
111+
const id = randomUUID();
112+
const encKeyFile = join(tmpdir(), `as-key-enc-${id}.bin`);
113+
const decKeyFile = join(tmpdir(), `as-key-dec-${id}.bin`);
114+
writeFileSync(encKeyFile, encKeyBlob, { mode: 0o600 });
115+
try {
116+
// Escape single quotes for PowerShell single-quoted strings (' → '')
117+
const psEncKeyFile = encKeyFile.replaceAll("'", "''");
118+
const psDecKeyFile = decKeyFile.replaceAll("'", "''");
119+
const psCmd = [
120+
"Add-Type -AssemblyName System.Security",
121+
`$e=[System.IO.File]::ReadAllBytes('${psEncKeyFile}')`,
122+
"$d=[System.Security.Cryptography.ProtectedData]::Unprotect($e,$null,[System.Security.Cryptography.DataProtectionScope]::CurrentUser)",
123+
`[System.IO.File]::WriteAllBytes('${psDecKeyFile}',$d)`,
124+
].join("; ");
125+
execFileSync("powershell", ["-ExecutionPolicy", "Bypass", "-Command", psCmd], {
126+
stdio: "pipe",
127+
});
128+
if (!existsSync(decKeyFile)) {
129+
throw new Error("DPAPI decryption failed: PowerShell did not produce the decrypted key file");
130+
}
131+
const aesKey = readFileSync(decKeyFile);
132+
133+
// AES-256-GCM: v10(3) + nonce(12) + ciphertext(N-16) + tag(16)
134+
const nonce = encrypted.subarray(3, 15);
135+
const ciphertextWithTag = encrypted.subarray(15);
136+
const tag = ciphertextWithTag.subarray(-16);
137+
const ciphertext = ciphertextWithTag.subarray(0, -16);
138+
139+
const decipher = createDecipheriv("aes-256-gcm", aesKey, nonce);
140+
decipher.setAuthTag(tag);
141+
let decrypted = decipher.update(ciphertext, undefined, "utf8");
142+
decrypted += decipher.final("utf8");
143+
144+
return decrypted;
145+
} finally {
146+
try {
147+
unlinkSync(encKeyFile);
148+
} catch {
149+
/* ignore */
150+
}
151+
try {
152+
unlinkSync(decKeyFile);
153+
} catch {
154+
/* ignore */
155+
}
156+
}
157+
}

src/auth/desktop.ts

Lines changed: 6 additions & 158 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,15 @@
11
// Desktop auth extraction approach inspired by:
22
// - slacktokens: https://github.com/hraftery/slacktokens
33
import { cp, mkdir, rm, unlink } from "node:fs/promises";
4-
import {
5-
existsSync,
6-
readFileSync,
7-
readdirSync,
8-
copyFileSync,
9-
writeFileSync,
10-
unlinkSync,
11-
} from "node:fs";
4+
import { existsSync, readdirSync, copyFileSync, unlinkSync } from "node:fs";
125
import { execFileSync } from "node:child_process";
13-
import { createDecipheriv, randomUUID } from "node:crypto";
146
import { homedir, platform, tmpdir } from "node:os";
157
import { join } from "node:path";
168
import { findKeysContaining } from "../lib/leveldb-reader.js";
179
import { isRecord } from "../lib/object-type-guards.ts";
1810
import { queryReadonlySqlite } from "./firefox-profile.ts";
1911
import { decryptChromiumCookieValue } from "./chromium-cookie.ts";
12+
import { getSafeStoragePasswords, decryptCookieWindows } from "./desktop-crypto.ts";
2013

2114
type DesktopTeam = { url: string; name?: string; token: string };
2215

@@ -261,154 +254,6 @@ async function extractTeamsFromSlackLevelDb(leveldbDir: string): Promise<Desktop
261254
}
262255
}
263256

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

483328
for (const password of passwords) {
484329
try {
485-
const decrypted = decryptChromiumCookieValue(data, password, IS_LINUX ? 1 : 1003);
330+
const decrypted = decryptChromiumCookieValue(data, {
331+
password,
332+
iterations: IS_LINUX ? 1 : 1003,
333+
});
486334
const match = decrypted.match(/xoxd-[A-Za-z0-9%/+_=.-]+/);
487335
if (match) {
488336
return match[0]!;

src/cli/context.ts

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,7 @@ import { extractFromChrome } from "../auth/chrome.ts";
33
import { parseSlackCurlCommand } from "../auth/curl.ts";
44
import { extractFromSlackDesktop } from "../auth/desktop.ts";
55
import { extractFromFirefox } from "../auth/firefox.ts";
6-
import {
7-
loadCredentials,
8-
resolveDefaultWorkspace,
9-
resolveWorkspaceForUrl,
10-
upsertWorkspace,
11-
upsertWorkspaces,
12-
} from "../auth/store.ts";
13-
import { resolveWorkspaceSelector } from "./workspace-selector.ts";
6+
import { loadCredentials, upsertWorkspaces } from "../auth/store.ts";
147
import { normalizeChannelInput } from "../slack/channels.ts";
158
import type { SlackApiClient } from "../slack/client.ts";
169
import { type SlackAuth } from "../slack/client.ts";

src/cli/message-actions.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import { resolveChannelId, openDmChannel } from "../slack/channels.ts";
55
import { normalizeSlackReactionName } from "../slack/emoji.ts";
66
import { warnOnTruncatedSlackUrl } from "./message-url-warning.ts";
77
import { textToRichTextBlocks } from "../slack/rich-text.ts";
8-
import { getThreadSummary, toThreadListMessage } from "./message-thread-info.ts";
98
import type { SlackApiClient } from "../slack/client.ts";
109
import { uploadLocalFileToSlack } from "../slack/upload.ts";
1110

0 commit comments

Comments
 (0)