@chainlesschain/personal-data-hub 0.2.0 → 0.2.1

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 (50) 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 +191 -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/wechat-bootstrap-end-to-end.test.js +390 -0
  22. package/__tests__/registry.test.js +4 -2
  23. package/lib/adapters/ai-chat-history/ai-chat-adapter.js +55 -16
  24. package/lib/adapters/ai-chat-history/cookie-capture-spec.js +331 -0
  25. package/lib/adapters/ai-chat-history/health-checker.js +210 -0
  26. package/lib/adapters/ai-chat-history/schema-map.js +42 -5
  27. package/lib/adapters/ai-chat-history/vendor-spec.js +1 -0
  28. package/lib/adapters/ai-chat-history/vendors/doubao.js +255 -0
  29. package/lib/adapters/ai-chat-history/wizard-controller.js +473 -0
  30. package/lib/adapters/alipay-bill/alipay-bill-adapter.js +4 -0
  31. package/lib/adapters/social-kuaishou/index.js +237 -0
  32. package/lib/adapters/social-toutiao/index.js +236 -0
  33. package/lib/adapters/system-data-android/adapter.js +348 -0
  34. package/lib/adapters/system-data-android/index.js +76 -0
  35. package/lib/adapters/wechat/bootstrap.js +146 -0
  36. package/lib/adapters/wechat/env-probe.js +218 -0
  37. package/lib/adapters/wechat/frida-agent/loader.js +67 -0
  38. package/lib/adapters/wechat/frida-agent/wechat-key-hook.js +126 -0
  39. package/lib/adapters/wechat/index.js +9 -0
  40. package/lib/adapters/wechat/key-providers/frida-key-provider.js +244 -0
  41. package/lib/adapters/wechat/key-providers/index.js +22 -0
  42. package/lib/adapters/wechat/key-providers/key-provider-base.js +44 -0
  43. package/lib/adapters/wechat/key-providers/md5-key-provider.js +81 -0
  44. package/lib/analysis-skills/spending.js +4 -1
  45. package/lib/analysis.js +191 -2
  46. package/lib/index.js +16 -0
  47. package/lib/prompt-builder.js +11 -1
  48. package/lib/query-parser.js +7 -1
  49. package/lib/vault.js +77 -0
  50. package/package.json +8 -1
