@chainlesschain/personal-data-hub 0.3.9 → 0.4.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.
Files changed (57) hide show
  1. package/__tests__/adapters/apple-health.test.js +95 -0
  2. package/__tests__/adapters/email-templates.test.js +123 -0
  3. package/__tests__/adapters/family-23-collectors-scaffold.test.js +178 -0
  4. package/__tests__/adapters/game-genshin-scaffold.test.js +107 -0
  5. package/__tests__/adapters/git-activity.test.js +7 -1
  6. package/__tests__/adapters/local-im-pc.test.js +149 -0
  7. package/__tests__/adapters/netease-music.test.js +74 -0
  8. package/__tests__/adapters/qq-pc-direct-read.test.js +186 -0
  9. package/__tests__/adapters/system-data-adapter.test.js +4 -1
  10. package/__tests__/adapters/wechat-pc-direct-read.test.js +207 -0
  11. package/__tests__/adapters/weread.test.js +123 -0
  12. package/__tests__/analysis.test.js +120 -15
  13. package/__tests__/mobile-extractor-encrypted.test.js +460 -0
  14. package/__tests__/prompt-builder.test.js +25 -0
  15. package/__tests__/registry-readiness.test.js +233 -0
  16. package/__tests__/social-douyin-im-direct-read.test.js +311 -0
  17. package/__tests__/social-douyin-snapshot.test.js +5 -2
  18. package/__tests__/vault.test.js +99 -0
  19. package/lib/adapter-guide.js +520 -0
  20. package/lib/adapter-readiness.js +257 -0
  21. package/lib/adapters/_local-im-db-reader.js +218 -0
  22. package/lib/adapters/_local-im-pc-adapter.js +162 -0
  23. package/lib/adapters/apple-health/index.js +329 -0
  24. package/lib/adapters/dingtalk-pc/index.js +29 -0
  25. package/lib/adapters/edu-huawei-learning/api-client.js +47 -0
  26. package/lib/adapters/edu-huawei-learning/index.js +255 -0
  27. package/lib/adapters/edu-zuoyebang/api-client.js +48 -0
  28. package/lib/adapters/edu-zuoyebang/index.js +259 -0
  29. package/lib/adapters/email-imap/email-adapter.js +16 -0
  30. package/lib/adapters/email-imap/templates/bill.js +174 -18
  31. package/lib/adapters/feishu-pc/index.js +29 -0
  32. package/lib/adapters/finance-alipay/api-client.js +48 -0
  33. package/lib/adapters/finance-alipay/index.js +257 -0
  34. package/lib/adapters/game-genshin/api-client.js +59 -0
  35. package/lib/adapters/game-genshin/index.js +274 -0
  36. package/lib/adapters/game-honor-of-kings/api-client.js +54 -0
  37. package/lib/adapters/game-honor-of-kings/index.js +259 -0
  38. package/lib/adapters/netease-music/index.js +227 -0
  39. package/lib/adapters/qq-pc/index.js +200 -0
  40. package/lib/adapters/qq-pc/nt-db-reader.js +210 -0
  41. package/lib/adapters/social-douyin/index.js +194 -1
  42. package/lib/adapters/wechat/wechat-adapter.js +7 -1
  43. package/lib/adapters/wechat-pc/index.js +335 -0
  44. package/lib/adapters/wechat-pc/pc-db-reader.js +327 -0
  45. package/lib/adapters/weread/api-client.js +128 -0
  46. package/lib/adapters/weread/index.js +337 -0
  47. package/lib/analysis.js +65 -0
  48. package/lib/index.js +39 -0
  49. package/lib/mobile-extractor/bplist.js +233 -0
  50. package/lib/mobile-extractor/ios-backup-crypto.js +315 -0
  51. package/lib/mobile-extractor/ios.js +131 -16
  52. package/lib/prompt-builder.js +11 -1
  53. package/lib/registry.js +170 -0
  54. package/lib/vault.js +105 -0
  55. package/package.json +1 -1
  56. package/scripts/run-native-tests-sandbox.sh +2 -0
  57. package/vitest.config.js +79 -1
@@ -354,6 +354,75 @@ describe("AnalysisEngine emits TOTALS preamble", () => {
354
354
  });
355
355
  });
356
356
 
