Created
May 17, 2026 22:52
-
-
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)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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