@powercess/qq-mcp 0.2.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.

Potentially problematic release.


This version of @powercess/qq-mcp might be problematic. Click here for more details.

@@ -0,0 +1,326 @@
1
+ /**
2
+ * Offline decryption of NTQQ (Windows QQ NT) SQLCipher databases — pure Node,
3
+ * no sqlcipher native dependency.
4
+ *
5
+ * Reverse-engineered layout, verified byte-exact against SQLCipher 4.12 on
6
+ * NTQQ 9.9.35.52892 (see docs/reverse-engineering.md):
7
+ *
8
+ * file = [1024-byte NTQQ header][salt 16B][page 2][page 3]...
9
+ * page = [16B salt, page 1 only][ciphertext][16B IV][32B tag]
10
+ * ciphertext = AES-256-CBC(plaintext) with IV from the page tail
11
+ * key = PBKDF2-HMAC-SHA512(passphrase, salt, 4000, 32)
12
+ * plaintext page 1 = [16B salt][SQLite header from offset 16 onward]
13
+ *
14
+ * The 32-byte tail tag is NOT authenticated here: no standard HMAC layout
15
+ * matched, and decryption does not need it (decrypted pages are validated by
16
+ * their SQLite structure instead).
17
+ */
18
+ import { createDecipheriv, createCipheriv, pbkdf2Sync, createHash } from "node:crypto";
19
+ import { closeSync, openSync, readSync, statSync, writeSync } from "node:fs";
20
+
21
+ /** NTQQ custom header preceding the SQLCipher salt. */
22
+ export const NTQQ_HEADER_SIZE = 1024;
23
+ /** SQLite page size used by every NTQQ database. */
24
+ export const DEFAULT_PAGE_SIZE = 4096;
25
+ /** Bytes after the ciphertext: 16-byte IV followed by a 32-byte unchecked tag. */
26
+ export const CRYPTO_TAIL_SIZE = 48;
27
+ /** SQLCipher default KDF iteration count for this key size. */
28
+ export const DEFAULT_KDF_ITER = 4000;
29
+ const SQLITE_MAGIC = Buffer.from("SQLite format 3\0", "latin1");
30
+
31
+ export interface NtqqHeader {
32
+ /** Page size parsed from the NTQQ header (always 4096 in practice). */
33
+ pageSize: number;
34
+ /** 32-byte device-level blob embedded in every NTQQ database header. */
35
+ deviceBlob: Buffer;
36
+ /** SQLCipher HMAC algorithm string advertised in the header, e.g. `HMAC_SHA1`. */
37
+ hmacAlgorithm: string;
38
+ /** Schema/format version string, e.g. `1.1.0.1`. */
39
+ formatVersion: string;
40
+ }
41
+
42
+ export interface DatabaseLayout {
43
+ /** Byte length of the NTQQ header: ciphertext pages start here. */
44
+ headerSize: number;
45
+ pageSize: number;
46
+ /** Size of the trailing crypto area inside each page. */
47
+ tailSize: number;
48
+ }
49
+
50
+ export const DEFAULT_LAYOUT: DatabaseLayout = {
51
+ headerSize: NTQQ_HEADER_SIZE,
52
+ pageSize: DEFAULT_PAGE_SIZE,
53
+ tailSize: CRYPTO_TAIL_SIZE,
54
+ };
55
+
56
+ function readAt(path: string, offset: number, length: number): Buffer {
57
+ const fd = openSync(path, "r");
58
+ try {
59
+ const buf = Buffer.alloc(length);
60
+ const n = readSync(fd, buf, 0, length, offset);
61
+ return buf.subarray(0, n);
62
+ } finally {
63
+ closeSync(fd);
64
+ }
65
+ }
66
+
67
+ /** Parses the 1024-byte NTQQ header (magic, page size, device blob, algorithms). */
68
+ export function parseNtqqHeader(path: string): NtqqHeader {
69
+ const buf = readAt(path, 0, NTQQ_HEADER_SIZE);
70
+ if (buf.length < NTQQ_HEADER_SIZE) throw new Error(`${path}: too small for an NTQQ header`);
71
+ if (buf.subarray(0, 15).toString("latin1") !== "SQLite header 3") {
72
+ throw new Error(`${path}: not an NTQQ database (bad magic ${JSON.stringify(buf.subarray(0, 15).toString("latin1"))})`);
73
+ }
74
+ const pageSize = buf.readUInt16BE(18);
75
+ const blobHex = buf.subarray(0x2f, 0xaf).toString("latin1");
76
+ if (!/^[0-9a-f]{128}$/i.test(blobHex)) throw new Error(`${path}: device blob missing`);
77
+ const text = buf.toString("latin1");
78
+ return {
79
+ pageSize: pageSize >= 512 && (pageSize & (pageSize - 1)) === 0 ? pageSize : DEFAULT_PAGE_SIZE,
80
+ deviceBlob: Buffer.from(blobHex, "hex"),
81
+ hmacAlgorithm: /HMAC_[A-Z0-9]+/.exec(text)?.[0] ?? "HMAC_SHA1",
82
+ formatVersion: /(\d+\.\d+\.\d+\.\d+)/.exec(text)?.[1] ?? "unknown",
83
+ };
84
+ }
85
+
86
+ /** Reads the 16-byte SQLCipher salt stored after the NTQQ header. */
87
+ export function readSalt(path: string, layout: DatabaseLayout = DEFAULT_LAYOUT): Buffer {
88
+ const salt = readAt(path, layout.headerSize, 16);
89
+ if (salt.length !== 16) throw new Error(`${path}: missing salt at ${layout.headerSize}`);
90
+ return Buffer.from(salt);
91
+ }
92
+
93
+ export interface KeyOptions {
94
+ kdfIter?: number;
95
+ /** PBKDF2 PRF; SQLCipher 4 uses SHA-512 for the NTQQ key size. */
96
+ prf?: "sha512" | "sha256" | "sha1";
97
+ }
98
+
99
+ /** Derives the 32-byte AES key from the device passphrase and the database salt. */
100
+ export function deriveKey(passphrase: string | Buffer, salt: Buffer, opts: KeyOptions = {}): Buffer {
101
+ const pass = typeof passphrase === "string" ? Buffer.from(passphrase, "utf8") : passphrase;
102
+ return pbkdf2Sync(pass, salt, opts.kdfIter ?? DEFAULT_KDF_ITER, 32, opts.prf ?? "sha512");
103
+ }
104
+
105
+ /** Decrypts one 4096-byte encrypted page into its SQLite plaintext form. */
106
+ export function decryptPage(encrypted: Buffer, key: Buffer, pgno: number, layout: DatabaseLayout = DEFAULT_LAYOUT): Buffer {
107
+ const { pageSize, tailSize } = layout;
108
+ if (encrypted.length !== pageSize) throw new Error(`page ${pgno}: expected ${pageSize} bytes, got ${encrypted.length}`);
109
+ // Page 1 prefers the salt over the SQLite magic, so its first 16 bytes are not ciphertext.
110
+ const ctStart = pgno === 1 ? 16 : 0;
111
+ const ctEnd = pageSize - tailSize;
112
+ const ivStart = ctEnd;
113
+ const decipher = createDecipheriv("aes-256-cbc", key, encrypted.subarray(ivStart, ivStart + 16));
114
+ decipher.setAutoPadding(false);
115
+ const plain = Buffer.concat([decipher.update(encrypted.subarray(ctStart, ctEnd)), decipher.final()]);
116
+ const page = Buffer.alloc(pageSize);
117
+ plain.copy(page, ctStart);
118
+ return page;
119
+ }
120
+
121
+ /** Inverse of {@link decryptPage}; used to build round-trip fixtures and repacked databases. */
122
+ export function encryptPage(plain: Buffer, key: Buffer, pgno: number, layout: DatabaseLayout = DEFAULT_LAYOUT): Buffer {
123
+ const { pageSize, tailSize } = layout;
124
+ if (plain.length !== pageSize) throw new Error(`page ${pgno}: expected ${pageSize} bytes, got ${plain.length}`);
125
+ const ctStart = pgno === 1 ? 16 : 0;
126
+ const ctEnd = pageSize - tailSize;
127
+ const page = Buffer.alloc(pageSize);
128
+ plain.subarray(ctStart, ctEnd).copy(page, ctStart);
129
+ // Deterministic IV: hash of the plaintext page, so encryption stays reproducible.
130
+ const iv = createHash("sha256").update(plain).digest().subarray(0, 16);
131
+ iv.copy(page, ctEnd);
132
+ const cipher = createCipheriv("aes-256-cbc", key, iv);
133
+ cipher.setAutoPadding(false);
134
+ cipher.update(page.subarray(ctStart, ctEnd)).copy(page, ctStart);
135
+ cipher.final();
136
+ return page;
137
+ }
138
+
139
+ export interface WalFrame {
140
+ pgno: number;
141
+ /** Database size after this frame when non-zero (commit marker). */
142
+ dbSize: number;
143
+ page: Buffer;
144
+ }
145
+
146
+ export interface WalContents {
147
+ frames: WalFrame[];
148
+ /** Frame count excluding frames past the last commit. */
149
+ committedFrames: number;
150
+ /** Database page count recorded by the last commit frame. */
151
+ dbSize?: number;
152
+ }
153
+
154
+ /**
155
+ * Reads a `-wal` file, verifying the WAL checksum chain, and returns only the
156
+ * frames up to the last committed transaction.
157
+ */
158
+ export function readWal(walPath: string, layout: DatabaseLayout = DEFAULT_LAYOUT): WalContents {
159
+ const { pageSize } = layout;
160
+ const size = statSync(walPath).size;
161
+ const frameSize = 24 + pageSize;
162
+ if (size < 32 + frameSize) return { frames: [], committedFrames: 0 };
163
+ const fd = openSync(walPath, "r");
164
+ const buf = Buffer.alloc(size);
165
+ try {
166
+ readSync(fd, buf, 0, size, 0);
167
+ } finally {
168
+ closeSync(fd);
169
+ }
170
+ const walPageSize = buf.readUInt32BE(8);
171
+ if (walPageSize !== pageSize) return { frames: [], committedFrames: 0 };
172
+ const bigEndian = (buf.readUInt32BE(0) & 1) === 1;
173
+ const salt1 = buf.readUInt32BE(16);
174
+ const salt2 = buf.readUInt32BE(20);
175
+ const checksum = (data: Buffer, s0: number, s1: number): [number, number] => {
176
+ for (let i = 0; i + 8 <= data.length; i += 8) {
177
+ const a = bigEndian ? data.readUInt32BE(i) : data.readUInt32LE(i);
178
+ const b = bigEndian ? data.readUInt32BE(i + 4) : data.readUInt32LE(i + 4);
179
+ s0 = (s0 + a + s1) >>> 0;
180
+ s1 = (s1 + b + s0) >>> 0;
181
+ }
182
+ return [s0, s1];
183
+ };
184
+ let [s0, s1] = checksum(buf.subarray(0, 24), 0, 0);
185
+ const raw: WalFrame[] = [];
186
+ const count = Math.floor((size - 32) / frameSize);
187
+ for (let i = 0; i < count; i++) {
188
+ const off = 32 + i * frameSize;
189
+ const head = buf.subarray(off, off + 24);
190
+ if (head.readUInt32BE(8) !== salt1 || head.readUInt32BE(12) !== salt2) break;
191
+ [s0, s1] = checksum(head.subarray(0, 8), s0, s1);
192
+ [s0, s1] = checksum(buf.subarray(off + 24, off + 24 + pageSize), s0, s1);
193
+ if (s0 !== head.readUInt32BE(16) || s1 !== head.readUInt32BE(20)) break;
194
+ raw.push({ pgno: head.readUInt32BE(0), dbSize: head.readUInt32BE(4), page: buf.subarray(off + 24, off + 24 + pageSize) });
195
+ }
196
+ let lastCommit = -1;
197
+ raw.forEach((f, i) => {
198
+ if (f.dbSize !== 0) lastCommit = i;
199
+ });
200
+ if (lastCommit < 0) return { frames: [], committedFrames: 0 };
201
+ return { frames: raw.slice(0, lastCommit + 1), committedFrames: lastCommit + 1, dbSize: raw[lastCommit].dbSize };
202
+ }
203
+
204
+ export interface ReconstructOptions {
205
+ /** Database file; defaults to `<nt_msg.db>` semantics — any NTQQ database. */
206
+ dbPath: string;
207
+ /** Device passphrase, or a 32-byte raw key. */
208
+ passphrase?: string | Buffer;
209
+ /** Pre-derived 32-byte key (skips PBKDF2). */
210
+ key?: Buffer;
211
+ /** Output path for the plaintext SQLite file. */
212
+ outPath: string;
213
+ /** Replay `<dbPath>-wal` frames (default true). */
214
+ wal?: boolean;
215
+ layout?: DatabaseLayout;
216
+ kdf?: KeyOptions;
217
+ onProgress?: (done: number, total: number) => void;
218
+ }
219
+
220
+ export interface ReconstructResult {
221
+ outPath: string;
222
+ pageCount: number;
223
+ /** Pages replaced by committed WAL frames. */
224
+ walPages: number;
225
+ bytes: number;
226
+ durationMs: number;
227
+ }
228
+
229
+ /** Page count of an NTQQ database file. */
230
+ export function pageCount(dbPath: string, layout: DatabaseLayout = DEFAULT_LAYOUT): number {
231
+ const size = statSync(dbPath).size;
232
+ if (size < layout.headerSize + layout.pageSize) return 0;
233
+ return Math.floor((size - layout.headerSize) / layout.pageSize);
234
+ }
235
+
236
+ /**
237
+ * Decrypts a whole NTQQ database (optionally replaying its WAL) into a plain
238
+ * SQLite file. Streaming: memory use is bounded by the batch size, not the
239
+ * database size.
240
+ */
241
+ export function reconstructDatabase(opts: ReconstructOptions): ReconstructResult {
242
+ const layout = opts.layout ?? DEFAULT_LAYOUT;
243
+ const { dbPath, outPath } = opts;
244
+ const { pageSize, headerSize } = layout;
245
+ const total = pageCount(dbPath, layout);
246
+ if (total === 0) throw new Error(`${dbPath}: no pages (file too small or wrong layout)`);
247
+ const key = opts.key ?? deriveKey(opts.passphrase ?? "", readSalt(dbPath, layout), opts.kdf);
248
+
249
+ const walPages = new Map<number, Buffer>();
250
+ let walDBSize: number | undefined;
251
+ if (opts.wal !== false) {
252
+ try {
253
+ const wal = readWal(`${dbPath}-wal`, layout);
254
+ if (wal.dbSize) {
255
+ walDBSize = wal.dbSize;
256
+ for (const f of wal.frames) walPages.set(f.pgno, f.page);
257
+ }
258
+ } catch {
259
+ // A missing or unreadable WAL only costs us the newest frames.
260
+ }
261
+ }
262
+ const effectiveTotal = walDBSize ? Math.min(walDBSize, total) : total;
263
+
264
+ const src = openSync(dbPath, "r");
265
+ const dst = openSync(outPath, "w");
266
+ const started = Date.now();
267
+ try {
268
+ const batch = 256;
269
+ const inBuf = Buffer.alloc(pageSize * batch);
270
+ const outBuf = Buffer.alloc(pageSize * batch);
271
+ let done = 0;
272
+ while (done < effectiveTotal) {
273
+ const n = Math.min(batch, effectiveTotal - done);
274
+ const got = readSync(src, inBuf, 0, n * pageSize, headerSize + done * pageSize);
275
+ if (got < n * pageSize) throw new Error(`${dbPath}: short read at page ${done + 1}`);
276
+ for (let i = 0; i < n; i++) {
277
+ const pgno = done + i + 1;
278
+ const encrypted = walPages.get(pgno) ?? inBuf.subarray(i * pageSize, (i + 1) * pageSize);
279
+ const plain = decryptPage(encrypted, key, pgno, layout);
280
+ if (pgno === 1) {
281
+ // SQLite needs its magic; the salt it replaces is not used by readers.
282
+ SQLITE_MAGIC.copy(plain, 0);
283
+ }
284
+ plain.copy(outBuf, i * pageSize);
285
+ }
286
+ writeSync(dst, outBuf, 0, n * pageSize);
287
+ done += n;
288
+ opts.onProgress?.(done, effectiveTotal);
289
+ }
290
+ return {
291
+ outPath,
292
+ pageCount: effectiveTotal,
293
+ walPages: walPages.size,
294
+ bytes: effectiveTotal * pageSize,
295
+ durationMs: Date.now() - started,
296
+ };
297
+ } finally {
298
+ closeSync(src);
299
+ closeSync(dst);
300
+ }
301
+ }
302
+
303
+ /**
304
+ * True when `passphrase` decrypts page 1 of `dbPath` into a plausible page:
305
+ * a SQLite header carrying the expected page size, plus a valid b-tree header
306
+ * for the `sqlite_schema` root page. The salt occupies bytes 0..16, the rest of
307
+ * the header keeps its standard offsets.
308
+ */
309
+ export function verifyPassphrase(dbPath: string, passphrase: string | Buffer, layout: DatabaseLayout = DEFAULT_LAYOUT): boolean {
310
+ try {
311
+ const key = deriveKey(passphrase, readSalt(dbPath, layout), {});
312
+ const page = readAt(dbPath, layout.headerSize, layout.pageSize);
313
+ if (page.length !== layout.pageSize) return false;
314
+ const plain = decryptPage(page, key, 1, layout);
315
+ if (plain.readUInt16BE(16) !== layout.pageSize) return false;
316
+ const pageType = plain[100];
317
+ const freeblock = plain.readUInt16BE(101);
318
+ const cellCount = plain.readUInt16BE(103);
319
+ if (![2, 5, 10, 13].includes(pageType)) return false;
320
+ if (freeblock !== 0 && (freeblock < 100 || freeblock >= layout.pageSize)) return false;
321
+ if (cellCount > layout.pageSize / 4) return false;
322
+ return true;
323
+ } catch {
324
+ return false;
325
+ }
326
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Worker that tests a slice of candidate passphrases against an NTQQ key
3
+ * oracle. Candidate checking is PBKDF2-bound (~1 ms each), so the parent fans
4
+ * the work out across cores instead of blocking the event loop.
5
+ */
6
+ import { parentPort, workerData } from "node:worker_threads";
7
+ import { verifyKeyCandidate, type KeyOracle } from "./keyscan.js";
8
+
9
+ const { oracle, candidates } = workerData as { oracle: KeyOracle; candidates: string[] };
10
+ let tested = 0;
11
+ for (const candidate of candidates) {
12
+ tested++;
13
+ if (verifyKeyCandidate(oracle, candidate)) {
14
+ parentPort?.postMessage({ passphrase: candidate, tested });
15
+ break;
16
+ }
17
+ }
18
+ parentPort?.postMessage({ passphrase: undefined, tested });