357
+ // ─── intent=sum-amount Phase 2 — AMOUNT_SUM authoritative total ──────────
358
+ describe("AnalysisEngine emits AMOUNT_SUM preamble (intent=sum-amount Phase 2)", () => {
359
+ const baseVault = (over) => ({
360
+ queryEvents: () => [],
361
+ queryPersons: () => [],
362
+ queryItems: () => [],
363
+ stats: () => ({ events: 5, persons: 0, places: 0, items: 0, topics: 0 }),
364
+ getEvent: () => null,
365
+ audit: () => {},
366
+ ...over,
367
+ });
368
+ const captureLlm = (calls) => ({
369
+ isLocal: true,
370
+ chat: async (msgs) => {
371
+ calls.push(msgs);
372
+ return { text: "ok", usage: {} };
373
+ },
374
+ });
375
+
376
+ it("calls sumEventAmount for sum-amount intent and puts AMOUNT_SUM in prompt", async () => {
377
+ const sumCalls = [];
378
+ const fakeVault = baseVault({
379
+ sumEventAmount: (f) => {
380
+ sumCalls.push(f);
381
+ return { total: 888.8, currency: "CNY", count: 5, byDirection: { out: 888.8, in: 0 } };
382
+ },
383
+ });
384
+ const chatCalls = [];
385
+ const engine = new AnalysisEngine({ vault: fakeVault, llm: captureLlm(chatCalls) });
386
+ await engine.ask("我总共花了多少钱");
387
+ expect(sumCalls.length).toBe(1);
388
+ const userMsg = chatCalls[0][1].content;
389
+ expect(userMsg).toContain("AMOUNT_SUM");
390
+ expect(userMsg).toContain('"total": 888.8');
391
+ expect(chatCalls[0][0].content).toMatch(/AMOUNT_SUM.*authoritative/i);
392
+ });
393
+
394
+ it("does NOT call sumEventAmount for non-sum-amount intent", async () => {
395
+ const sumCalls = [];
396
+ const fakeVault = baseVault({
397
+ sumEventAmount: (f) => {
398
+ sumCalls.push(f);
399
+ return { total: 0, currency: "CNY", count: 0, byDirection: { out: 0, in: 0 } };
400
+ },
401
+ });
402
+ const engine = new AnalysisEngine({ vault: fakeVault, llm: captureLlm([]) });
403
+ await engine.ask("列出我的联系人"); // intent=list
404
+ expect(sumCalls.length).toBe(0);
405
+ });
406
+
407
+ it("omits AMOUNT_SUM block when sumEventAmount returns count 0", async () => {
408
+ const fakeVault = baseVault({
409
+ sumEventAmount: () => ({ total: 0, currency: "CNY", count: 0, byDirection: { out: 0, in: 0 } }),
410
+ });
411
+ const chatCalls = [];
412
+ const engine = new AnalysisEngine({ vault: fakeVault, llm: captureLlm(chatCalls) });
413
+ await engine.ask("我总共花了多少钱");
414
+ expect(chatCalls[0][1].content).not.toContain("AMOUNT_SUM");
415
+ });
416
+
417
+ it("legacy vault without sumEventAmount falls back gracefully", async () => {
418
+ const fakeVault = baseVault({}); // no sumEventAmount
419
+ const chatCalls = [];
420
+ const engine = new AnalysisEngine({ vault: fakeVault, llm: captureLlm(chatCalls) });
421
+ await engine.ask("我总共花了多少钱");
422
+ expect(chatCalls[0][1].content).not.toContain("AMOUNT_SUM");
423
+ });
424
+ });
425
+
357
426
  // ─── Cache bypass — PDH ask must always go to LLM, never cached ───────
358
427
  //
359
428
  // Bug 2026-05-21: desktop ResponseCache (7-day TTL) served a stale
