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.
@@ -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
+ }