@chainlesschain/personal-data-hub 0.4.30 → 0.4.31
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/lib/forensics/qq-nt-collect.js +190 -0
- package/package.json +5 -3
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* qq-nt-collect — on-device-ready QQNT (nt_msg.db) decrypt + protobuf-parse.
|
|
4
|
+
*
|
|
5
|
+
* Pure Node (crypto + a caller-provided better-sqlite3 ctor) — NO frida, NO adb.
|
|
6
|
+
* This is the bundle-shipped core behind `cc hub collect-qq`, which the Android
|
|
7
|
+
* `CollectQqNativeTool` invokes after su-staging the encrypted DB + uid list. The
|
|
8
|
+
* exact same logic runs on PC (USB pull) and on-device (app su-read).
|
|
9
|
+
*
|
|
10
|
+
* Method (key DERIVED, no frida): `key = MD5(MD5(uid) + rand)` → SQLCipher
|
|
11
|
+
* PBKDF2-HMAC-SHA512/1 (iter 4000) → AES-256-CBC. `rand` is read from the 1024-byte
|
|
12
|
+
* plaintext header; `uid` (QQNT `u_...`) is brute-forced from candidates — page 1
|
|
13
|
+
* decrypting to a valid SQLite header identifies the self uid. (See
|
|
14
|
+
* scripts/android/pdh-qq-android-decrypt.mjs for the standalone PC tool this was
|
|
15
|
+
* extracted from; behaviour is byte-identical.)
|
|
16
|
+
*/
|
|
17
|
+
const crypto = require('crypto');
|
|
18
|
+
const md5 = (s) => crypto.createHash('md5').update(s).digest('hex');
|
|
19
|
+
|
|
20
|
+
/** Read `rand` (protobuf field after "QQ_NT DB") from the plaintext header. */
|
|
21
|
+
function extractRand(raw) {
|
|
22
|
+
const head = raw.subarray(0, 256).toString('latin1');
|
|
23
|
+
const i = head.indexOf('QQ_NT DB');
|
|
24
|
+
if (i < 0) return null;
|
|
25
|
+
const m = head.slice(i + 8, i + 40).match(/[A-Za-z0-9]{6,12}/);
|
|
26
|
+
return m ? m[0] : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Detect HMAC algo named in the header (informational). */
|
|
30
|
+
function headerHmac(raw) {
|
|
31
|
+
return (/HMAC_SHA\d+/.exec(raw.subarray(0, 256).toString('latin1')) || [])[0] || null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// decrypt one page slice. p1=true → page 1 (first 16 bytes = salt, skipped).
|
|
35
|
+
function decPage(page, encKey, reserve, ivOff, p1) {
|
|
36
|
+
const ro = page.length - reserve;
|
|
37
|
+
const ct = page.subarray(p1 ? 16 : 0, ro);
|
|
38
|
+
const iv = page.subarray(ro + ivOff, ro + ivOff + 16);
|
|
39
|
+
if (iv.length < 16) return null;
|
|
40
|
+
try {
|
|
41
|
+
const d = crypto.createDecipheriv('aes-256-cbc', encKey, iv);
|
|
42
|
+
d.setAutoPadding(false);
|
|
43
|
+
return Buffer.concat([d.update(ct), d.final()]);
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const validHdr = (pt) => pt && pt.length > 8 && pt[5] === 64 && pt[6] === 32 && pt[7] === 32;
|
|
49
|
+
const CFG = [[4096, 48, 0], [4096, 80, 0], [4096, 48, 20]];
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Brute the key over uid candidates, then decrypt every page.
|
|
53
|
+
* @returns {{decrypted: Buffer, uid: string, kdf: string}|null} null = no uid matched.
|
|
54
|
+
*/
|
|
55
|
+
function deriveAndDecrypt(raw, uids, rand) {
|
|
56
|
+
if (!raw || raw.length <= 1024 || !rand || !uids || !uids.length) return null;
|
|
57
|
+
const body = raw.subarray(1024);
|
|
58
|
+
const salt = body.subarray(0, 16);
|
|
59
|
+
let hit = null;
|
|
60
|
+
for (const uid of uids) {
|
|
61
|
+
const pass = md5(md5(uid) + rand);
|
|
62
|
+
for (const algo of ['sha512', 'sha1']) {
|
|
63
|
+
const ek = crypto.pbkdf2Sync(Buffer.from(pass, 'utf8'), salt, 4000, 32, algo);
|
|
64
|
+
for (const [pg, rs, iv] of CFG) {
|
|
65
|
+
if (validHdr(decPage(body.subarray(0, pg), ek, rs, iv, true))) {
|
|
66
|
+
hit = { uid, algo, page: pg, reserve: rs, ivOff: iv, ek };
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (hit) break;
|
|
71
|
+
}
|
|
72
|
+
if (hit) break;
|
|
73
|
+
}
|
|
74
|
+
if (!hit) return null;
|
|
75
|
+
const { ek, page, reserve, ivOff } = hit;
|
|
76
|
+
const n = Math.floor(body.length / page);
|
|
77
|
+
const out = [];
|
|
78
|
+
for (let i = 0; i < n; i++) {
|
|
79
|
+
const pt = decPage(body.subarray(i * page, (i + 1) * page), ek, reserve, ivOff, i === 0);
|
|
80
|
+
const full = Buffer.alloc(page);
|
|
81
|
+
if (i === 0) {
|
|
82
|
+
Buffer.from('SQLite format 3\0').copy(full, 0);
|
|
83
|
+
if (pt) pt.copy(full, 16);
|
|
84
|
+
} else if (pt) {
|
|
85
|
+
pt.copy(full, 0);
|
|
86
|
+
}
|
|
87
|
+
out.push(full);
|
|
88
|
+
}
|
|
89
|
+
return { decrypted: Buffer.concat(out), uid: hit.uid, kdf: hit.algo };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── protobuf message-body → readable text (from pdh-qq-ingest.mjs) ──────────
|
|
93
|
+
function readVarint(buf, p) {
|
|
94
|
+
let shift = 0, r = 0n;
|
|
95
|
+
while (p < buf.length) {
|
|
96
|
+
const b = buf[p++];
|
|
97
|
+
r |= BigInt(b & 0x7f) << BigInt(shift);
|
|
98
|
+
if (!(b & 0x80)) break;
|
|
99
|
+
shift += 7;
|
|
100
|
+
}
|
|
101
|
+
return [r, p];
|
|
102
|
+
}
|
|
103
|
+
function* fields(buf) {
|
|
104
|
+
let p = 0;
|
|
105
|
+
while (p < buf.length) {
|
|
106
|
+
let tag;[tag, p] = readVarint(buf, p); tag = Number(tag);
|
|
107
|
+
const wire = tag & 7;
|
|
108
|
+
if (wire === 0) { let v;[v, p] = readVarint(buf, p); } else if (wire === 2) {
|
|
109
|
+
let len;[len, p] = readVarint(buf, p); len = Number(len);
|
|
110
|
+
const data = buf.subarray(p, p + len); p += len; yield data;
|
|
111
|
+
} else if (wire === 5) p += 4; else if (wire === 1) p += 8; else return;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const readable = (s) => s && /[一-鿿 -ヿA-Za-z0-9]/.test(s) && !/[�]/.test(s) &&
|
|
115
|
+
[...s].every((c) => c.charCodeAt(0) >= 32 || c === '\n');
|
|
116
|
+
function extractTexts(buf, depth, out) {
|
|
117
|
+
if (depth > 6 || !buf || buf.length === 0) return;
|
|
118
|
+
for (const data of fields(buf)) {
|
|
119
|
+
if (!data || data.length === 0) continue;
|
|
120
|
+
let s = null; try { s = data.toString('utf8'); } catch {}
|
|
121
|
+
if (s && Buffer.from(s, 'utf8').equals(data) && readable(s) && s.length <= 1000 &&
|
|
122
|
+
!/^https?:\/\/\S+$/.test(s)) out.push(s);
|
|
123
|
+
if (data.length >= 2) extractTexts(data, depth + 1, out);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
function bodyText(blob) {
|
|
127
|
+
if (!Buffer.isBuffer(blob)) return null;
|
|
128
|
+
const out = []; extractTexts(blob, 0, out);
|
|
129
|
+
const cjk = out.filter((t) => /[一-鿿]/.test(t)).sort((a, b) => b.length - a.length);
|
|
130
|
+
return cjk[0] || out.sort((a, b) => b.length - a.length)[0] || null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Parse a DECRYPTED nt_msg.db into vault events (qq-pc adapter shape).
|
|
135
|
+
* @param Database a better-sqlite3 constructor (injected by the caller/bundle)
|
|
136
|
+
* @param dbPath path to the decrypted nt_msg.db
|
|
137
|
+
* @param self the user's own QQ number (attribution fallback)
|
|
138
|
+
* @returns {Array} event objects ready for vault.putEvent
|
|
139
|
+
*/
|
|
140
|
+
function parseEvents(Database, dbPath, self) {
|
|
141
|
+
const src = new Database(dbPath, { readonly: true });
|
|
142
|
+
const events = [];
|
|
143
|
+
const num = (v) => (typeof v === 'bigint' ? Number(v) : v);
|
|
144
|
+
const ingestTable = (table, isGroup) => {
|
|
145
|
+
let rows;
|
|
146
|
+
try {
|
|
147
|
+
rows = src.prepare(
|
|
148
|
+
`SELECT [40001] msgId,[40020] uid,[40011] type,[40033] sender,[40021] peer,` +
|
|
149
|
+
`[40050] t,[40800] body FROM ${table}`,
|
|
150
|
+
).safeIntegers().all();
|
|
151
|
+
} catch { return; }
|
|
152
|
+
for (const r of rows) {
|
|
153
|
+
const type = num(r.type);
|
|
154
|
+
if (type === 5 || type === 9) continue; // grey-bar / system
|
|
155
|
+
const text = bodyText(r.body);
|
|
156
|
+
if (!text || !/[一-鿿A-Za-z0-9]/.test(text)) continue;
|
|
157
|
+
// QQ service/gray-tip config messages (e.g. com.tencent.* push configs) —
|
|
158
|
+
// not real chat; drop so the vault holds conversation, not service noise.
|
|
159
|
+
if (text.includes('com.tencent.') || text.includes('public desc')) continue;
|
|
160
|
+
const msgId = typeof r.msgId === 'bigint' ? r.msgId.toString() : String(r.msgId);
|
|
161
|
+
const sender = String(num(r.sender) || '');
|
|
162
|
+
const peer = String(num(r.peer) || '');
|
|
163
|
+
const occurredAt = num(r.t) * 1000;
|
|
164
|
+
if (!occurredAt) continue;
|
|
165
|
+
const actor = sender ? `person-qq-${sender}` : `person-qq-${self}`;
|
|
166
|
+
const participants = [actor];
|
|
167
|
+
participants.push(isGroup ? `group-qq-${peer}` : `person-qq-${peer}`);
|
|
168
|
+
events.push({
|
|
169
|
+
type: 'event', subtype: 'message', id: `qq:${table}:${msgId}`,
|
|
170
|
+
occurredAt, actor, participants,
|
|
171
|
+
content: { text: isGroup ? `[群${peer}] ${text}` : text },
|
|
172
|
+
topics: isGroup ? [`group-qq-${peer}`] : undefined,
|
|
173
|
+
source: {
|
|
174
|
+
adapter: 'qq-pc', adapterVersion: '0.1.0', originalId: `${table}:${msgId}`,
|
|
175
|
+
capturedAt: occurredAt, capturedBy: 'sqlite',
|
|
176
|
+
},
|
|
177
|
+
ingestedAt: Date.now(),
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
try {
|
|
182
|
+
ingestTable('c2c_msg_table', false);
|
|
183
|
+
ingestTable('group_msg_table', true);
|
|
184
|
+
} finally {
|
|
185
|
+
src.close();
|
|
186
|
+
}
|
|
187
|
+
return events;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
module.exports = { extractRand, headerHmac, deriveAndDecrypt, bodyText, parseEvents };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chainlesschain/personal-data-hub",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.31",
|
|
4
4
|
"description": "Personal Data Hub — UnifiedSchema + validators + KG ingest helpers for the data-back-to-the-individual middleware",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -73,7 +73,8 @@
|
|
|
73
73
|
"./adapters/messaging-whatsapp": "./lib/adapters/messaging-whatsapp/index.js",
|
|
74
74
|
"./sidecar": "./lib/sidecar/index.js",
|
|
75
75
|
"./forensics/leaf-salvage": "./lib/forensics/leaf-salvage.js",
|
|
76
|
-
"./forensics/salvage-ingest": "./lib/forensics/salvage-ingest.js"
|
|
76
|
+
"./forensics/salvage-ingest": "./lib/forensics/salvage-ingest.js",
|
|
77
|
+
"./forensics/qq-nt-collect": "./lib/forensics/qq-nt-collect.js"
|
|
77
78
|
},
|
|
78
79
|
"scripts": {
|
|
79
80
|
"test": "vitest run",
|
|
@@ -104,7 +105,8 @@
|
|
|
104
105
|
"optionalDependencies": {
|
|
105
106
|
"imapflow": "^1.0.183",
|
|
106
107
|
"adm-zip": "^0.5.16",
|
|
107
|
-
"iconv-lite": "^0.6.3"
|
|
108
|
+
"iconv-lite": "^0.6.3",
|
|
109
|
+
"pdf-parse": "^1.1.1"
|
|
108
110
|
},
|
|
109
111
|
"devDependencies": {
|
|
110
112
|
"vitest": "^4.1.5"
|