@@ -1569,9 +1638,15 @@ describe("AnalysisEngine._gatherFacts intent=sum-amount routing", () => {
1569
1638
  // 2026-05-24 — `intent=count` ("几个 X" / "多少个 Y") is handled by the
1570
1639
  // TOTALS preamble (commit 19c11920e): vault.stats() is rendered before
1571
1640
  // FACTS so the LLM quotes the real number instead of FACTS array length.
1572
- // FACTS itself still goes through the default broader path (no narrow
1573
- // routing). This block isolates the count-specific behavior into its
1574
- // own describe so the audit gap is closed.
1641
+ //
1642
+ // 2026-06-02 FACTS now ALSO hard-caps to COUNT_INTENT_FACT_LIMIT (5)
1643
+ // illustrative rows instead of the full ≤80 default sample: TOTALS already
1644
+ // carries the authoritative count (Rule 6), so a count question only needs a
1645
+ // few examples — saves prompt budget on local small models. Scoped by reliable
1646
+ // adapter+time filters; persons/items skipped (count-of-contacts/apps routes
1647
+ // via entityFocus). 0 hits → fall through to the default broader path (safety
1648
+ // net for a count misclassification of a list question). Memory:
1649
+ // pdh_analysis_engine_intent_routing.md.
1575
1650
 
1576
1651
  describe("AnalysisEngine._gatherFacts intent=count routing", () => {
1577
1652
  const mkEvent = (id, subtype = "order", adapter = "taobao") => ({
@@ -1580,28 +1655,56 @@ describe("AnalysisEngine._gatherFacts intent=count routing", () => {
1580
1655
  source: { adapter, adapterVersion: "0", capturedAt: Date.now(), capturedBy: "api" },
1581
1656
  });
1582
1657
 
1583
- it("(a) intent=count goes through default broader path (no narrow query)", async () => {
1658
+ it("(a) intent=count ≤5 illustrative events (capped), persons/items NOT queried", async () => {
1659
+ const queryEventsCalls = [];
1660
+ const fakeVault = {
1661
+ queryEvents: (q) => {
1662
+ queryEventsCalls.push(q);
1663
+ return Array.from({ length: 20 }, (_, i) => mkEvent("e-" + i)).slice(0, q.limit);
1664
+ },
1665
+ queryPersons: vi.fn(() => []),
1666
+ queryItems: vi.fn(() => []),
1667
+ getEvent: () => null,
1668
+ audit: () => {},
1669
+ stats: () => ({ events: 20, persons: 0, places: 0, items: 0, topics: 0 }),
1670
+ };
1671
+ const llm = new MockLLMClient({ reply: "ok" });
1672
+ const engine = new AnalysisEngine({ vault: fakeVault, llm });
1673
+ const r = await engine.ask("我有多少个订单");
1674
+
1675
+ expect(r.parsed.intent).toBe("count");
1676
+ // Capped to COUNT_INTENT_FACT_LIMIT (5), NOT the old default 200 — TOTALS
1677
+ // carries the authoritative count, FACTS is just a few examples.
1678
+ expect(queryEventsCalls).toHaveLength(1);
1679
+ expect(queryEventsCalls[0].limit).toBe(5);
1680
+ expect(queryEventsCalls[0].subtype).toBeUndefined(); // subtype NOT passed (unreliable)
1681
+ expect(r.facts).toHaveLength(5);
1682
+ // count-of-events doesn't need contacts/apps — skipped (those route via entityFocus).
1683
+ expect(fakeVault.queryPersons).not.toHaveBeenCalled();
1684
+ expect(fakeVault.queryItems).not.toHaveBeenCalled();
1685
+ });
1686
+
1687
+ it("(a2) intent=count with adapter scope → adapter passed through on the capped query", async () => {
1584
1688
  const queryEventsCalls = [];
1585
1689
  const fakeVault = {
1586
1690
  queryEvents: (q) => {
1587
1691
  queryEventsCalls.push(q);
1588
- return [mkEvent("e-1"), mkEvent("e-2")];
1692
+ return [mkEvent("e-1")];
1589
1693
  },
1590
1694
  queryPersons: () => [],
1591
1695
  queryItems: () => [],
1592
1696
  getEvent: () => null,
1593
1697
  audit: () => {},
1594
- stats: () => ({ events: 2, persons: 500, places: 0, items: 0, topics: 0 }),
1698
+ stats: () => ({ events: 1, persons: 0, places: 0, items: 0, topics: 0 }),
1595
1699
  };
1596
- const llm = new MockLLMClient({ reply: "你有 500 个联系人" });
1700
+ const llm = new MockLLMClient({ reply: "ok" });
1597
1701
  const engine = new AnalysisEngine({ vault: fakeVault, llm });
1598
- const r = await engine.ask("我有几个联系人");
1702
+ const r = await engine.ask("我在淘宝有多少个订单");
1599
1703
 
1600
1704
  expect(r.parsed.intent).toBe("count");
1601
- // Single default queryEvents call (limit=200, no subtype filter, no narrow).
1602
1705
  expect(queryEventsCalls).toHaveLength(1);
1603
- expect(queryEventsCalls[0].limit).toBe(200);
1604
- expect(queryEventsCalls[0].subtype).toBeUndefined();
1706
+ expect(queryEventsCalls[0].limit).toBe(5);
1707
+ expect(queryEventsCalls[0].adapter).toBe("taobao");
1605
1708
  });
1606
1709
 
1607
1710
  it("(b) intent=count emits TOTALS block in prompt (authoritative ground truth)", async () => {
@@ -1661,12 +1764,14 @@ describe("AnalysisEngine._gatherFacts intent=count routing", () => {
1661
1764
  const llm = new MockLLMClient({ reply: "ok" });
1662
1765
  const engine = new AnalysisEngine({ vault: fakeVault, llm });
1663
1766
  await engine.ask("几个订单");
1664
- // Single default call NOT 4 subtype calls (those are sum-amount only).
1665
- expect(queryEventsCalls).toHaveLength(1);
1666
- expect(queryEventsCalls[0].subtype).toBeUndefined();
1767
+ // count branch (limit 5, 0 hits) fall through to default (limit 200).
1768
+ // Neither call carries a subtype filter — NOT the 4 subtype-narrowed calls
1769
+ // that are sum-amount only.
1770
+ expect(queryEventsCalls.map((q) => q.limit)).toEqual([5, 200]);
1771
+ expect(queryEventsCalls.every((q) => q.subtype === undefined)).toBe(true);
1667
1772
  });
1668
1773
 
1669
- it("(e) intent=count pulls persons + items in FACTS (default path behavior)", async () => {
1774
+ it("(e) intent=count with 0 events falls through → persons + items in FACTS (safety net)", async () => {
1670
1775
  const fakeVault = {
1671
1776
  queryEvents: () => [],
1672
1777
  queryPersons: ({ limit }) => Array.from({ length: Math.min(limit, 5) }, (_, i) => ({
@@ -0,0 +1,460 @@
1
+ "use strict";
2
+
3
+ import { describe, it, expect, afterEach } from "vitest";
4
+
5
+ const fs = require("node:fs");
6
+ const path = require("node:path");
7
+ const os = require("node:os");
8
+ const crypto = require("node:crypto");
9
+
10
+ const {
11
+ parseKeybag,
12
+ deriveBackupKey,
13
+ aesUnwrap,
14
+ aesWrap,
15
+ unwrapClassKeys,
16
+ unwrapEncryptionKey,
17
+ decryptCBC,
18
+ encryptCBC,
19
+ } = require("../lib/mobile-extractor/ios-backup-crypto");
20
+ const { parseBplist, unwrapNSKeyedArchiver, UID } = require("../lib/mobile-extractor/bplist");
21
+ const { iOSBackupReader } = require("../lib/mobile-extractor");
22
+
23
+ // ─── test helpers: keybag TLV + bplist00 encoder ─────────────────────────
24
+
25
+ function tlv(tag, value) {
26
+ const header = Buffer.alloc(8);
27
+ header.write(tag, 0, "ascii");
28
+ header.writeUInt32BE(value.length, 4);
29
+ return Buffer.concat([header, value]);
30
+ }
31
+
32
+ function beInt(n, len) {
33
+ const b = Buffer.alloc(len);
34
+ for (let i = len - 1; i >= 0; i--) { b[i] = n & 0xff; n = Math.floor(n / 256); }
35
+ return b;
36
+ }
37
+
38
+ // Minimal bplist00 encoder — mirrors the subset our parser reads. UID
39
+ // instances encode as UID objects; Buffers as <data>; strings/ints/bools/
40
+ // arrays/dicts as expected. No dedup needed for fixtures.
41
+ function buildBplist(root) {
42
+ const objects = [];
43
+ const objIndex = new Map(); // identity for collections/buffers/UID
44
+ const primIndex = new Map(); // value-key for primitives
45
+
46
+ function assign(node) {
47
+ if (node === null || typeof node === "boolean" || typeof node === "number" || typeof node === "string") {
48
+ const k = `${typeof node}:${String(node)}`;
49
+ if (primIndex.has(k)) return primIndex.get(k);
50
+ const i = objects.length; objects.push(node); primIndex.set(k, i); return i;
51
+ }
52
+ if (objIndex.has(node)) return objIndex.get(node);
53
+ const i = objects.length; objects.push(node); objIndex.set(node, i);
54
+ if (Array.isArray(node)) { node.forEach(assign); }
55
+ else if (node instanceof UID || Buffer.isBuffer(node)) { /* leaf */ }
56
+ else if (typeof node === "object") { for (const [k, v] of Object.entries(node)) { assign(k); assign(v); } }
57
+ return i;
58
+ }
59
+ assign(root);
60
+
61
+ const refSize = objects.length < 256 ? 1 : 2;
62
+ const encoded = [];
63
+ for (const node of objects) encoded.push(encodeObj(node, refSize, assign));
64
+
65
+ const header = Buffer.from("bplist00", "ascii");
66
+ const body = Buffer.concat([header, ...encoded]);
67
+ const offsets = [];
68
+ let acc = header.length;
69
+ for (const e of encoded) { offsets.push(acc); acc += e.length; }
70
+
71
+ const offsetSize = body.length < 256 ? 1 : 2;
72
+ const offsetTable = Buffer.concat(offsets.map((o) => beInt(o, offsetSize)));
73
+ const offsetTableOffset = body.length;
74
+
75
+ const trailer = Buffer.alloc(32);
76
+ trailer.writeUInt8(offsetSize, 6);
77
+ trailer.writeUInt8(refSize, 7);
78
+ trailer.writeBigUInt64BE(BigInt(objects.length), 8);
79
+ trailer.writeBigUInt64BE(BigInt(0), 16); // top object is index 0 (root)
80
+ trailer.writeBigUInt64BE(BigInt(offsetTableOffset), 24);
81
+
82
+ return Buffer.concat([body, offsetTable, trailer]);
83
+ }
84
+
85
+ function encodeObj(node, refSize, assign) {
86
+ if (node === null) return Buffer.from([0x00]);
87
+ if (node === false) return Buffer.from([0x08]);
88
+ if (node === true) return Buffer.from([0x09]);
89
+ if (typeof node === "number" && Number.isInteger(node)) {
90
+ if (node >= 0 && node < 256) return Buffer.from([0x10, node]);
91
+ if (node >= 0 && node < 65536) return Buffer.concat([Buffer.from([0x11]), beInt(node, 2)]);
92
+ return Buffer.concat([Buffer.from([0x12]), beInt(node, 4)]);
93
+ }
94
+ if (typeof node === "string") {
95
+ const buf = Buffer.from(node, "ascii");
96
+ return Buffer.concat([marker(0x50, buf.length), buf]);
97
+ }
98
+ if (Buffer.isBuffer(node)) {
99
+ return Buffer.concat([marker(0x40, node.length), node]);
100
+ }
101
+ if (node instanceof UID) {
102
+ return Buffer.concat([Buffer.from([0x80]), beInt(node.UID, 1)]);
103
+ }
104
+ if (Array.isArray(node)) {
105
+ const refs = Buffer.concat(node.map((c) => beInt(assign(c), refSize)));
106
+ return Buffer.concat([marker(0xa0, node.length), refs]);
107
+ }
108
+ // dict
109
+ const entries = Object.entries(node);
110
+ const keyRefs = Buffer.concat(entries.map(([k]) => beInt(assign(k), refSize)));
111
+ const valRefs = Buffer.concat(entries.map(([, v]) => beInt(assign(v), refSize)));
112
+ return Buffer.concat([marker(0xd0, entries.length), keyRefs, valRefs]);
113
+ }
114
+
115
+ function marker(base, count) {
116
+ if (count < 15) return Buffer.from([base | count]);
117
+ return Buffer.concat([Buffer.from([base | 0x0f]), Buffer.from([0x11]), beInt(count, 2)]);
118
+ }
119
+
120
+ // ─── RFC 3394 AES key wrap/unwrap — official test vectors ────────────────
121
+
122
+ describe("ios-backup-crypto — RFC 3394 AES key wrap", () => {
123
+ const kek256 = Buffer.from("000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F", "hex");
124
+
125
+ it("unwraps the RFC 3394 §4.5 vector (256-bit KEK, 128-bit key)", () => {
126
+ const wrapped = Buffer.from("64E8C3F9CE0F5BA263E9777905818A2A93C8191E7D6E8AE7", "hex");
127
+ const key = aesUnwrap(kek256, wrapped);
128
+ expect(key.toString("hex").toUpperCase()).toBe("00112233445566778899AABBCCDDEEFF");
129
+ });
130
+
131
+ it("unwraps the RFC 3394 §4.6 vector (256-bit KEK, 256-bit key)", () => {
132
+ const wrapped = Buffer.from(
133
+ "28C9F404C4B810F4CBCCB35CFB87F8263F5786E2D80ED326CBC7F0E71A99F43BFB988B9B7A02DD21",
134
+ "hex",
135
+ );
136
+ const key = aesUnwrap(kek256, wrapped);
137
+ expect(key.toString("hex").toUpperCase()).toBe(
138
+ "00112233445566778899AABBCCDDEEFF000102030405060708090A0B0C0D0E0F",
139
+ );
140
+ });
141
+
142
+ it("wrap is the exact inverse of unwrap (matches RFC ciphertext)", () => {
143
+ const key = Buffer.from("00112233445566778899AABBCCDDEEFF", "hex");
144
+ const wrapped = aesWrap(kek256, key);
145
+ expect(wrapped.toString("hex").toUpperCase()).toBe("64E8C3F9CE0F5BA263E9777905818A2A93C8191E7D6E8AE7");
146
+ expect(aesUnwrap(kek256, wrapped).equals(key)).toBe(true);
147
+ });
148
+
149
+ it("rejects a wrapped key tampered with the wrong KEK (integrity check)", () => {
150
+ const wrapped = aesWrap(kek256, Buffer.alloc(32, 7));
151
+ const wrongKek = Buffer.alloc(32, 9);
152
+ expect(() => aesUnwrap(wrongKek, wrapped)).toThrow(/integrity check failed/);
153
+ });
154
+ });
155
+
156
+ // ─── keybag parse + key derivation ───────────────────────────────────────
157
+
158
+ describe("ios-backup-crypto — keybag + derivation", () => {
159
+ function buildKeybag({ salt, iter, dpsl, dpic, classNum, wpky }) {
160
+ const parts = [
161
+ tlv("VERS", beInt(4, 4)),
162
+ tlv("TYPE", beInt(1, 4)),
163
+ tlv("UUID", crypto.randomBytes(16)), // header uuid
164
+ tlv("HMCK", crypto.randomBytes(40)),
165
+ tlv("WRAP", beInt(0, 4)),
166
+ tlv("SALT", salt),
167
+ tlv("ITER", beInt(iter, 4)),
168
+ ];
169
+ if (dpsl) { parts.push(tlv("DPSL", dpsl)); parts.push(tlv("DPIC", beInt(dpic, 4))); }
170
+ // class-key block
171
+ parts.push(tlv("UUID", crypto.randomBytes(16)));
172
+ parts.push(tlv("CLAS", beInt(classNum, 4)));
173
+ parts.push(tlv("WRAP", beInt(2, 4))); // WRAP_PASSCODE
174
+ parts.push(tlv("WPKY", wpky));
175
+ parts.push(tlv("KTYP", beInt(0, 4)));
176
+ return Buffer.concat(parts);
177
+ }
178
+
179
+ it("parses header attrs + a passcode-wrapped class key", () => {
180
+ const salt = crypto.randomBytes(20);
181
+ const blob = buildKeybag({ salt, iter: 1000, classNum: 4, wpky: Buffer.alloc(40, 1) });
182
+ const { attrs, classKeys } = parseKeybag(blob);
183
+ expect(attrs.ITER).toBe(1000);
184
+ expect(Buffer.isBuffer(attrs.SALT)).toBe(true);
185
+ expect(attrs.SALT.equals(salt)).toBe(true);
186
+ expect(classKeys[4]).toBeDefined();
187
+ expect(classKeys[4].WRAP).toBe(2);
188
+ expect(classKeys[4].WPKY.length).toBe(40);
189
+ });
190
+
191
+ it("single-PBKDF2 derivation + class-key unwrap round-trips", () => {
192
+ const salt = crypto.randomBytes(20);
193
+ const classKey = crypto.randomBytes(32);
194
+ // derive with the SAME params the keybag advertises
195
+ const attrsForDerive = { SALT: salt, ITER: 1000 };
196
+ const backupKey = deriveBackupKey("hunter2", attrsForDerive);
197
+ const wpky = aesWrap(backupKey, classKey);
198
+ const blob = buildKeybag({ salt, iter: 1000, classNum: 4, wpky });
199
+ const { attrs, classKeys } = parseKeybag(blob);
200
+ unwrapClassKeys(classKeys, deriveBackupKey("hunter2", attrs));
201
+ expect(classKeys[4].KEY.equals(classKey)).toBe(true);
202
+ });
203
+
204
+ it("double-PBKDF2 (iOS 10.2+ DPSL/DPIC) derivation round-trips", () => {
205
+ const salt = crypto.randomBytes(20);
206
+ const dpsl = crypto.randomBytes(20);
207
+ const classKey = crypto.randomBytes(32);
208
+ const backupKey = deriveBackupKey("pw", { SALT: salt, ITER: 1000, DPSL: dpsl, DPIC: 2000 });
209
+ const wpky = aesWrap(backupKey, classKey);
210
+ const blob = buildKeybag({ salt, iter: 1000, dpsl, dpic: 2000, classNum: 4, wpky });
211
+ const { attrs, classKeys } = parseKeybag(blob);
212
+ unwrapClassKeys(classKeys, deriveBackupKey("pw", attrs));
213
+ expect(classKeys[4].KEY.equals(classKey)).toBe(true);
214
+ });
215
+
216
+ it("wrong password fails the class-key integrity check", () => {
217
+ const salt = crypto.randomBytes(20);
218
+ const classKey = crypto.randomBytes(32);
219
+ const backupKey = deriveBackupKey("right", { SALT: salt, ITER: 1000 });
220
+ const blob = buildKeybag({ salt, iter: 1000, classNum: 4, wpky: aesWrap(backupKey, classKey) });
221
+ const { attrs, classKeys } = parseKeybag(blob);
222
+ expect(() => unwrapClassKeys(classKeys, deriveBackupKey("wrong", attrs))).toThrow(/integrity check/);
223
+ });
224
+ });
225
+
226
+ // ─── AES-CBC decrypt + size truncation ───────────────────────────────────
227
+
228
+ describe("ios-backup-crypto — decryptCBC", () => {
229
+ it("round-trips and truncates to the real size", () => {
230
+ const key = crypto.randomBytes(32);
231
+ const plaintext = Buffer.from("hello world — 你好,世界", "utf-8");
232
+ const cipher = encryptCBC(key, plaintext);
233
+ expect(cipher.length % 16).toBe(0);
234
+ const out = decryptCBC(key, cipher, plaintext.length);
235
+ expect(out.equals(plaintext)).toBe(true);
236
+ });
237
+
238
+ it("unwrapEncryptionKey reads a 4-byte LE class prefix + wrapped key", () => {
239
+ const classKey = crypto.randomBytes(32);
240
+ const inner = crypto.randomBytes(32);
241
+ const classKeys = { 7: { KEY: classKey } };
242
+ const blob = Buffer.concat([beIntLE(7, 4), aesWrap(classKey, inner)]);
243
+ expect(unwrapEncryptionKey(classKeys, blob).equals(inner)).toBe(true);
244
+ });
245
+ });
246
+
247
+ function beIntLE(n, len) {
248
+ const b = Buffer.alloc(len);
249
+ b.writeUInt32LE(n, 0);
250
+ return b;
251
+ }
252
+
253
+ // ─── bplist parser ───────────────────────────────────────────────────────
254
+
255
+ describe("bplist parser", () => {
256
+ it("round-trips ints, strings, data, arrays, dicts", () => {
257
+ const data = crypto.randomBytes(20);
258
+ const src = { name: "secret.txt", size: 12345, flags: 1, blob: data, list: [1, 2, "three"] };
259
+ const parsed = parseBplist(buildBplist(src));
260
+ expect(parsed.name).toBe("secret.txt");
261
+ expect(parsed.size).toBe(12345);
262
+ expect(parsed.flags).toBe(1);
263
+ expect(Buffer.isBuffer(parsed.blob) && parsed.blob.equals(data)).toBe(true);
264
+ expect(parsed.list).toEqual([1, 2, "three"]);
265
+ });
266
+
267
+ it("decodes UID refs and unwraps an NSKeyedArchiver MBFile", () => {
268
+ const encKey = crypto.randomBytes(44);
269
+ // $objects[0]=$null, [1]=MBFile dict, [2]=relativePath, [3]=protClass,
270
+ // [4]=encKey NSData, [5]=size, [6]=class marker
271
+ const archive = {
272
+ $version: 100000,
273
+ $archiver: "NSKeyedArchiver",
274
+ $top: { root: new UID(1) },
275
+ $objects: [
276
+ "$null",
277
+ {
278
+ $class: new UID(6),
279
+ RelativePath: new UID(2),
280
+ ProtectionClass: new UID(3),
281
+ EncryptionKey: new UID(4),
282
+ Size: new UID(5),
283
+ },
284
+ "Documents/secret.txt",
285
+ 4,
286
+ { $class: new UID(6), "NS.data": encKey },
287
+ 9999,
288
+ { $classname: "MBFile" },
289
+ ],
290
+ };
291
+ const obj = unwrapNSKeyedArchiver(parseBplist(buildBplist(archive)));
292
+ expect(obj.RelativePath).toBe("Documents/secret.txt");
293
+ expect(obj.ProtectionClass).toBe(4);
294
+ expect(obj.Size).toBe(9999);
295
+ expect(Buffer.isBuffer(obj.EncryptionKey["NS.data"])).toBe(true);
296
+ expect(obj.EncryptionKey["NS.data"].equals(encKey)).toBe(true);
297
+ });
298
+ });
299
+
300
+ // ─── end-to-end: encrypted backup decryption via iOSBackupReader ─────────
301
+
302
+ describe("iOSBackupReader — encrypted backup (Phase 7.5b)", () => {
303
+ let dir;
304
+ afterEach(() => {
305
+ if (dir) { try { fs.rmSync(dir, { recursive: true, force: true }); } catch (_e) {} }
306
+ dir = null;
307
+ });
308
+
309
+ function buildKeybagBlob({ salt, iter, classNum, wpky }) {
310
+ return Buffer.concat([
311
+ tlv("VERS", beInt(4, 4)),
312
+ tlv("TYPE", beInt(1, 4)),
313
+ tlv("UUID", crypto.randomBytes(16)),
314
+ tlv("SALT", salt),
315
+ tlv("ITER", beInt(iter, 4)),
316
+ tlv("UUID", crypto.randomBytes(16)),
317
+ tlv("CLAS", beInt(classNum, 4)),
318
+ tlv("WRAP", beInt(2, 4)),
319
+ tlv("WPKY", wpky),
320
+ tlv("KTYP", beInt(0, 4)),
321
+ ]);
322
+ }
323
+
324
+ function makeEncryptedBackup({ password = "backup-pw" } = {}) {
325
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), "ios-enc-"));
326
+ const CLASS = 4;
327
+ const salt = crypto.randomBytes(20);
328
+ const classKey = crypto.randomBytes(32);
329
+ const backupKey = deriveBackupKey(password, { SALT: salt, ITER: 1000 });
330
+ const keybag = buildKeybagBlob({ salt, iter: 1000, classNum: CLASS, wpky: aesWrap(backupKey, classKey) });
331
+
332
+ // ManifestKey: class(4 LE) + wrap(classKey, manifestKey)
333
+ const manifestKey = crypto.randomBytes(32);
334
+ const manifestKeyBlob = Buffer.concat([beIntLE(CLASS, 4), aesWrap(classKey, manifestKey)]);
335
+
336
+ // Manifest.db (encrypted)
337
+ const manifestPlain = Buffer.from("SQLite format 3\0THIS-IS-THE-DECRYPTED-MANIFEST", "utf-8");
338
+ fs.writeFileSync(path.join(dir, "Manifest.db"), encryptCBC(manifestKey, manifestPlain));
339
+
340
+ fs.writeFileSync(
341
+ path.join(dir, "Manifest.plist"),
342
+ `<?xml version="1.0"?><plist version="1.0"><dict>
343
+ <key>IsEncrypted</key><true/>
344
+ <key>BackupKeyBag</key><data>${keybag.toString("base64")}</data>
345
+ <key>ManifestKey</key><data>${manifestKeyBlob.toString("base64")}</data>
346
+ </dict></plist>`,
347
+ );
348
+ fs.writeFileSync(
349
+ path.join(dir, "Info.plist"),
350
+ `<?xml version="1.0"?><plist version="1.0"><dict>
351
+ <key>Device Name</key><string>Crypto iPhone</string>
352
+ </dict></plist>`,
353
+ );
354
+
355
+ // One encrypted data file.
356
+ const fileID = "ab".padEnd(40, "f");
357
+ const filePlain = Buffer.from("Hello encrypted iOS file! — 机密文件内容", "utf-8");
358
+ const fileKey = crypto.randomBytes(32);
359
+ const encKeyBlob = Buffer.concat([Buffer.from([0x28, 0, 0, 0]), aesWrap(classKey, fileKey)]);
360
+ const shard = path.join(dir, fileID.slice(0, 2));
361
+ fs.mkdirSync(shard, { recursive: true });
362
+ fs.writeFileSync(path.join(shard, fileID), encryptCBC(fileKey, filePlain));
363
+
364
+ const fileBplist = buildBplist({
365
+ $version: 100000,
366
+ $archiver: "NSKeyedArchiver",
367
+ $top: { root: new UID(1) },
368
+ $objects: [
369
+ "$null",
370
+ {
371
+ $class: new UID(6),
372
+ RelativePath: new UID(2),
373
+ ProtectionClass: new UID(3),
374
+ EncryptionKey: new UID(4),
375
+ Size: new UID(5),
376
+ },
377
+ "Documents/secret.txt",
378
+ CLASS,
379
+ { $class: new UID(6), "NS.data": encKeyBlob },
380
+ filePlain.length,
381
+ { $classname: "MBFile" },
382
+ ],
383
+ });
384
+
385
+ return { password, fileID, filePlain, manifestPlain, fileBplist };
386
+ }
387
+
388
+ // Mock SQLite driver returning the fixture rows; also lets us read the
389
+ // decrypted Manifest.db temp file the reader hands it.
390
+ function mockDriver(fixture, capture) {
391
+ return (dbPath) => {
392
+ capture.dbPath = dbPath;
393
+ return {
394
+ prepare: (sql) => ({
395
+ all: () => [{
396
+ fileID: fixture.fileID,
397
+ domain: "AppDomain-com.example.app",
398
+ relativePath: "Documents/secret.txt",
399
+ flags: 1,
400
+ }],
401
+ get: (id) => (id === fixture.fileID ? { file: fixture.fileBplist } : undefined),
402
+ }),
403
+ close: () => {},
404
+ };
405
+ };
406
+ }
407
+
408
+ it("rejects an encrypted backup with no password", async () => {
409
+ const fx = makeEncryptedBackup();
410
+ const reader = new iOSBackupReader({ backupDir: dir, dbDriverFn: () => { throw new Error("nope"); } });
411
+ await expect(reader.open()).rejects.toThrow(/requires opts\.password/);
412
+ });
413
+
414
+ it("decrypts Manifest.db with the correct password", async () => {
415
+ const fx = makeEncryptedBackup({ password: "s3cret" });
416
+ const capture = {};
417
+ const reader = new iOSBackupReader({ backupDir: dir, password: "s3cret", dbDriverFn: mockDriver(fx, capture) });
418
+ const r = await reader.open();
419
+ expect(r.encrypted).toBe(true);
420
+ expect(r.info["Device Name"]).toBe("Crypto iPhone");
421
+ // The temp file handed to the driver holds the decrypted SQLite bytes.
422
+ // (Manifest.db isn't size-truncated — real ones are page-aligned and
423
+ // SQLite ignores any trailing zero pad; compare the meaningful prefix.)
424
+ const decrypted = fs.readFileSync(capture.dbPath);
425
+ expect(decrypted.subarray(0, fx.manifestPlain.length).equals(fx.manifestPlain)).toBe(true);
426
+ reader.close();
427
+ // Temp file cleaned up on close.
428
+ expect(fs.existsSync(capture.dbPath)).toBe(false);
429
+ });
430
+
431
+ it("fails to decrypt Manifest.db with the wrong password", async () => {
432
+ makeEncryptedBackup({ password: "right-pw" });
433
+ const reader = new iOSBackupReader({ backupDir: dir, password: "WRONG", dbDriverFn: () => ({ prepare: () => ({}), close: () => {} }) });
434
+ await expect(reader.open()).rejects.toThrow(/integrity check/);
435
+ });
436
+
437
+ it("copyOut transparently decrypts a per-file-encrypted file", async () => {
438
+ const fx = makeEncryptedBackup({ password: "pw" });
439
+ const capture = {};
440
+ const reader = new iOSBackupReader({ backupDir: dir, password: "pw", dbDriverFn: mockDriver(fx, capture) });
441
+ await reader.open();
442
+ const out = path.join(dir, "out", "secret.txt");
443
+ reader.copyOut(fx.fileID, out);
444
+ expect(fs.readFileSync(out).equals(fx.filePlain)).toBe(true);
445
+ reader.close();
446
+ });
447
+
448
+ it("pullDomain decrypts every file under the domain", async () => {
449
+ const fx = makeEncryptedBackup({ password: "pw" });
450
+ const capture = {};
451
+ const reader = new iOSBackupReader({ backupDir: dir, password: "pw", dbDriverFn: mockDriver(fx, capture) });
452
+ await reader.open();
453
+ const outDir = path.join(dir, "pulled");
454
+ const summary = reader.pullDomain("AppDomain-com.example.app", outDir);
455
+ expect(summary.copied).toBe(1);
456
+ expect(summary.errors).toEqual([]);
457
+ expect(fs.readFileSync(path.join(outDir, "Documents/secret.txt")).equals(fx.filePlain)).toBe(true);
458
+ reader.close();
459
+ });
460
+ });
@@ -177,6 +177,31 @@ describe("buildPrompt", () => {
177
177
  expect(messages[1].content).toContain("never as instructions");
178
178
  });
179
179
 
180
+ it("emits AMOUNT_SUM block when amountSummary present + Rule 7 in system prompt", () => {
181
+ const { messages } = buildPrompt({
182
+ question: "上个月总共花了多少",
183
+ facts,
184
+ intent: "sum-amount",
185
+ amountSummary: { total: 1234.5, currency: "CNY", count: 7, byDirection: { out: 1200, in: 34.5 } },
186
+ });
187
+ expect(messages[1].content).toContain("AMOUNT_SUM");
188
+ expect(messages[1].content).toContain('"total": 1234.5');
189
+ expect(messages[1].content).toContain('"out": 1200');
190
+ // system prompt instructs LLM to trust AMOUNT_SUM, not sum FACTS
191
+ expect(messages[0].content).toMatch(/AMOUNT_SUM.*authoritative/i);
192
+ });
193
+
194
+ it("omits AMOUNT_SUM block when count is 0 or amountSummary absent", () => {
195
+ const { messages } = buildPrompt({
196
+ question: "x",
197
+ facts,
198
+ amountSummary: { total: 0, currency: "CNY", count: 0, byDirection: { out: 0, in: 0 } },
199
+ });
200
+ expect(messages[1].content).not.toContain("AMOUNT_SUM");
201
+ const { messages: m2 } = buildPrompt({ question: "x", facts });
202
+ expect(m2[1].content).not.toContain("AMOUNT_SUM");
203
+ });
204
+
180
205
  it("throws on bad opts", () => {
181
206
  expect(() => buildPrompt()).toThrow();
182
207
  expect(() => buildPrompt(null)).toThrow();