@@ -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,67 @@
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
+ };
57
+ const ctx = vm.createContext(sandbox);
58
+ const src = loadAgentScript();
59
+ vm.runInContext(src, ctx, { filename: "wechat-key-hook.js" });
60
+ return sandbox;
61
+ }
62
+
63
+ module.exports = {
64
+ AGENT_PATH,
65
+ loadAgentScript,
66
+ runAgentUnderMock,
67
+ };
@@ -0,0 +1,126 @@
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
+ var TARGET_MODULE = "libwcdb.so";
36
+ // Primary symbol per §18.3. Add fallbacks below — version drift will
37
+ // shift the export name; host treats first hit as authoritative.
38
+ var SYMBOLS = [
39
+ "sqlite3_key",
40
+ "sqlite3_key_v2",
41
+ "wcdb_setkey",
42
+ "WCDBKeyDerive",
43
+ // C++-mangled symbols (Itanium ABI) — rare but seen in WCDB 1.x
44
+ "_ZN4WCDB8Database13setCipherKeyERKNSt6__ndk112basic_stringIcNS1_11char_traitsIcEENS1_9allocatorIcEEEE",
45
+ ];
46
+
47
+ function bytesToHex(buf) {
48
+ if (!buf || buf.byteLength === 0) return "";
49
+ var bytes = new Uint8Array(buf);
50
+ var out = "";
51
+ for (var i = 0; i < bytes.length; i++) {
52
+ var b = bytes[i].toString(16);
53
+ if (b.length < 2) b = "0" + b;
54
+ out += b;
55
+ }
56
+ return out;
57
+ }
58
+
59
+ // Track which symbol fired first; only emit the first key event so
60
+ // the host detaches quickly (anti-detection §18.6 #4).
61
+ var fired = false;
62
+
63
+ function makeHook(symbolName) {
64
+ return {
65
+ onEnter: function (args) {
66
+ if (fired) return;
67
+ try {
68
+ // sqlite3_key signature: int sqlite3_key(sqlite3 *db, const void *pKey, int nKey)
69
+ // args[1] = key bytes, args[2] = key length
70
+ var len = args[2].toInt32();
71
+ if (len <= 0 || len > 256) {
72
+ send({ kind: "error", message: "implausible key length " + len + " at " + symbolName });
73
+ return;
74
+ }
75
+ var buf = args[1].readByteArray(len);
76
+ var hex = bytesToHex(buf);
77
+ if (!hex) {
78
+ send({ kind: "error", message: "empty key buffer at " + symbolName });
79
+ return;
80
+ }
81
+ fired = true;
82
+ send({ kind: "key", hex: hex, source: symbolName });
83
+ } catch (e) {
84
+ send({ kind: "error", message: "hook exception at " + symbolName + ": " + (e && e.message ? e.message : String(e)) });
85
+ }
86
+ },
87
+ };
88
+ }
89
+
90
+ function tryAttach() {
91
+ var mod = Process.findModuleByName(TARGET_MODULE);
92
+ if (!mod) return false;
93
+ var attached = 0;
94
+ for (var i = 0; i < SYMBOLS.length; i++) {
95
+ var addr = Module.findExportByName(TARGET_MODULE, SYMBOLS[i]);
96
+ if (!addr) continue;
97
+ try {
98
+ Interceptor.attach(addr, makeHook(SYMBOLS[i]));
99
+ send({ kind: "hooked", symbol: SYMBOLS[i], module: TARGET_MODULE });
100
+ attached++;
101
+ } catch (e) {
102
+ send({ kind: "error", message: "Interceptor.attach failed for " + SYMBOLS[i] + ": " + (e && e.message ? e.message : String(e)) });
103
+ }
104
+ }
105
+ return attached > 0;
106
+ }
107
+
108
+ // Module-load polling — §18.6 #1 "hook at module-load time before
109
+ // anti-detection thread runs". WeChat lazy-loads libwcdb when the
110
+ // first DB opens, so we can't always find it at script start.
111
+ if (!tryAttach()) {
112
+ send({ kind: "module-waiting", module: TARGET_MODULE });
113
+ var attempts = 0;
114
+ var poll = function () {
115
+ attempts++;
116
+ if (tryAttach()) return;
117
+ if (attempts >= 60) {
118
+ // 60 attempts × 500ms = 30s ceiling, matches host timeoutMs
119
+ send({ kind: "error", message: TARGET_MODULE + " did not load within 30s" });
120
+ return;
121
+ }
122
+ setTimeout(poll, 500);
123
+ };
124
+ setTimeout(poll, 500);
125
+ }
126
+ })();
@@ -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
  };
