Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,9 +145,9 @@ On first run, macOS will show a Keychain dialog asking whether to allow access t

### How it works

1. **Cookie decryption** — Reads the encrypted `d` cookie from Slack's SQLite cookie store (`~/Library/Application Support/Slack/Cookies`). Decrypts it using the "Slack Safe Storage" key from the macOS Keychain via PBKDF2 + AES-128-CBC.
1. **Cookie decryption** — Reads the encrypted `d` cookie from Slack's SQLite cookie store (`Cookies` file). Decrypts it using the "Slack Safe Storage" key from the macOS Keychain via PBKDF2 + AES-128-CBC. Supports both direct-download and Mac App Store keychain account names.

2. **Token extraction** — Scans Slack's LevelDB storage (`~/Library/Application Support/Slack/Local Storage/leveldb/`) for `xoxc-` session tokens. Uses both direct regex scanning and a Python fallback for Snappy-compressed entries.
2. **Token extraction** — Scans Slack's LevelDB storage (`Local Storage/leveldb/`) for `xoxc-` session tokens. Uses both direct regex scanning and a Python fallback for Snappy-compressed entries. The Slack data directory is auto-detected (direct download or App Store sandbox).

3. **Validation** — Tests each candidate token against `auth.test` with the decrypted cookie. The first valid pair is used.

Expand Down Expand Up @@ -177,8 +177,8 @@ Validated tokens are cached to avoid re-extracting on every invocation:
| Data | Source | Purpose |
|------|--------|---------|
| Keychain password | `security find-generic-password -s "Slack Safe Storage"` | Derive AES key for cookie decryption |
| Encrypted cookie | `~/Library/Application Support/Slack/Cookies` (SQLite) | Decrypt the `d` session cookie (`xoxd-`) |
| Session token | `~/Library/Application Support/Slack/Local Storage/leveldb/` | Extract `xoxc-` token |
| Encrypted cookie | `<slack-data-dir>/Cookies` (SQLite) | Decrypt the `d` session cookie (`xoxd-`) |
| Session token | `<slack-data-dir>/Local Storage/leveldb/` | Extract `xoxc-` token |

## Agent usage patterns

Expand Down Expand Up @@ -241,6 +241,7 @@ npm link # symlink globally for development
## Notes

- **macOS only** — uses Keychain and Electron storage paths specific to macOS.
- **Both Slack variants supported** — works with the direct download (`~/Library/Application Support/Slack/`) and the Mac App Store version (`~/Library/Containers/com.tinyspeck.slackmacgap/.../Slack/`). The correct path is auto-detected at runtime.
- **Slack desktop app required** — must be installed and logged in. The app does not need to be running for cached tokens.
- **Zero dependencies** — uses only Node.js built-in modules (`crypto`, `fs`, `child_process`, `fetch`).
- **Session-based** — uses `xoxc-` tokens (user session), not bot tokens. This means you act as yourself.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "slkcli",
"version": "0.1.2",
"version": "0.1.3",
"description": "Slack CLI for macOS, so your agents can read and send messages",
"type": "module",
"license": "MIT",
Expand Down
47 changes: 40 additions & 7 deletions src/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,27 @@ import { pbkdf2Sync } from "crypto";

import { existsSync, mkdirSync } from "fs";

const SLACK_DIR = join(homedir(), "Library", "Application Support", "Slack");
const SLACK_DIR_DIRECT = join(homedir(), "Library", "Application Support", "Slack");
const SLACK_DIR_APPSTORE = join(
homedir(),
"Library", "Containers", "com.tinyspeck.slackmacgap",
"Data", "Library", "Application Support", "Slack"
);

function resolveSlackDir() {
if (existsSync(SLACK_DIR_DIRECT)) return SLACK_DIR_DIRECT;
if (existsSync(SLACK_DIR_APPSTORE)) return SLACK_DIR_APPSTORE;
console.error(
"Could not find Slack data directory.\n" +
"Checked:\n" +
` ${SLACK_DIR_DIRECT}\n` +
` ${SLACK_DIR_APPSTORE}\n` +
"Is Slack installed?"
);
process.exit(1);
}

const SLACK_DIR = resolveSlackDir();
const LEVELDB_DIR = join(SLACK_DIR, "Local Storage", "leveldb");
const COOKIES_DB = join(SLACK_DIR, "Cookies");
const CACHE_DIR = join(homedir(), ".local", "slk");
Expand All @@ -23,11 +43,24 @@ const TOKEN_CACHE = join(CACHE_DIR, "token-cache.json");
let cachedCreds = null;

function getKeychainKey() {
return Buffer.from(
execSync('security find-generic-password -s "Slack Safe Storage" -w', {
encoding: "utf-8",
}).trim()
);
// Mac App Store Slack uses account "Slack App Store Key", direct download uses "Slack"
const accounts = SLACK_DIR === SLACK_DIR_APPSTORE
? ["Slack App Store Key", "Slack"]
: ["Slack", "Slack App Store Key"];

for (const account of accounts) {
try {
return Buffer.from(
execSync(
`security find-generic-password -s "Slack Safe Storage" -a "${account}" -w`,
{ encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }
).trim()
);
} catch {}
}

console.error("Could not find Slack Safe Storage key in Keychain.");
process.exit(1);
}

function decryptCookie() {
Expand Down Expand Up @@ -111,7 +144,7 @@ function extractToken() {
try {
const pyResult = spawnSync("python3", ["-c", `
import os, re
path = os.path.expanduser("~/Library/Application Support/Slack/Local Storage/leveldb")
path = ${JSON.stringify(LEVELDB_DIR)}
for f in os.listdir(path):
if not (f.endswith(".ldb") or f.endswith(".log")): continue
data = open(os.path.join(path, f), "rb").read()
Expand Down