@chainlesschain/personal-data-hub 0.3.8 → 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.
- package/__tests__/adapters/apple-health.test.js +95 -0
- package/__tests__/adapters/email-templates.test.js +123 -0
- package/__tests__/adapters/family-23-collectors-scaffold.test.js +178 -0
- package/__tests__/adapters/game-genshin-scaffold.test.js +107 -0
- package/__tests__/adapters/git-activity.test.js +7 -1
- package/__tests__/adapters/local-im-pc.test.js +149 -0
- package/__tests__/adapters/netease-music.test.js +74 -0
- package/__tests__/adapters/qq-pc-direct-read.test.js +186 -0
- package/__tests__/adapters/system-data-adapter.test.js +4 -1
- package/__tests__/adapters/wechat-pc-direct-read.test.js +207 -0
- package/__tests__/adapters/weread.test.js +123 -0
- package/__tests__/analysis.test.js +120 -15
- package/__tests__/mobile-extractor-encrypted.test.js +460 -0
- package/__tests__/prompt-builder.test.js +47 -2
- package/__tests__/registry-readiness.test.js +233 -0
- package/__tests__/social-douyin-im-direct-read.test.js +311 -0
- package/__tests__/social-douyin-snapshot.test.js +5 -2
- package/__tests__/vault.test.js +99 -0
- package/lib/adapter-guide.js +520 -0
- package/lib/adapter-readiness.js +257 -0
- package/lib/adapters/_local-im-db-reader.js +218 -0
- package/lib/adapters/_local-im-pc-adapter.js +162 -0
- package/lib/adapters/apple-health/index.js +329 -0
- package/lib/adapters/dingtalk-pc/index.js +29 -0
- package/lib/adapters/edu-huawei-learning/api-client.js +47 -0
- package/lib/adapters/edu-huawei-learning/index.js +255 -0
- package/lib/adapters/edu-zuoyebang/api-client.js +48 -0
- package/lib/adapters/edu-zuoyebang/index.js +259 -0
- package/lib/adapters/email-imap/email-adapter.js +16 -0
- package/lib/adapters/email-imap/templates/bill.js +174 -18
- package/lib/adapters/feishu-pc/index.js +29 -0
- package/lib/adapters/finance-alipay/api-client.js +48 -0
- package/lib/adapters/finance-alipay/index.js +257 -0
- package/lib/adapters/game-genshin/api-client.js +59 -0
- package/lib/adapters/game-genshin/index.js +274 -0
- package/lib/adapters/game-honor-of-kings/api-client.js +54 -0
- package/lib/adapters/game-honor-of-kings/index.js +259 -0
- package/lib/adapters/netease-music/index.js +227 -0
- package/lib/adapters/qq-pc/index.js +200 -0
- package/lib/adapters/qq-pc/nt-db-reader.js +210 -0
- package/lib/adapters/social-douyin/index.js +194 -1
- package/lib/adapters/wechat/wechat-adapter.js +7 -1
- package/lib/adapters/wechat-pc/index.js +335 -0
- package/lib/adapters/wechat-pc/pc-db-reader.js +327 -0
- package/lib/adapters/weread/api-client.js +128 -0
- package/lib/adapters/weread/index.js +337 -0
- package/lib/analysis.js +65 -0
- package/lib/index.js +39 -0
- package/lib/mobile-extractor/bplist.js +233 -0
- package/lib/mobile-extractor/ios-backup-crypto.js +315 -0
- package/lib/mobile-extractor/ios.js +131 -16
- package/lib/prompt-builder.js +19 -1
- package/lib/registry.js +170 -0
- package/lib/vault.js +105 -0
- package/package.json +1 -1
- package/scripts/run-native-tests-sandbox.sh +2 -0
- 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
|
-
//
|
|
1573
|
-
//
|
|
1574
|
-
//
|
|
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
|
|
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")
|
|
1692
|
+
return [mkEvent("e-1")];
|
|
1589
1693
|
},
|
|
1590
1694
|
queryPersons: () => [],
|
|
1591
1695
|
queryItems: () => [],
|
|
1592
1696
|
getEvent: () => null,
|
|
1593
1697
|
audit: () => {},
|
|
1594
|
-
stats: () => ({ events:
|
|
1698
|
+
stats: () => ({ events: 1, persons: 0, places: 0, items: 0, topics: 0 }),
|
|
1595
1699
|
};
|
|
1596
|
-
const llm = new MockLLMClient({ reply: "
|
|
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(
|
|
1604
|
-
expect(queryEventsCalls[0].
|
|
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
|
-
//
|
|
1665
|
-
|
|
1666
|
-
|
|
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
|
|
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
|
+
});
|