@@ -0,0 +1,244 @@
1
+ /**
2
+ * Phase 12.6.3 — FridaKeyProvider (v1 hot path).
3
+ *
4
+ * Attaches frida to a live WeChat process (com.tencent.mm) on a rooted
5
+ * Android device, injects the wechat-key-hook agent (see
6
+ * frida-agent/wechat-key-hook.js), waits for the first sqlite3_key
7
+ * onEnter, captures the 32-byte hex key, then detaches.
8
+ *
9
+ * Why detach immediately:
10
+ * §18.6 anti-detection — minimize injection window so WeChat's
11
+ * ptrace-tracer / mem-scanner doesn't catch frida-gum sitting in
12
+ * the process. We hold the script alive only as long as it takes
13
+ * the user to touch a chat thread (typically 1-3s).
14
+ *
15
+ * Wire to KeyProvider:
16
+ * getKey() resolves with lowercase 64-char hex on success, or
17
+ * rejects with one of the typed error codes:
18
+ * - FRIDA_BINDING_MISSING : opts.frida not provided and require()
19
+ * of "frida" failed (binding not installed)
20
+ * - WECHAT_NOT_RUNNING : device.attach() threw on package name
21
+ * - FRIDA_ATTACH_FAILED : any other attach/createScript error
22
+ * - HOOK_FAILED : agent reported error event before key
23
+ * - WCDB_KEY_TIMEOUT : no key event within timeoutMs
24
+ *
25
+ * Test seam: opts.frida overrides the lazy require("frida"), so unit
26
+ * tests inject a mock device manager without touching the real binding.
27
+ */
28
+ "use strict";
29
+
30
+ const { KeyProvider } = require("./key-provider-base");
31
+ const { loadAgentScript } = require("../frida-agent/loader");
32
+
33
+ class FridaKeyProvider extends KeyProvider {
34
+ /**
35
+ * @param {object} opts
36
+ * @param {object} [opts.frida] injected nodejs binding (test seam);
37
+ * if absent, lazy require("frida")
38
+ * @param {string} [opts.deviceId] Frida device id (USB device default
39
+ * if omitted; "local" for Wear/host)
40
+ * @param {string} [opts.packageName="com.tencent.mm"]
41
+ * @param {number} [opts.timeoutMs=30000]
42
+ * @param {Function} [opts.agentLoader] test seam: returns agent script
43
+ * text; defaults to loadAgentScript
44
+ * @param {Function} [opts.logger] optional log({level, ...evt})
45
+ */
46
+ constructor(opts = {}) {
47
+ super();
48
+ if (!opts || typeof opts !== "object") {
49
+ throw new Error("FridaKeyProvider: opts required");
50
+ }
51
+ this._fridaInjected = opts.frida || null;
52
+ this._deviceId = opts.deviceId || null;
53
+ this._packageName = opts.packageName || "com.tencent.mm";
54
+ this._timeoutMs = Number.isFinite(opts.timeoutMs) && opts.timeoutMs > 0
55
+ ? opts.timeoutMs
56
+ : 30_000;
57
+ this._agentLoader = typeof opts.agentLoader === "function"
58
+ ? opts.agentLoader
59
+ : loadAgentScript;
60
+ this._logger = typeof opts.logger === "function" ? opts.logger : null;
61
+ this._lastTelemetry = null;
62
+ }
63
+
64
+ get name() {
65
+ return "frida";
66
+ }
67
+
68
+ getLastTelemetry() {
69
+ return this._lastTelemetry;
70
+ }
71
+
72
+ _log(evt) {
73
+ if (this._logger) {
74
+ try { this._logger(evt); } catch (_e) { /* swallow logger faults */ }
75
+ }
76
+ }
77
+
78
+ _loadFrida() {
79
+ if (this._fridaInjected) return this._fridaInjected;
80
+ try {
81
+ // eslint-disable-next-line global-require
82
+ return require("frida");
83
+ } catch (err) {
84
+ const e = new Error(
85
+ "FridaKeyProvider: frida nodejs binding not installed. " +
86
+ "Install with `npm install frida` on the host, or pass opts.frida. " +
87
+ "Underlying error: " + (err && err.message ? err.message : String(err))
88
+ );
89
+ e.code = "FRIDA_BINDING_MISSING";
90
+ throw e;
91
+ }
92
+ }
93
+
94
+ async _getDevice(frida) {
95
+ if (this._deviceId) {
96
+ const dev = await frida.getDevice(this._deviceId);
97
+ return dev;
98
+ }
99
+ // No id → first USB device
100
+ if (typeof frida.getUsbDevice === "function") {
101
+ return await frida.getUsbDevice();
102
+ }
103
+ return await frida.getDeviceManager().getUsbDevice();
104
+ }
105
+
106
+ /**
107
+ * @returns {Promise<string>} 64-char lowercase hex SQLCipher key
108
+ */
109
+ async getKey(_callOpts) {
110
+ const telemetry = {
111
+ startedAt: Date.now(),
112
+ packageName: this._packageName,
113
+ deviceId: this._deviceId,
114
+ hooked: [],
115
+ errors: [],
116
+ keySource: null,
117
+ durationMs: null,
118
+ };
119
+
120
+ const frida = this._loadFrida();
121
+ let device, session, script;
122
+
123
+ try {
124
+ device = await this._getDevice(frida);
125
+ } catch (err) {
126
+ const e = new Error(
127
+ "FridaKeyProvider: failed to acquire Frida device" +
128
+ (this._deviceId ? ` (${this._deviceId})` : "") +
129
+ ": " + (err && err.message ? err.message : String(err))
130
+ );
131
+ e.code = "FRIDA_ATTACH_FAILED";
132
+ this._lastTelemetry = telemetry;
133
+ throw e;
134
+ }
135
+
136
+ try {
137
+ session = await device.attach(this._packageName);
138
+ } catch (err) {
139
+ const errMsg = err && err.message ? err.message : String(err);
140
+ const e = new Error(
141
+ `FridaKeyProvider: device.attach(${this._packageName}) failed: ${errMsg}`
142
+ );
143
+ // Distinguish "process not found" vs other attach errors
144
+ e.code = /unable to find process|process not found/i.test(errMsg)
145
+ ? "WECHAT_NOT_RUNNING"
146
+ : "FRIDA_ATTACH_FAILED";
147
+ this._lastTelemetry = telemetry;
148
+ throw e;
149
+ }
150
+
151
+ try {
152
+ const agentSrc = this._agentLoader();
153
+ script = await session.createScript(agentSrc);
154
+ } catch (err) {
155
+ const e = new Error(
156
+ "FridaKeyProvider: createScript failed: " +
157
+ (err && err.message ? err.message : String(err))
158
+ );
159
+ e.code = "FRIDA_ATTACH_FAILED";
160
+ this._lastTelemetry = telemetry;
161
+ // Clean up the session before throwing
162
+ try { await session.detach(); } catch (_e) {}
163
+ throw e;
164
+ }
165
+
166
+ // Promise resolves on the first 'key' message; rejects on the first
167
+ // 'error' (after script load) or after timeoutMs without key.
168
+ const keyHex = await new Promise((resolve, reject) => {
169
+ let settled = false;
170
+ let timer = null;
171
+
172
+ const cleanup = async () => {
173
+ if (timer) { clearTimeout(timer); timer = null; }
174
+ try { await script.unload(); } catch (_e) {}
175
+ try { await session.detach(); } catch (_e) {}
176
+ };
177
+
178
+ const onMessage = (message, _data) => {
179
+ if (settled) return;
180
+ if (!message || message.type !== "send" || !message.payload) return;
181
+ const evt = message.payload;
182
+ this._log({ level: "info", kind: "frida-message", evt });
183
+
184
+ if (evt.kind === "hooked") {
185
+ telemetry.hooked.push({ symbol: evt.symbol, module: evt.module });
186
+ return;
187
+ }
188
+ if (evt.kind === "module-waiting") {
189
+ return; // informational
190
+ }
191
+ if (evt.kind === "key") {
192
+ settled = true;
193
+ telemetry.keySource = evt.source;
194
+ telemetry.durationMs = Date.now() - telemetry.startedAt;
195
+ cleanup().then(() => resolve(String(evt.hex || "").toLowerCase()));
196
+ return;
197
+ }
198
+ if (evt.kind === "error") {
199
+ telemetry.errors.push(evt.message);
200
+ // Don't reject on individual hook errors; we may still get a
201
+ // key from a fallback symbol. Only reject on timeout.
202
+ return;
203
+ }
204
+ };
205
+
206
+ script.message.connect(onMessage);
207
+
208
+ script.load().catch((err) => {
209
+ if (settled) return;
210
+ settled = true;
211
+ cleanup().then(() => {
212
+ const e = new Error(
213
+ "FridaKeyProvider: script.load failed: " +
214
+ (err && err.message ? err.message : String(err))
215
+ );
216
+ e.code = "FRIDA_ATTACH_FAILED";
217
+ reject(e);
218
+ });
219
+ });
220
+
221
+ timer = setTimeout(() => {
222
+ if (settled) return;
223
+ settled = true;
224
+ cleanup().then(() => {
225
+ const last = telemetry.errors.length > 0
226
+ ? ` (last hook error: ${telemetry.errors[telemetry.errors.length - 1]})`
227
+ : "";
228
+ const e = new Error(
229
+ `FridaKeyProvider: no sqlite3_key call within ${this._timeoutMs}ms` +
230
+ (telemetry.hooked.length === 0 ? " — libwcdb.so never loaded; " +
231
+ "did the user touch a chat thread?" : "") + last
232
+ );
233
+ e.code = "WCDB_KEY_TIMEOUT";
234
+ reject(e);
235
+ });
236
+ }, this._timeoutMs);
237
+ });
238
+
239
+ this._lastTelemetry = telemetry;
240
+ return keyHex;
241
+ }
242
+ }
243
+
244
+ module.exports = { FridaKeyProvider };
@@ -0,0 +1,22 @@
1
+ "use strict";
2
+
3
+ const { KeyProvider } = require("./key-provider-base");
4
+ const { MD5KeyProvider } = require("./md5-key-provider");
5
+
6
+ // FridaKeyProvider depends on the optional `frida` nodejs binding. Load
7
+ // lazily so users on devices without the binding can still use the v0.5
8
+ // MD5 path. Phase 12.6.3 ships the implementation.
9
+ let FridaKeyProvider = null;
10
+ try {
11
+ // eslint-disable-next-line global-require
12
+ ({ FridaKeyProvider } = require("./frida-key-provider"));
13
+ } catch (_e) {
14
+ // Module not yet built / frida binding missing — leave null. Callers
15
+ // that need it should require it directly so they see the real error.
16
+ }
17
+
18
+ module.exports = {
19
+ KeyProvider,
20
+ MD5KeyProvider,
21
+ FridaKeyProvider,
22
+ };