Skip to content

Instantly share code, notes, and snippets.

@ewilderj
Created May 17, 2026 22:52
Show Gist options
  • Select an option

  • Save ewilderj/7b8c9e74eb8d3740b60021c46864ee8d to your computer and use it in GitHub Desktop.

Select an option

Save ewilderj/7b8c9e74eb8d3740b60021c46864ee8d to your computer and use it in GitHub Desktop.
Extract Slack desktop app xoxc/xoxd tokens on macOS (for mautrix-slack login token)
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.10"
# dependencies = [
# "cryptography>=42",
# ]
# ///
"""Extract Slack desktop app tokens for use with mautrix-slack (macOS).
For each Slack workspace you're signed into in the desktop app, prints
a ready-to-paste `login token <xoxc-…> <xoxd-…>` line for the bridge
management DM with @mxslack:eddpod.com.
How it works:
1. Reads xoxc tokens from Slack's LevelDB (`Local Storage/leveldb/`)
by raw-byte regex — tokens survive Snappy compression as plain text.
2. Reads the `d` cookie from Slack's Cookies sqlite, decrypting the
Chromium-style `encrypted_value` with the key in macOS Keychain.
3. Calls Slack's auth.test API once per token to get authoritative
workspace name / team_id / signed-in user.
Slack does NOT need to be quit — both stores are copied to a tempdir
before being read.
Usage:
./slack-extract-tokens.py # list workspaces, pick interactively
./slack-extract-tokens.py --all # print every workspace
./slack-extract-tokens.py --domain X # filter by Slack subdomain
"""
from __future__ import annotations
import argparse
import glob
import hashlib
import json
import re
import shutil
import sqlite3
import subprocess
import sys
import tempfile
import urllib.parse
import urllib.request
from pathlib import Path
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
KEYCHAIN_SERVICE = "Slack Safe Storage"
# Slack ships in two flavours on macOS:
# - Mac App Store (sandboxed): ~/Library/Containers/com.tinyspeck.slackmacgap/
# Data/Library/Application Support/Slack
# - Direct download: ~/Library/Application Support/Slack
_CANDIDATE_DIRS = [
Path.home() / "Library" / "Containers" / "com.tinyspeck.slackmacgap"
/ "Data" / "Library" / "Application Support" / "Slack",
Path.home() / "Library" / "Application Support" / "Slack",
]
def die(msg: str, code: int = 1) -> None:
print(f"error: {msg}", file=sys.stderr)
sys.exit(code)
def find_slack_dir() -> Path:
for d in _CANDIDATE_DIRS:
if (d / "Cookies").is_file() and (d / "Local Storage" / "leveldb").is_dir():
return d
die("Could not find Slack app data in any of:\n "
+ "\n ".join(str(d) for d in _CANDIDATE_DIRS)
+ "\nIs Slack desktop installed and signed in?")
return Path()
def get_keychain_password() -> bytes:
"""Pull the Slack Safe Storage key from the macOS Keychain.
First call pops an OS prompt asking the user to "Always Allow" the
python interpreter access. There's no way around that.
"""
try:
out = subprocess.check_output(
["/usr/bin/security", "find-generic-password",
"-w", "-s", KEYCHAIN_SERVICE],
stderr=subprocess.PIPE,
)
except subprocess.CalledProcessError as e:
die(
f"Could not read '{KEYCHAIN_SERVICE}' from Keychain.\n"
f" ({e.stderr.decode().strip()})\n"
f"Has Slack desktop ever been logged in on this Mac?",
)
return out.strip()
def derive_aes_key(keychain_password: bytes) -> bytes:
"""Chromium key derivation: PBKDF2-HMAC-SHA1, salt=saltysalt, iter=1003, 16 bytes."""
return hashlib.pbkdf2_hmac(
"sha1", keychain_password, b"saltysalt", 1003, 16,
)
def decrypt_chromium_cookie(value: bytes, aes_key: bytes, host_key: str) -> str:
"""Decrypt a Chromium 'encrypted_value' cookie blob.
Layout:
- Bytes 0..2: version tag, 'v10' or 'v11'.
- Bytes 3..: AES-128-CBC ciphertext.
- IV: 16 bytes of 0x20 (ASCII space).
- Plaintext: PKCS#7 padded. In Chromium 130+ the plaintext is
prefixed with SHA256(host_key) (32 bytes) which must
be stripped. Older versions have no such prefix, so
we detect by comparing to the SHA256 we expect.
"""
if not value or len(value) < 3 + 16:
return ""
if value[:3] not in (b"v10", b"v11"):
return ""
cipher = Cipher(algorithms.AES(aes_key), modes.CBC(b" " * 16))
try:
plain = cipher.decryptor().update(value[3:]) + cipher.decryptor().finalize()
except Exception:
return ""
# PKCS#7 unpad
if plain:
pad_len = plain[-1]
if 1 <= pad_len <= 16 and plain.endswith(bytes([pad_len]) * pad_len):
plain = plain[:-pad_len]
# Strip the 32-byte binary prefix that Chromium prepends to the
# plaintext (likely an integrity tag/HMAC). The real cookie value
# starts at byte 32.
if len(plain) > 32:
plain = plain[32:]
return plain.decode("utf-8", errors="replace").strip("\x00").strip()
def copy_to_temp(src: Path, tmp: Path) -> Path:
"""Copy a file or directory into the tempdir so Slack can keep the locks."""
dst = tmp / src.name
if src.is_dir():
shutil.copytree(src, dst, dirs_exist_ok=True)
else:
shutil.copy2(src, dst)
return dst
XOXC_RE = re.compile(rb"xoxc-[a-f0-9-]{40,}")
def extract_xoxc_tokens(leveldb_copy: Path) -> list[str]:
"""Pull xoxc-* tokens from Slack's LevelDB store.
The values in .ldb files are Snappy-compressed at the block level,
but the tokens themselves don't appear as repeated substrings, so
they survive the compression as plain text and a raw byte regex
finds them reliably.
"""
seen: list[str] = []
seen_set: set[str] = set()
for path in sorted(leveldb_copy.iterdir()):
if not path.is_file() or path.suffix not in (".log", ".ldb"):
continue
for m in XOXC_RE.finditer(path.read_bytes()):
tok = m.group(0).decode()
if tok not in seen_set:
seen_set.add(tok)
seen.append(tok)
return seen
def extract_d_cookie(cookies_copy: Path, aes_key: bytes) -> tuple[str, str]:
"""Return the .slack.com 'd' cookie, shared across workspaces.
Returns (raw, decoded): `raw` is the value as Slack stores it
(URL-encoded, needed for the Cookie header on auth.test), `decoded`
is the literal `xoxd-…` string the bridge expects in
`login token xoxc-… xoxd-…`.
"""
conn = sqlite3.connect(f"file:{cookies_copy}?mode=ro", uri=True)
try:
rows = conn.execute(
"SELECT host_key, value, encrypted_value FROM cookies WHERE name = 'd'"
).fetchall()
finally:
conn.close()
candidates: list[tuple[str, str]] = []
for host_key, value, encrypted_value in rows:
plain = value or ""
if not plain and encrypted_value:
plain = decrypt_chromium_cookie(encrypted_value, aes_key, host_key)
if not plain:
continue
# Slack stores 'd' URL-encoded; the literal form starts with xoxd-.
decoded = urllib.parse.unquote(plain)
if decoded.startswith("xoxd-"):
candidates.append((host_key, plain, decoded))
# Prefer .slack.com (shared session cookie). Fall back to anything xoxd-ish.
for host_key, raw, decoded in candidates:
if host_key.lstrip(".") == "slack.com":
return raw, decoded
if candidates:
return candidates[0][1], candidates[0][2]
return "", ""
def slack_auth_test(token: str, d_cookie: str) -> dict:
"""Ask Slack who this token belongs to.
The Slack desktop session expects `token=` in the form body and the
`d` cookie sent raw (NOT URL-encoded again — Slack stores it
already-encoded).
"""
data = urllib.parse.urlencode({"token": token}).encode()
req = urllib.request.Request(
"https://slack.com/api/auth.test",
data=data,
method="POST",
headers={
"Cookie": f"d={d_cookie}",
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent": "slack-extract-tokens/1.0",
},
)
try:
with urllib.request.urlopen(req, timeout=15) as resp:
return json.loads(resp.read())
except Exception as e:
return {"ok": False, "error": str(e)}
def main() -> int:
p = argparse.ArgumentParser(description=__doc__.split("\n")[0])
p.add_argument("--domain",
help="Only show the given workspace subdomain "
"(e.g. 'eddpod' for eddpod.slack.com)")
p.add_argument("--all", action="store_true",
help="Print every workspace, no prompts.")
args = p.parse_args()
if sys.platform != "darwin":
die("This script only supports macOS for now.")
slack_dir = find_slack_dir()
aes_key = derive_aes_key(get_keychain_password())
with tempfile.TemporaryDirectory(prefix="slack-tokens-") as td:
tmp = Path(td)
leveldb_copy = copy_to_temp(slack_dir / "Local Storage" / "leveldb", tmp)
cookies_copy = copy_to_temp(slack_dir / "Cookies", tmp)
tokens = extract_xoxc_tokens(leveldb_copy)
d_cookie_raw, d_cookie_decoded = extract_d_cookie(cookies_copy, aes_key)
if not tokens:
die("No xoxc tokens found in LevelDB. Are you signed in to a "
"workspace in the Slack desktop app?")
if not d_cookie_raw:
die("No xoxd 'd' cookie found in Slack's Cookies store. "
"Are you fully signed in?")
# Identify each token via Slack's auth.test.
workspaces: list[dict] = []
for tok in tokens:
info = slack_auth_test(tok, d_cookie_raw)
if not info.get("ok"):
print(f"⚠ token {tok[:20]}… rejected by Slack "
f"({info.get('error', 'unknown')}); skipping",
file=sys.stderr)
continue
# url is like "https://eddpod.slack.com/"
url = info.get("url", "")
domain = url.split("//", 1)[-1].split(".", 1)[0] if url else ""
workspaces.append({
"team": info.get("team", ""),
"team_id": info.get("team_id", ""),
"domain": domain,
"user": info.get("user", ""),
"user_id": info.get("user_id", ""),
"token": tok,
})
if not workspaces:
die("No tokens were accepted by Slack. If you have multiple "
"workspaces signed in, the raw-byte token extractor can be "
"truncated by Snappy compression boundaries in Slack's "
"LevelDB. Try quitting and reopening Slack (which writes a "
"fresh uncompressed .log) and rerun.")
if args.domain:
workspaces = [w for w in workspaces if w["domain"] == args.domain]
if not workspaces:
die(f"No workspace with domain '{args.domain}' found.")
elif not args.all and len(workspaces) > 1:
print("Multiple workspaces signed in:")
for i, w in enumerate(workspaces, 1):
print(f" {i}. {w['team']:<40} {w['domain']}.slack.com "
f"(as {w['user']})")
try:
pick = int(input("Which one? [1] ") or "1")
except (ValueError, EOFError):
die("Invalid choice.")
if not 1 <= pick <= len(workspaces):
die("Out of range.")
workspaces = [workspaces[pick - 1]]
print()
for w in workspaces:
print("=" * 70)
print(f" {w['team']} ({w['domain']}.slack.com, team {w['team_id']})")
print(f" Signed in as {w['user']} ({w['user_id']})")
print("=" * 70)
print()
print("In your DM with the bridge bot — labelled "
"\"mautrix-slack bridge bot\" (@mxslack:eddpod.com) "
"in Element — paste:")
print()
print(f" login token {w['token']} {d_cookie_decoded}")
print()
return 0
if __name__ == "__main__":
sys.exit(main())
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment