@chainlesschain/personal-data-hub 0.2.0 → 0.2.2

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.
Files changed (59) hide show
  1. package/__tests__/adapters/ai-chat-cookie-capture-spec.test.js +211 -0
  2. package/__tests__/adapters/ai-chat-health-checker.test.js +262 -0
  3. package/__tests__/adapters/ai-chat-history.test.js +8 -7
  4. package/__tests__/adapters/ai-chat-vendors.test.js +149 -8
  5. package/__tests__/adapters/social-toutiao-kuaishou-scaffold.test.js +269 -0
  6. package/__tests__/adapters/system-data-android-ingest.test.js +144 -0
  7. package/__tests__/adapters/system-data-android.test.js +387 -0
  8. package/__tests__/adapters/wechat-bootstrap.test.js +240 -0
  9. package/__tests__/adapters/wechat-env-probe.test.js +162 -0
  10. package/__tests__/adapters/wechat-frida-agent.test.js +322 -0
  11. package/__tests__/adapters/wechat-frida-integration.test.js +149 -0
  12. package/__tests__/adapters/wechat-frida-key-provider.test.js +188 -0
  13. package/__tests__/adapters/wechat-md5-key-provider.test.js +101 -0
  14. package/__tests__/analysis-skills.test.js +147 -0
  15. package/__tests__/analysis.test.js +329 -1
  16. package/__tests__/e2e/ai-chat-cross-source-journey.test.js +213 -0
  17. package/__tests__/e2e/full-user-journey.test.js +188 -0
  18. package/__tests__/integration/ai-chat-history-registry.test.js +228 -0
  19. package/__tests__/integration/aichat-wizard-end-to-end.test.js +282 -0
  20. package/__tests__/integration/cross-adapter-pipelines.test.js +396 -0
  21. package/__tests__/integration/social-bilibili-pipeline.test.js +261 -0
  22. package/__tests__/integration/wechat-bootstrap-end-to-end.test.js +390 -0
  23. package/__tests__/registry.test.js +4 -2
  24. package/__tests__/social-adapters.test.js +63 -14
  25. package/__tests__/social-bilibili-snapshot.test.js +278 -0
  26. package/__tests__/wechat-adapter.test.js +118 -0
  27. package/lib/adapters/ai-chat-history/ai-chat-adapter.js +55 -16
  28. package/lib/adapters/ai-chat-history/cookie-capture-spec.js +331 -0
  29. package/lib/adapters/ai-chat-history/health-checker.js +210 -0
  30. package/lib/adapters/ai-chat-history/schema-map.js +42 -5
  31. package/lib/adapters/ai-chat-history/vendor-spec.js +1 -0
  32. package/lib/adapters/ai-chat-history/vendors/doubao.js +255 -0
  33. package/lib/adapters/ai-chat-history/wizard-controller.js +473 -0
  34. package/lib/adapters/alipay-bill/alipay-bill-adapter.js +4 -0
  35. package/lib/adapters/social-bilibili/adapter.js +500 -0
  36. package/lib/adapters/social-bilibili/index.js +21 -169
  37. package/lib/adapters/social-kuaishou/index.js +237 -0
  38. package/lib/adapters/social-toutiao/index.js +236 -0
  39. package/lib/adapters/system-data-android/adapter.js +348 -0
  40. package/lib/adapters/system-data-android/index.js +76 -0
  41. package/lib/adapters/wechat/bootstrap.js +146 -0
  42. package/lib/adapters/wechat/content-parser.js +11 -2
  43. package/lib/adapters/wechat/db-reader.js +88 -10
  44. package/lib/adapters/wechat/env-probe.js +218 -0
  45. package/lib/adapters/wechat/frida-agent/loader.js +74 -0
  46. package/lib/adapters/wechat/frida-agent/wechat-key-hook.js +248 -0
  47. package/lib/adapters/wechat/index.js +9 -0
  48. package/lib/adapters/wechat/key-providers/frida-key-provider.js +252 -0
  49. package/lib/adapters/wechat/key-providers/index.js +22 -0
  50. package/lib/adapters/wechat/key-providers/key-provider-base.js +44 -0
  51. package/lib/adapters/wechat/key-providers/md5-key-provider.js +81 -0
  52. package/lib/adapters/wechat/normalize.js +12 -3
  53. package/lib/analysis-skills/spending.js +4 -1
  54. package/lib/analysis.js +191 -2
  55. package/lib/index.js +16 -0
  56. package/lib/prompt-builder.js +11 -1
  57. package/lib/query-parser.js +7 -1
  58. package/lib/vault.js +77 -0
  59. package/package.json +8 -1
