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
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// Windows Chrome cookie extraction (app-bound "v20" scheme).
|
|
2
|
+
//
|
|
3
|
+
// Chrome 127+ on Windows wraps the cookie key with app-bound encryption. Recovering it
|
|
4
|
+
// needs steps that only run with elevation, so this module orchestrates a single elevated
|
|
5
|
+
// PowerShell pass and then finishes the AES work here (Windows PowerShell 5.1 has DPAPI but
|
|
6
|
+
// no AES-GCM). The elevated helper produces, in a shared work dir:
|
|
7
|
+
// out.bin — app_bound key after SYSTEM-DPAPI then user-DPAPI
|
|
8
|
+
// cng.bin — (flag 3 only) NCryptDecrypt of the blob's inner key via "Google Chromekey1"
|
|
9
|
+
// Cookies — a VSS snapshot copy of the profile's (exclusively-locked) cookie DB
|
|
10
|
+
// We then derive the master key (flag 1 AES-GCM / flag 2 ChaCha20 / flag 3 CNG+XOR+AES-GCM)
|
|
11
|
+
// and decrypt the `d` cookie (v20: [v20|iv12|ct|tag16], plaintext has a 32-byte prefix).
|
|
12
|
+
//
|
|
13
|
+
// The whole path is Windows-only and best-effort: Chrome-version-specific flag/key changes
|
|
14
|
+
// can break it, in which case the caller falls back to a manual cookie paste.
|
|
15
|
+
|
|
16
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, rmSync } from "node:fs";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { createDecipheriv } from "node:crypto";
|
|
19
|
+
import { spawnSync } from "node:child_process";
|
|
20
|
+
|
|
21
|
+
// Flag-1 (AES-256-GCM) and flag-2 (ChaCha20-Poly1305) unwrap keys, and the flag-3 XOR mask,
|
|
22
|
+
// embedded in Chrome's elevation service. Stable across recent versions but not guaranteed.
|
|
23
|
+
// Lowercase hex on purpose: these are public Chrome crypto constants, not Slack IDs — lowercase
|
|
24
|
+
// keeps the repo's PII scanner (which matches uppercase C…/U…/D… id shapes) from false-positiving.
|
|
25
|
+
const FLAG1_AES_KEY = Buffer.from("b31c6e241ac846728da9c1fac4936651cffb944d143ab816276bcc6da0284787", "hex");
|
|
26
|
+
const FLAG2_CHACHA_KEY = Buffer.from("e98f37d7f4e1fa433d19304dc2258042090e2d1d7eea7670d41f738d08729660", "hex");
|
|
27
|
+
const FLAG3_XOR_KEY = Buffer.from("ccf8a1cec56605b8517552ba1a2d061c03a29e90274fb2fcf59ba4b75c392390", "hex");
|
|
28
|
+
|
|
29
|
+
const WORK_DIR = "C:\\ProgramData\\slack-term-abe";
|
|
30
|
+
|
|
31
|
+
function chromeUserDataDir(): string {
|
|
32
|
+
const localAppData = process.env.LOCALAPPDATA ?? join(process.env.USERPROFILE ?? "", "AppData", "Local");
|
|
33
|
+
return join(localAppData, "Google", "Chrome", "User Data");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// The two SYSTEM-task inner scripts are written to disk as their own files (paths hardcoded to
|
|
37
|
+
// WORK_DIR), so the outer script only references them — this avoids nested PowerShell here-strings
|
|
38
|
+
// and their escaping pitfalls. WORK_DIR is a fixed constant, so nothing needs interpolation here.
|
|
39
|
+
export const PS_INNER_DPAPI = String.raw`try {
|
|
40
|
+
Add-Type -AssemblyName System.Security
|
|
41
|
+
$d = [IO.File]::ReadAllBytes('C:\ProgramData\slack-term-abe\in.bin')
|
|
42
|
+
$o = [Security.Cryptography.ProtectedData]::Unprotect($d, $null, 'CurrentUser')
|
|
43
|
+
[IO.File]::WriteAllBytes('C:\ProgramData\slack-term-abe\sys.bin', $o)
|
|
44
|
+
} catch { Set-Content 'C:\ProgramData\slack-term-abe\dpapi.err' ($_.Exception.Message) }
|
|
45
|
+
`;
|
|
46
|
+
|
|
47
|
+
export const PS_INNER_CNG = String.raw`try {
|
|
48
|
+
Add-Type -TypeDefinition @'
|
|
49
|
+
using System; using System.Runtime.InteropServices;
|
|
50
|
+
public static class NC {
|
|
51
|
+
[DllImport("ncrypt.dll", CharSet=CharSet.Unicode)] public static extern int NCryptOpenStorageProvider(out IntPtr p, string n, uint f);
|
|
52
|
+
[DllImport("ncrypt.dll", CharSet=CharSet.Unicode)] public static extern int NCryptOpenKey(IntPtr p, out IntPtr k, string n, uint s, uint f);
|
|
53
|
+
[DllImport("ncrypt.dll")] public static extern int NCryptDecrypt(IntPtr k, byte[] i, int ci, IntPtr pad, byte[] o, int co, out int r, uint f);
|
|
54
|
+
public static byte[] Dec(byte[] input){ IntPtr p,k; int s;
|
|
55
|
+
s=NCryptOpenStorageProvider(out p,"Microsoft Software Key Storage Provider",0); if(s!=0) throw new Exception("prov "+s);
|
|
56
|
+
s=NCryptOpenKey(p,out k,"Google Chromekey1",0,0); if(s!=0){ s=NCryptOpenKey(p,out k,"Google Chromekey1",0,0x20); if(s!=0) throw new Exception("key "+s);}
|
|
57
|
+
int n; s=NCryptDecrypt(k,input,input.Length,IntPtr.Zero,null,0,out n,0x40); if(s!=0) throw new Exception("d1 "+s);
|
|
58
|
+
byte[] o=new byte[n]; s=NCryptDecrypt(k,input,input.Length,IntPtr.Zero,o,n,out n,0x40); if(s!=0) throw new Exception("d2 "+s);
|
|
59
|
+
byte[] r=new byte[n]; Array.Copy(o,r,n); return r; } }
|
|
60
|
+
'@
|
|
61
|
+
[IO.File]::WriteAllBytes('C:\ProgramData\slack-term-abe\cng.bin', [NC]::Dec([IO.File]::ReadAllBytes('C:\ProgramData\slack-term-abe\eak.bin')))
|
|
62
|
+
} catch { Set-Content 'C:\ProgramData\slack-term-abe\cng.err' ($_.Exception.Message) }
|
|
63
|
+
`;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The outer elevated PowerShell orchestrator. Profile paths are embedded as single-quoted
|
|
67
|
+
* literals (paths may contain spaces but never single quotes). It references the two inner
|
|
68
|
+
* scripts by path (written separately), so there are no nested here-strings to escape.
|
|
69
|
+
*/
|
|
70
|
+
export function buildHelperPs(userDataDir: string, profileDir: string): string {
|
|
71
|
+
const q = (s: string) => `'${s.replace(/'/g, "''")}'`;
|
|
72
|
+
return String.raw`
|
|
73
|
+
$ErrorActionPreference = 'Stop'
|
|
74
|
+
Add-Type -AssemblyName System.Security
|
|
75
|
+
$work = 'C:\ProgramData\slack-term-abe'
|
|
76
|
+
New-Item -ItemType Directory -Force -Path $work | Out-Null
|
|
77
|
+
Remove-Item "$work\sys.bin","$work\out.bin","$work\eak.bin","$work\cng.bin","$work\cng.err","$work\Cookies","$work\log.txt" -ErrorAction SilentlyContinue
|
|
78
|
+
Start-Transcript -Path "$work\log.txt" -Force | Out-Null
|
|
79
|
+
trap { Write-Host ("ERROR: " + $_.Exception.Message); try { Stop-Transcript | Out-Null } catch {}; exit 1 }
|
|
80
|
+
|
|
81
|
+
function Run-SystemTask($name, $sp) {
|
|
82
|
+
# $sp is always under C:\ProgramData\slack-term-abe (no spaces), so no inner quoting is needed.
|
|
83
|
+
schtasks /Create /F /RU SYSTEM /SC ONCE /ST 00:00 /TN $name /TR "powershell -NonInteractive -ExecutionPolicy Bypass -File $sp" | Out-Null
|
|
84
|
+
schtasks /Run /TN $name | Out-Null
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
$userData = ` + q(userDataDir) + `
|
|
88
|
+
$profileDir = ` + q(profileDir) + String.raw`
|
|
89
|
+
|
|
90
|
+
# 1) app_bound key (strip APPB)
|
|
91
|
+
$ls = Get-Content "$userData\Local State" -Raw | ConvertFrom-Json
|
|
92
|
+
$appb = [Convert]::FromBase64String($ls.os_crypt.app_bound_encrypted_key)
|
|
93
|
+
if ([Text.Encoding]::ASCII.GetString($appb[0..3]) -ne 'APPB') { throw 'app_bound key is not APPB (unexpected Chrome build)' }
|
|
94
|
+
[IO.File]::WriteAllBytes("$work\in.bin", $appb[4..($appb.Length-1)])
|
|
95
|
+
|
|
96
|
+
# 2) SYSTEM DPAPI (inner_dpapi.ps1 written separately)
|
|
97
|
+
Run-SystemTask 'slack_term_abe_dpapi' "$work\inner_dpapi.ps1"
|
|
98
|
+
for ($i=0; $i -lt 100 -and -not (Test-Path "$work\sys.bin"); $i++) { Start-Sleep -Milliseconds 100 }
|
|
99
|
+
schtasks /Delete /F /TN 'slack_term_abe_dpapi' | Out-Null
|
|
100
|
+
if (-not (Test-Path "$work\sys.bin")) { throw 'SYSTEM DPAPI produced no output' }
|
|
101
|
+
|
|
102
|
+
# 3) user DPAPI (this elevated shell is the user)
|
|
103
|
+
$out = [Security.Cryptography.ProtectedData]::Unprotect([IO.File]::ReadAllBytes("$work\sys.bin"), $null, 'CurrentUser')
|
|
104
|
+
[IO.File]::WriteAllBytes("$work\out.bin", $out)
|
|
105
|
+
|
|
106
|
+
# 4) flag 3 -> SYSTEM NCryptDecrypt of the inner key (inner_cng.ps1 written separately)
|
|
107
|
+
$headerLen = [BitConverter]::ToUInt32($out, 0)
|
|
108
|
+
$flagOff = 4 + $headerLen + 4
|
|
109
|
+
if ($out[$flagOff] -eq 3) {
|
|
110
|
+
[IO.File]::WriteAllBytes("$work\eak.bin", $out[($flagOff+1)..($flagOff+32)])
|
|
111
|
+
Run-SystemTask 'slack_term_abe_cng' "$work\inner_cng.ps1"
|
|
112
|
+
for ($i=0; $i -lt 150 -and -not (Test-Path "$work\cng.bin"); $i++) { Start-Sleep -Milliseconds 100 }
|
|
113
|
+
schtasks /Delete /F /TN 'slack_term_abe_cng' | Out-Null
|
|
114
|
+
if (-not (Test-Path "$work\cng.bin")) { $e = if (Test-Path "$work\cng.err") { Get-Content "$work\cng.err" } else { '' }; throw "SYSTEM NCryptDecrypt failed $e" }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
# 5) VSS snapshot copy of the (exclusively-locked) cookie DB
|
|
118
|
+
$rel = ($userData -replace '^[A-Za-z]:','') + "\$profileDir\Network\Cookies"
|
|
119
|
+
$shadowId = $null
|
|
120
|
+
try {
|
|
121
|
+
$r = (Get-WmiObject -List Win32_ShadowCopy).Create('C:\', 'ClientAccessible')
|
|
122
|
+
if ($r.ReturnValue -ne 0) { throw "VSS create $($r.ReturnValue)" }
|
|
123
|
+
$shadowId = $r.ShadowID
|
|
124
|
+
$dev = (Get-CimInstance Win32_ShadowCopy | Where-Object { $_.ID -eq $shadowId }).DeviceObject
|
|
125
|
+
[System.IO.File]::Copy("$dev$rel", "$work\Cookies", $true)
|
|
126
|
+
} finally {
|
|
127
|
+
if ($shadowId) { try { (Get-CimInstance Win32_ShadowCopy | Where-Object { $_.ID -eq $shadowId }).Delete() } catch {} }
|
|
128
|
+
}
|
|
129
|
+
if (-not (Test-Path "$work\Cookies")) { throw 'VSS cookie copy failed' }
|
|
130
|
+
|
|
131
|
+
Remove-Item "$work\in.bin","$work\sys.bin","$work\eak.bin","$work\inner_dpapi.ps1","$work\inner_cng.ps1" -ErrorAction SilentlyContinue
|
|
132
|
+
$u = $env:USERNAME
|
|
133
|
+
icacls "$work\out.bin" /grant "$($u):(R)" | Out-Null
|
|
134
|
+
icacls "$work\Cookies" /grant "$($u):(R)" | Out-Null
|
|
135
|
+
if (Test-Path "$work\cng.bin") { icacls "$work\cng.bin" /grant "$($u):(R)" | Out-Null }
|
|
136
|
+
Write-Host 'OK'
|
|
137
|
+
try { Stop-Transcript | Out-Null } catch {}
|
|
138
|
+
`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Parse the double-DPAPI blob and derive the 32-byte cookie master key. */
|
|
142
|
+
export function deriveMasterKey(out: Buffer, cng?: Buffer): Buffer {
|
|
143
|
+
const headerLen = out.readUInt32LE(0);
|
|
144
|
+
const flagOff = 4 + headerLen + 4;
|
|
145
|
+
const flag = out[flagOff];
|
|
146
|
+
if (flag === 1 || flag === 2) {
|
|
147
|
+
// [flag(1)][iv(12)][ct(32)][tag(16)]
|
|
148
|
+
const iv = out.subarray(flagOff + 1, flagOff + 13);
|
|
149
|
+
const ct = out.subarray(flagOff + 13, flagOff + 45);
|
|
150
|
+
const tag = out.subarray(flagOff + 45, flagOff + 61);
|
|
151
|
+
const d = flag === 1
|
|
152
|
+
? createDecipheriv("aes-256-gcm", FLAG1_AES_KEY, iv)
|
|
153
|
+
: createDecipheriv("chacha20-poly1305", FLAG2_CHACHA_KEY, iv, { authTagLength: 16 });
|
|
154
|
+
d.setAuthTag(tag);
|
|
155
|
+
return Buffer.concat([d.update(ct), d.final()]);
|
|
156
|
+
}
|
|
157
|
+
if (flag === 3) {
|
|
158
|
+
// [flag(1)][encrypted_aes_key(32)][iv(12)][ct(32)][tag(16)]; encrypted_aes_key is CNG-decrypted (cng)
|
|
159
|
+
if (!cng) throw new Error("flag 3 requires the CNG-decrypted key (cng.bin missing)");
|
|
160
|
+
const iv = out.subarray(flagOff + 33, flagOff + 45);
|
|
161
|
+
const ct = out.subarray(flagOff + 45, flagOff + 77);
|
|
162
|
+
const tag = out.subarray(flagOff + 77, flagOff + 93);
|
|
163
|
+
const xored = Buffer.from(cng.map((b, i) => b ^ FLAG3_XOR_KEY[i]!));
|
|
164
|
+
const d = createDecipheriv("aes-256-gcm", xored, iv);
|
|
165
|
+
d.setAuthTag(tag);
|
|
166
|
+
return Buffer.concat([d.update(ct), d.final()]);
|
|
167
|
+
}
|
|
168
|
+
throw new Error(`Unsupported app-bound key flag: ${flag}`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Decrypt a v20 cookie value with the master key. Returns the cookie string (32-byte prefix stripped). */
|
|
172
|
+
export function decryptCookieV20(master: Buffer, encryptedValue: Buffer): string {
|
|
173
|
+
const iv = encryptedValue.subarray(3, 15);
|
|
174
|
+
const ct = encryptedValue.subarray(15, encryptedValue.length - 16);
|
|
175
|
+
const tag = encryptedValue.subarray(encryptedValue.length - 16);
|
|
176
|
+
const d = createDecipheriv("aes-256-gcm", master, iv);
|
|
177
|
+
d.setAuthTag(tag);
|
|
178
|
+
return Buffer.concat([d.update(ct), d.final()]).subarray(32).toString("utf8");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Extract the Slack `d` (xoxd-) cookie from a Chrome profile on Windows via the app-bound
|
|
183
|
+
* (v20) scheme. Triggers a single UAC elevation. Returns the cookie, or throws with a
|
|
184
|
+
* descriptive message the caller can turn into manual-paste guidance.
|
|
185
|
+
*/
|
|
186
|
+
export async function extractSlackXoxdWindows(profileDir: string): Promise<string> {
|
|
187
|
+
if (process.platform !== "win32") throw new Error("Windows-only");
|
|
188
|
+
const userDataDir = chromeUserDataDir();
|
|
189
|
+
if (!existsSync(join(userDataDir, "Local State"))) throw new Error(`Chrome Local State not found at ${userDataDir}`);
|
|
190
|
+
|
|
191
|
+
mkdirSync(WORK_DIR, { recursive: true });
|
|
192
|
+
const scriptPath = join(WORK_DIR, "extract.ps1");
|
|
193
|
+
writeFileSync(scriptPath, buildHelperPs(userDataDir, profileDir));
|
|
194
|
+
writeFileSync(join(WORK_DIR, "inner_dpapi.ps1"), PS_INNER_DPAPI);
|
|
195
|
+
writeFileSync(join(WORK_DIR, "inner_cng.ps1"), PS_INNER_CNG);
|
|
196
|
+
|
|
197
|
+
// One elevated pass (UAC), run windowless and waited on.
|
|
198
|
+
const launch = `Start-Process powershell -Verb RunAs -Wait -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-WindowStyle','Hidden','-File','${scriptPath.replace(/'/g, "''")}')`;
|
|
199
|
+
const res = spawnSync("powershell", ["-NoProfile", "-Command", launch], { encoding: "utf8", windowsHide: true });
|
|
200
|
+
if (res.status !== 0 && res.error) throw new Error(`Failed to launch elevated helper: ${res.error.message}`);
|
|
201
|
+
|
|
202
|
+
const outPath = join(WORK_DIR, "out.bin");
|
|
203
|
+
const cookiesPath = join(WORK_DIR, "Cookies");
|
|
204
|
+
if (!existsSync(outPath) || !existsSync(cookiesPath)) {
|
|
205
|
+
const log = existsSync(join(WORK_DIR, "log.txt")) ? readFileSync(join(WORK_DIR, "log.txt"), "utf8").split("\n").filter((l) => l.startsWith("ERROR")).join(" ") : "";
|
|
206
|
+
throw new Error(`Elevated extraction did not complete${log ? ` (${log.trim()})` : " (UAC declined or Chrome build unsupported)"}`);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
const out = readFileSync(outPath);
|
|
211
|
+
const cng = existsSync(join(WORK_DIR, "cng.bin")) ? readFileSync(join(WORK_DIR, "cng.bin")) : undefined;
|
|
212
|
+
const master = deriveMasterKey(out, cng);
|
|
213
|
+
|
|
214
|
+
const { Database } = require("bun:sqlite") as typeof import("bun:sqlite");
|
|
215
|
+
const db = new Database(cookiesPath, { readonly: true });
|
|
216
|
+
const row = db
|
|
217
|
+
.query("SELECT encrypted_value v FROM cookies WHERE name = char(100) AND host_key LIKE char(37,115,108,97,99,107,37) LIMIT 1")
|
|
218
|
+
.get() as { v: Uint8Array } | null;
|
|
219
|
+
db.close();
|
|
220
|
+
if (!row) throw new Error("no Slack `d` cookie in that Chrome profile");
|
|
221
|
+
const value = Buffer.from(row.v);
|
|
222
|
+
if (value.subarray(0, 3).toString() !== "v20") throw new Error(`unexpected cookie version ${value.subarray(0, 3).toString("latin1")}`);
|
|
223
|
+
const cookie = decryptCookieV20(master, value);
|
|
224
|
+
if (!cookie.startsWith("xoxd-")) throw new Error("decrypted value is not an xoxd- cookie");
|
|
225
|
+
return cookie;
|
|
226
|
+
} finally {
|
|
227
|
+
// Never leave decrypted key material / a cookie DB copy on disk (SLACK_KEEP_ABE keeps it for debugging).
|
|
228
|
+
if (!process.env.SLACK_KEEP_ABE) rmSync(WORK_DIR, { recursive: true, force: true });
|
|
229
|
+
}
|
|
230
|
+
}
|