@@ -129,27 +129,88 @@ class WeChatDBReader {
129
129
  .map((r) => r.name);
130
130
  }
131
131
 
132
+ /**
133
+ * Discover actual column names via `PRAGMA table_info(<table>)` so
134
+ * uppercase/lowercase divergence across WeChat builds doesn't blow up
135
+ * the SELECT. Returns a Map<lowercased_name, actual_name>.
136
+ *
137
+ * Post-sjqz audit defence — sjqz schema docs show some column-case
138
+ * variation across versions; failing late at SELECT yields a confusing
139
+ * "no such column" error rather than a clean fallback path.
140
+ */
141
+ _columnMap(table) {
142
+ if (!this._db) return new Map();
143
+ try {
144
+ const rows = this._db.prepare(`PRAGMA table_info(${table})`).all();
145
+ return new Map(rows.map((r) => [String(r.name).toLowerCase(), r.name]));
146
+ } catch (_e) {
147
+ return new Map();
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Resolve a list of desired column names against the actual table
153
+ * schema. Returns the actual column names quoted for SQL use; throws
154
+ * if any required column is missing (caller catches and surfaces a
155
+ * "schema-mismatch" error to the host).
156
+ */
157
+ _resolveColumns(table, desiredNames, { required = true } = {}) {
158
+ const map = this._columnMap(table);
159
+ const resolved = [];
160
+ const missing = [];
161
+ for (const name of desiredNames) {
162
+ const actual = map.get(name.toLowerCase());
163
+ if (actual) resolved.push(actual);
164
+ else if (required) missing.push(name);
165
+ }
166
+ if (missing.length > 0 && required) {
167
+ const err = new Error(
168
+ `WeChatDBReader: table '${table}' missing required columns: ${missing.join(", ")} ` +
169
+ `(available: ${Array.from(map.values()).join(", ")})`,
170
+ );
171
+ err.code = "WECHAT_SCHEMA_MISMATCH";
172
+ throw err;
173
+ }
174
+ return resolved;
175
+ }
176
+
132
177
  /**
133
178
  * Fetch up to `limit` messages since `sinceMsgSvrId` (per design doc
134
179
  * §6 OQ-6 watermark = per-talker last msgSvrId). For initial v0 we
135
180
  * accept a global watermark and let the adapter post-filter per
136
181
  * talker.
182
+ *
183
+ * Column names resolved via PRAGMA table_info to survive case-drift
184
+ * across WeChat versions (sjqz audit defence).
137
185
  */
138
186
  fetchMessages({ sinceMsgSvrId = 0, limit = 1000, talker = null } = {}) {
139
187
  if (!this._db) throw new Error("WeChatDBReader: call open() first");
140
- let sql = "SELECT msgId, msgSvrId, talker, content, type, createTime, isSend, status FROM message";
188
+ const cols = this._resolveColumns("message", [
189
+ "msgId",
190
+ "msgSvrId",
191
+ "talker",
192
+ "content",
193
+ "type",
194
+ "createTime",
195
+ "isSend",
196
+ "status",
197
+ ]);
198
+ let sql = `SELECT ${cols.join(", ")} FROM message`;
141
199
  const params = [];
142
200
  const where = [];
143
201
  if (sinceMsgSvrId) {
144
- where.push("msgSvrId > ?");
202
+ // Use the resolved column name in WHERE / ORDER BY to match case.
203
+ const msgSvrIdCol = cols[1];
204
+ where.push(`${msgSvrIdCol} > ?`);
145
205
  params.push(sinceMsgSvrId);
146
206
  }
147
207
  if (talker) {
148
- where.push("talker = ?");
208
+ const talkerCol = cols[2];
209
+ where.push(`${talkerCol} = ?`);
149
210
  params.push(talker);
150
211
  }
151
212
  if (where.length > 0) sql += " WHERE " + where.join(" AND ");
152
- sql += " ORDER BY msgSvrId ASC LIMIT ?";
213
+ sql += ` ORDER BY ${cols[1]} ASC LIMIT ?`;
153
214
  params.push(limit);
154
215
  return this._db.prepare(sql).all(...params);
155
216
  }
@@ -157,14 +218,31 @@ class WeChatDBReader {
157
218
  /**
158
219
  * Fetch contacts. WeChat rcontact has many columns; we pull the ones
159
220
  * relevant for normalization.
221
+ *
222
+ * sjqz parity (wechat.py:262-263): excludes `@stranger` (unconfirmed
223
+ * friend requests) and `fake_*` (WeChat internal placeholder accounts).
224
+ * Without this filter the vault gets polluted with junk Person entities
225
+ * that never represent real contacts.
226
+ *
227
+ * @param {object} [opts]
228
+ * @param {number} [opts.limit=5000]
229
+ * @param {boolean} [opts.includeJunk=false] true to skip the
230
+ * stranger/fake filter (debug / forensic use only)
160
231
  */
161
- fetchContacts({ limit = 5000 } = {}) {
232
+ fetchContacts({ limit = 5000, includeJunk = false } = {}) {
162
233
  if (!this._db) throw new Error("WeChatDBReader: call open() first");
163
- return this._db
164
- .prepare(
165
- "SELECT username, alias, nickname, conRemark, type FROM rcontact LIMIT ?",
166
- )
167
- .all(limit);
234
+ const cols = this._resolveColumns("rcontact", [
235
+ "username",
236
+ "alias",
237
+ "nickname",
238
+ "conRemark",
239
+ "type",
240
+ ]);
241
+ const usernameCol = cols[0];
242
+ const sql = includeJunk
243
+ ? `SELECT ${cols.join(", ")} FROM rcontact LIMIT ?`
244
+ : `SELECT ${cols.join(", ")} FROM rcontact WHERE ${usernameCol} NOT LIKE '%@stranger' AND ${usernameCol} NOT LIKE 'fake_%' LIMIT ?`;
245
+ return this._db.prepare(sql).all(limit);
168
246
  }
169
247
 
170
248
  /**
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Phase 12.6.4 — WeChat env-probe.
3
+ *
4
+ * Inspects the connected Android device (via `adb`) and decides which
5
+ * KeyProvider strategy is viable:
6
+ *
7
+ * - suggestedKeyProvider: "md5" | "frida" | "unsupported"
8
+ *
9
+ * Decision tree:
10
+ * - WeChat < 8.0 → "md5" (legacy MD5(IMEI+UIN)[:7] path works)
11
+ * - WeChat 8.0+ → "frida" (need live hook; requires root + frida-server)
12
+ * - WeChat absent / device unreachable → "unsupported"
13
+ *
14
+ * Each capability is probed independently so UI can show a checklist.
15
+ * All shell commands are routed through an injected `exec()` so unit
16
+ * tests don't spawn real processes.
17
+ *
18
+ * Wire shape:
19
+ * {
20
+ * ok: boolean, // overall — true iff suggestedKeyProvider !== "unsupported"
21
+ * suggestedKeyProvider: "md5" | "frida" | "unsupported",
22
+ * reasons: string[], // human strings for why suggestion chosen
23
+ * device: {
24
+ * reachable: boolean,
25
+ * serial: string | null,
26
+ * abi: string | null, // arm64-v8a / armeabi-v7a / x86_64
27
+ * },
28
+ * root: {
29
+ * detected: boolean,
30
+ * magiskInstalled: boolean,
31
+ * },
32
+ * frida: {
33
+ * serverRunning: boolean,
34
+ * port: number | null,
35
+ * },
36
+ * wechat: {
37
+ * installed: boolean,
38
+ * versionName: string | null,
39
+ * majorVersion: number | null,
40
+ * },
41
+ * warnings: string[],
42
+ * }
43
+ */
44
+ "use strict";
45
+
46
+ // Lazy require of child_process so test injection beats the import
47
+ function defaultExec() {
48
+ // eslint-disable-next-line global-require
49
+ const cp = require("node:child_process");
50
+ return (cmd, opts = {}) => new Promise((resolve) => {
51
+ cp.exec(cmd, { encoding: "utf-8", timeout: 5000, ...opts }, (err, stdout, stderr) => {
52
+ resolve({
53
+ code: err ? (err.code || 1) : 0,
54
+ stdout: String(stdout || ""),
55
+ stderr: String(stderr || ""),
56
+ });
57
+ });
58
+ });
59
+ }
60
+
61
+ async function probeDevice(exec) {
62
+ const out = { reachable: false, serial: null, abi: null };
63
+ const r = await exec("adb devices");
64
+ if (r.code !== 0) return out;
65
+ const lines = r.stdout.split(/\r?\n/);
66
+ // Header: "List of devices attached" then "<serial>\tdevice"
67
+ for (const line of lines) {
68
+ const m = /^(\S+)\s+device\s*$/.exec(line);
69
+ if (m) {
70
+ out.reachable = true;
71
+ out.serial = m[1];
72
+ break;
73
+ }
74
+ }
75
+ if (!out.reachable) return out;
76
+ const abiR = await exec("adb shell getprop ro.product.cpu.abi");
77
+ out.abi = abiR.code === 0 ? abiR.stdout.trim() || null : null;
78
+ return out;
79
+ }
80
+
81
+ async function probeRoot(exec) {
82
+ const out = { detected: false, magiskInstalled: false };
83
+ // `su -c id` succeeds (uid=0) only on rooted devices
84
+ const su = await exec('adb shell "su -c id" 2>&1');
85
+ if (su.code === 0 && /uid=0/.test(su.stdout)) out.detected = true;
86
+ // which magisk (Magisk command-line)
87
+ const ma = await exec('adb shell "command -v magisk"');
88
+ if (ma.code === 0 && ma.stdout.trim()) out.magiskInstalled = true;
89
+ return out;
90
+ }
91
+
92
+ async function probeFridaServer(exec) {
93
+ const out = { serverRunning: false, port: null };
94
+ // Look for frida-server in process list (matches frida-server-* prebuilds)
95
+ const ps = await exec('adb shell "pgrep -f frida-server" 2>&1');
96
+ if (ps.code === 0 && /^\d+/m.test(ps.stdout.trim())) out.serverRunning = true;
97
+ // Default port 27042; check if it's listening
98
+ const ns = await exec('adb shell "netstat -tln 2>/dev/null | grep -E \':27042\\b\'"');
99
+ if (ns.code === 0 && ns.stdout.includes("27042")) {
100
+ out.serverRunning = true;
101
+ out.port = 27042;
102
+ } else if (out.serverRunning) {
103
+ out.port = 27042;
104
+ }
105
+ return out;
106
+ }
107
+
108
+ async function probeWeChat(exec) {
109
+ const out = { installed: false, versionName: null, majorVersion: null };
110
+ const r = await exec('adb shell "dumpsys package com.tencent.mm | grep versionName"');
111
+ if (r.code !== 0) return out;
112
+ const m = /versionName=([\d.]+)/i.exec(r.stdout);
113
+ if (!m) return out;
114
+ out.installed = true;
115
+ out.versionName = m[1];
116
+ const major = parseInt(m[1].split(".")[0], 10);
117
+ out.majorVersion = Number.isFinite(major) ? major : null;
118
+ return out;
119
+ }
120
+
121
+ /**
122
+ * Decide suggested KeyProvider given probed capabilities.
123
+ * Exported separately so callers can also pass synthetic facts.
124
+ */
125
+ function decide({ device, root, frida, wechat }) {
126
+ const reasons = [];
127
+ if (!device.reachable) {
128
+ return { suggestedKeyProvider: "unsupported", reasons: ["No adb device reachable"] };
129
+ }
130
+ if (!wechat.installed) {
131
+ return { suggestedKeyProvider: "unsupported", reasons: ["WeChat (com.tencent.mm) not installed"] };
132
+ }
133
+ const major = wechat.majorVersion;
134
+ if (major != null && major < 8) {
135
+ reasons.push(`WeChat ${wechat.versionName} (< 8.0) — legacy MD5(IMEI+UIN) path supported`);
136
+ return { suggestedKeyProvider: "md5", reasons };
137
+ }
138
+ // 8.0+ requires frida
139
+ if (!root.detected) {
140
+ return {
141
+ suggestedKeyProvider: "unsupported",
142
+ reasons: [`WeChat ${wechat.versionName || "8.x"} requires root for SQLCipher key extraction, root not detected`],
143
+ };
144
+ }
145
+ if (!frida.serverRunning) {
146
+ return {
147
+ suggestedKeyProvider: "unsupported",
148
+ reasons: [
149
+ `WeChat ${wechat.versionName || "8.x"} requires Frida hook`,
150
+ "frida-server not running on device — see Frida Setup runbook",
151
+ ],
152
+ };
153
+ }
154
+ reasons.push(`WeChat ${wechat.versionName} (≥ 8.0) — Frida hook on libwcdb.so sqlite3_key`);
155
+ if (!root.magiskInstalled) {
156
+ reasons.push("Magisk not detected — DenyList configuration unavailable; reverse-detection may be weaker");
157
+ }
158
+ return { suggestedKeyProvider: "frida", reasons };
159
+ }
160
+
161
+ /**
162
+ * Top-level probe.
163
+ *
164
+ * @param {object} [opts]
165
+ * @param {Function} [opts.exec] injected exec(cmd, opts) → {code, stdout, stderr}
166
+ * @returns {Promise<object>} full probe shape (see file header)
167
+ */
168
+ async function probe(opts = {}) {
169
+ const exec = typeof opts.exec === "function" ? opts.exec : defaultExec();
170
+ const warnings = [];
171
+
172
+ const device = await probeDevice(exec);
173
+ if (!device.reachable) {
174
+ return {
175
+ ok: false,
176
+ suggestedKeyProvider: "unsupported",
177
+ reasons: ["No adb device reachable — enable USB debugging and reconnect"],
178
+ device,
179
+ root: { detected: false, magiskInstalled: false },
180
+ frida: { serverRunning: false, port: null },
181
+ wechat: { installed: false, versionName: null, majorVersion: null },
182
+ warnings,
183
+ };
184
+ }
185
+
186
+ const [root, frida, wechat] = await Promise.all([
187
+ probeRoot(exec),
188
+ probeFridaServer(exec),
189
+ probeWeChat(exec),
190
+ ]);
191
+
192
+ if (device.abi && !/^arm/.test(device.abi)) {
193
+ warnings.push(`ABI ${device.abi} — Frida prebuilt may need manual build for non-ARM target`);
194
+ }
195
+
196
+ const { suggestedKeyProvider, reasons } = decide({ device, root, frida, wechat });
197
+
198
+ return {
199
+ ok: suggestedKeyProvider !== "unsupported",
200
+ suggestedKeyProvider,
201
+ reasons,
202
+ device,
203
+ root,
204
+ frida,
205
+ wechat,
206
+ warnings,
207
+ };
208
+ }
209
+
210
+ module.exports = {
211
+ probe,
212
+ decide,
213
+ // exposed for fine-grained testing
214
+ probeDevice,
215
+ probeRoot,
216
+ probeFridaServer,
217
+ probeWeChat,
218
+ };
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Phase 12.6.2 — Frida agent loader (host side).
3
+ *
4
+ * Reads the raw agent JS so FridaKeyProvider can pass it to
5
+ * `session.createScript(text)`. Also exposes `runUnderMock()` so the
6
+ * host can unit-test the agent's behavior by injecting fake Frida
7
+ * globals (Module / Interceptor / Process / send / setTimeout).
8
+ *
9
+ * The agent itself (wechat-key-hook.js) is plain JS — no `require` or
10
+ * `module.exports` — because Frida loads it as a text blob inside the
11
+ * target process. The loader hides that detail from callers.
12
+ */
13
+ "use strict";
14
+
15
+ const fs = require("node:fs");
16
+ const path = require("node:path");
17
+ const vm = require("node:vm");
18
+
19
+ const AGENT_PATH = path.join(__dirname, "wechat-key-hook.js");
20
+
21
+ function loadAgentScript() {
22
+ return fs.readFileSync(AGENT_PATH, "utf-8");
23
+ }
24
+
25
+ /**
26
+ * Execute the agent script in a sandboxed context where the caller
27
+ * supplies the Frida globals. Used for unit tests.
28
+ *
29
+ * @param {object} mocks
30
+ * @param {object} mocks.Module mock with findExportByName(modName, sym)
31
+ * @param {object} mocks.Process mock with findModuleByName(modName)
32
+ * @param {object} mocks.Interceptor mock with attach(addr, handlers)
33
+ * @param {Function} mocks.send captures send() calls
34
+ * @param {Function} [mocks.setTimeout] defaults to global setTimeout
35
+ * @returns {object} the sandbox context after execution
36
+ */
37
+ function runAgentUnderMock(mocks = {}) {
38
+ if (!mocks.Module || typeof mocks.Module.findExportByName !== "function") {
39
+ throw new Error("runAgentUnderMock: mocks.Module.findExportByName required");
40
+ }
41
+ if (!mocks.Process || typeof mocks.Process.findModuleByName !== "function") {
42
+ throw new Error("runAgentUnderMock: mocks.Process.findModuleByName required");
43
+ }
44
+ if (!mocks.Interceptor || typeof mocks.Interceptor.attach !== "function") {
45
+ throw new Error("runAgentUnderMock: mocks.Interceptor.attach required");
46
+ }
47
+ if (typeof mocks.send !== "function") {
48
+ throw new Error("runAgentUnderMock: mocks.send required");
49
+ }
50
+ const sandbox = {
51
+ Module: mocks.Module,
52
+ Process: mocks.Process,
53
+ Interceptor: mocks.Interceptor,
54
+ send: mocks.send,
55
+ setTimeout: mocks.setTimeout || setTimeout,
56
+ // Frida injects Memory at runtime; tests that exercise the ascii-hex
57
+ // key-read path inject a mock with readCString(ptr, maxLen). Tests
58
+ // that don't touch it get a no-op stub so the agent module loads
59
+ // cleanly even when the hook itself never calls readCString.
60
+ Memory: mocks.Memory || {
61
+ readCString: () => null,
62
+ },
63
+ };
64
+ const ctx = vm.createContext(sandbox);
65
+ const src = loadAgentScript();
66
+ vm.runInContext(src, ctx, { filename: "wechat-key-hook.js" });
67
+ return sandbox;
68
+ }
69
+
70
+ module.exports = {
71
+ AGENT_PATH,
72
+ loadAgentScript,
73
+ runAgentUnderMock,
74
+ };
@@ -0,0 +1,248 @@
1
+ /**
2
+ * Phase 12.6.2 — Frida agent: WeChat SQLCipher key hook.
3
+ *
4
+ * This script runs INSIDE the WeChat process (com.tencent.mm) after
5
+ * being injected by the FridaKeyProvider on the host. It hooks the
6
+ * sqlite3_key family of symbols in libwcdb.so (WeChat's SQLCipher fork)
7
+ * and sends the captured 32-byte key hex back to the host via Frida's
8
+ * `send()` channel.
9
+ *
10
+ * Wire contract (host expects):
11
+ * { kind: "key", hex: "<lowercase hex>", source: "<symbol-name>" }
12
+ * { kind: "hooked", symbol: "<symbol>", module: "libwcdb.so" }
13
+ * { kind: "error", message: "<reason>" }
14
+ * { kind: "module-waiting", module: "libwcdb.so" }
15
+ *
16
+ * Design references:
17
+ * - Adapter_WeChat_SQLCipher.md §18.3 (agent script structure)
18
+ * - §18.6 (anti-detection — hook at module-load time)
19
+ *
20
+ * Notes on portability:
21
+ * - This file is loaded by the host as raw text and passed verbatim
22
+ * to `session.createScript(...)`. It MUST NOT use any node `require()`
23
+ * or host-only APIs. Frida injects its own runtime (Module,
24
+ * Interceptor, Process, send, etc.) at runtime.
25
+ * - Keep dependencies on host helpers (e.g. esbuild) zero. The whole
26
+ * agent is ≤ 120 LOC of plain JS — no compilation step needed.
27
+ */
28
+
29
+ /* eslint-disable */
30
+ /* global Module, Interceptor, Process, send, setTimeout */
31
+
32
+ "use strict";
33
+
34
+ (function () {
35
+ // sjqz-verified module name is `libWCDB.so` (uppercase); some WeChat
36
+ // builds ship lowercase. Try both — first match wins, no extra cost
37
+ // because Process.findModuleByName is a cheap lookup.
38
+ var TARGET_MODULES = ["libWCDB.so", "libwcdb.so"];
39
+ // Primary symbol per §18.3. Add fallbacks below — version drift will
40
+ // shift the export name; host treats first hit as authoritative.
41
+ var SYMBOLS = [
42
+ "sqlite3_key",
43
+ "sqlite3_key_v2",
44
+ "wcdb_setkey",
45
+ "WCDBKeyDerive",
46
+ // C++-mangled symbols (Itanium ABI) — rare but seen in WCDB 1.x
47
+ "_ZN4WCDB8Database13setCipherKeyERKNSt6__ndk112basic_stringIcNS1_11char_traitsIcEENS1_9allocatorIcEEEE",
48
+ ];
49
+
50
+ function bytesToHex(buf) {
51
+ if (!buf || buf.byteLength === 0) return "";
52
+ var bytes = new Uint8Array(buf);
53
+ var out = "";
54
+ for (var i = 0; i < bytes.length; i++) {
55
+ var b = bytes[i].toString(16);
56
+ if (b.length < 2) b = "0" + b;
57
+ out += b;
58
+ }
59
+ return out;
60
+ }
61
+
62
+ // Track which symbol fired first; only emit the first key event so
63
+ // the host detaches quickly (anti-detection §18.6 #4).
64
+ var fired = false;
65
+
66
+ // Sig-aware arg index map. The host treats the first 'key' event as
67
+ // authoritative, so picking the wrong index for v2 = host gets the
68
+ // database NAME pointer (e.g. "main") and DB opens fail silently.
69
+ // sqlite3_key(sqlite3 *db, const void *pKey, int nKey)
70
+ // args[0]=db, args[1]=key, args[2]=len
71
+ // sqlite3_key_v2(sqlite3 *db, const char *zDbName, const void *pKey, int nKey)
72
+ // args[0]=db, args[1]=name, args[2]=key, args[3]=len
73
+ // wcdb_setkey / WCDBKeyDerive: unknown sig — assume sqlite3_key shape
74
+ // Mangled C++: WCDB::Database::setCipherKey(*this, const std::string&)
75
+ // args[0]=this, args[1]=&string (length needs .size()) — not handled
76
+ // here; emit error so the host falls back to MD5 path.
77
+ function argIndicesFor(symbolName) {
78
+ if (symbolName === "sqlite3_key_v2") {
79
+ return { key: 2, len: 3, sig: "v2" };
80
+ }
81
+ if (symbolName.indexOf("_ZN4WCDB") === 0) {
82
+ return { key: -1, len: -1, sig: "mangled-cpp" };
83
+ }
84
+ return { key: 1, len: 2, sig: "v1" };
85
+ }
86
+
87
+ // sjqz extract_wechat_key.py uses Memory.readCString(args[1]) for the
88
+ // key — meaning some WeChat builds pass the key as a NUL-terminated
89
+ // 64-char ASCII hex string. Other builds (and the original SQLCipher
90
+ // contract) pass 32 raw bytes. We can disambiguate by `len`:
91
+ // - len === 32 → raw 32-byte key → readByteArray + bytesToHex
92
+ // - len === 64 → ASCII hex string → readCString
93
+ // - anything else → emit error, host falls back to MD5 path
94
+ function makeHook(symbolName) {
95
+ var idx = argIndicesFor(symbolName);
96
+ return {
97
+ onEnter: function (args) {
98
+ if (fired) return;
99
+ if (idx.key < 0) {
100
+ send({
101
+ kind: "error",
102
+ message:
103
+ "unsupported symbol signature: " +
104
+ symbolName +
105
+ " — host should fall back to MD5(IMEI+UIN) key path",
106
+ });
107
+ return;
108
+ }
109
+ try {
110
+ var len = args[idx.len].toInt32();
111
+ if (len <= 0 || len > 256) {
112
+ send({
113
+ kind: "error",
114
+ message:
115
+ "implausible key length " + len + " at " + symbolName,
116
+ });
117
+ return;
118
+ }
119
+ var hex;
120
+ var format;
121
+ if (len === 64) {
122
+ // ASCII hex string (sjqz-verified path on WeChat 7.x/8.0 libWCDB)
123
+ var s = Memory.readCString(args[idx.key], len);
124
+ if (!s || s.length === 0) {
125
+ send({
126
+ kind: "error",
127
+ message: "readCString returned empty at " + symbolName,
128
+ });
129
+ return;
130
+ }
131
+ hex = s.toLowerCase();
132
+ format = "ascii-hex";
133
+ } else if (len === 32) {
134
+ // Raw 32-byte key — convert to 64-char hex
135
+ var buf = args[idx.key].readByteArray(len);
136
+ hex = bytesToHex(buf);
137
+ format = "raw-bytes";
138
+ } else {
139
+ // Ambiguous length — could be either. Emit both interpretations
140
+ // and let the host try each against the DB until one succeeds.
141
+ var bufAmb = args[idx.key].readByteArray(len);
142
+ var hexFromBytes = bytesToHex(bufAmb);
143
+ var hexFromString = null;
144
+ try {
145
+ var sAmb = Memory.readCString(args[idx.key], len);
146
+ if (sAmb) hexFromString = sAmb.toLowerCase();
147
+ } catch (_e) {
148
+ // readCString may fault on non-NUL-terminated bytes; ignore.
149
+ }
150
+ fired = true;
151
+ send({
152
+ kind: "key",
153
+ hex: hexFromBytes,
154
+ alt: hexFromString,
155
+ source: symbolName,
156
+ sig: idx.sig,
157
+ format: "ambiguous",
158
+ length: len,
159
+ });
160
+ return;
161
+ }
162
+ if (!hex) {
163
+ send({
164
+ kind: "error",
165
+ message: "empty key buffer at " + symbolName,
166
+ });
167
+ return;
168
+ }
169
+ fired = true;
170
+ send({
171
+ kind: "key",
172
+ hex: hex,
173
+ source: symbolName,
174
+ sig: idx.sig,
175
+ format: format,
176
+ length: len,
177
+ });
178
+ } catch (e) {
179
+ send({
180
+ kind: "error",
181
+ message:
182
+ "hook exception at " +
183
+ symbolName +
184
+ ": " +
185
+ (e && e.message ? e.message : String(e)),
186
+ });
187
+ }
188
+ },
189
+ };
190
+ }
191
+
192
+ function tryAttachOnModule(moduleName) {
193
+ var mod = Process.findModuleByName(moduleName);
194
+ if (!mod) return false;
195
+ var attached = 0;
196
+ for (var i = 0; i < SYMBOLS.length; i++) {
197
+ var addr = Module.findExportByName(moduleName, SYMBOLS[i]);
198
+ if (!addr) continue;
199
+ try {
200
+ Interceptor.attach(addr, makeHook(SYMBOLS[i]));
201
+ send({ kind: "hooked", symbol: SYMBOLS[i], module: moduleName });
202
+ attached++;
203
+ } catch (e) {
204
+ send({
205
+ kind: "error",
206
+ message:
207
+ "Interceptor.attach failed for " +
208
+ SYMBOLS[i] +
209
+ ": " +
210
+ (e && e.message ? e.message : String(e)),
211
+ });
212
+ }
213
+ }
214
+ return attached > 0;
215
+ }
216
+
217
+ function tryAttach() {
218
+ for (var i = 0; i < TARGET_MODULES.length; i++) {
219
+ if (tryAttachOnModule(TARGET_MODULES[i])) {
220
+ return true;
221
+ }
222
+ }
223
+ return false;
224
+ }
225
+
226
+ // Module-load polling — §18.6 #1 "hook at module-load time before
227
+ // anti-detection thread runs". WeChat lazy-loads libWCDB when the
228
+ // first DB opens, so we can't always find it at script start.
229
+ if (!tryAttach()) {
230
+ send({ kind: "module-waiting", module: TARGET_MODULES.join("|") });
231
+ var attempts = 0;
232
+ var poll = function () {
233
+ attempts++;
234
+ if (tryAttach()) return;
235
+ if (attempts >= 60) {
236
+ // 60 attempts × 500ms = 30s ceiling, matches host timeoutMs
237
+ send({
238
+ kind: "error",
239
+ message:
240
+ TARGET_MODULES.join("|") + " did not load within 30s",
241
+ });
242
+ return;
243
+ }
244
+ setTimeout(poll, 500);
245
+ };
246
+ setTimeout(poll, 500);
247
+ }
248
+ })();
@@ -5,6 +5,9 @@ const { parseContent, parseXmlAttrs, extractTag, isGroupTalker, TYPE_NAMES, APPM
5
5
  const { extractWeChatKey, deriveLegacyKey, extractUinFromPrefs, extractImeiFromCompatibleInfo } = require("./key-extractor");
6
6
  const { WeChatDBReader, KNOWN_PRAGMA_PROFILES } = require("./db-reader");
7
7
  const { normalizeMessage, normalizeContact, wxidToPersonId } = require("./normalize");
8
+ const { KeyProvider, MD5KeyProvider, FridaKeyProvider } = require("./key-providers");
9
+ const envProbe = require("./env-probe");
10
+ const { bootstrapWechatAdapter } = require("./bootstrap");
8
11
 
9
12
  module.exports = {
10
13
  WechatAdapter,
@@ -25,4 +28,10 @@ module.exports = {
25
28
  normalizeWeChatMessage: normalizeMessage,
26
29
  normalizeWeChatContact: normalizeContact,
27
30
  wxidToWeChatPersonId: wxidToPersonId,
31
+ WeChatKeyProvider: KeyProvider,
32
+ WeChatMD5KeyProvider: MD5KeyProvider,
33
+ WeChatFridaKeyProvider: FridaKeyProvider,
34
+ probeWeChatEnv: envProbe.probe,
35
+ decideWeChatKeyProvider: envProbe.decide,
36
+ bootstrapWechatAdapter,
28
37
  };