@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
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Minimal binary property list (bplist00) reader — enough to decode the
3
+ * NSKeyedArchiver blob stored in Manifest.db's `Files.file` column of an
4
+ * iOS backup, from which we pull each file's ProtectionClass +
5
+ * EncryptionKey + Size.
6
+ *
7
+ * Supports the object types that appear in those archives: null/bool,
8
+ * int, real, date, data, ASCII/UTF-16 string, UID, array, dict. This is
9
+ * NOT a general-purpose plist library — it targets the iOS-backup subset.
10
+ *
11
+ * Format reference: Apple CoreFoundation CFBinaryPList.c.
12
+ */
13
+
14
+ "use strict";
15
+
16
+ const BPLIST_MAGIC = "bplist00";
17
+
18
+ class UID {
19
+ constructor(value) { this.UID = value; }
20
+ }
21
+
22
+ /**
23
+ * Parse a bplist00 buffer into a JS value. UIDs become {UID:n} (UID class
24
+ * instances); data stays a Buffer; dates become Date.
25
+ *
26
+ * @param {Buffer} buf
27
+ * @returns {*}
28
+ */
29
+ function parseBplist(buf) {
30
+ if (!Buffer.isBuffer(buf) || buf.length < 32 + 8) {
31
+ throw new Error("parseBplist: buffer too small");
32
+ }
33
+ if (buf.toString("ascii", 0, 8) !== BPLIST_MAGIC) {
34
+ throw new Error("parseBplist: bad magic (not bplist00)");
35
+ }
36
+
37
+ // Trailer: last 32 bytes.
38
+ const trailer = buf.subarray(buf.length - 32);
39
+ const offsetSize = trailer.readUInt8(6);
40
+ const objectRefSize = trailer.readUInt8(7);
41
+ const numObjects = readUIntBE(trailer, 8, 8);
42
+ const topObject = readUIntBE(trailer, 16, 8);
43
+ const offsetTableOffset = readUIntBE(trailer, 24, 8);
44
+
45
+ // Offset table.
46
+ const offsets = [];
47
+ for (let i = 0; i < numObjects; i++) {
48
+ offsets.push(readUIntBE(buf, offsetTableOffset + i * offsetSize, offsetSize));
49
+ }
50
+
51
+ const cache = new Array(numObjects);
52
+
53
+ function readObject(index) {
54
+ if (index < 0 || index >= numObjects) throw new Error(`parseBplist: object ref ${index} out of range`);
55
+ if (cache[index] !== undefined) return cache[index];
56
+ let pos = offsets[index];
57
+ const marker = buf.readUInt8(pos);
58
+ const hi = marker >> 4;
59
+ const lo = marker & 0x0f;
60
+ pos += 1;
61
+ let result;
62
+
63
+ switch (hi) {
64
+ case 0x0: // singleton
65
+ if (lo === 0x0) result = null;
66
+ else if (lo === 0x8) result = false;
67
+ else if (lo === 0x9) result = true;
68
+ else result = null;
69
+ break;
70
+ case 0x1: { // int (2^lo bytes)
71
+ const n = 1 << lo;
72
+ result = readIntBE(buf, pos, n);
73
+ break;
74
+ }
75
+ case 0x2: { // real
76
+ const n = 1 << lo;
77
+ result = n === 4 ? buf.readFloatBE(pos) : buf.readDoubleBE(pos);
78
+ break;
79
+ }
80
+ case 0x3: { // date (8-byte double, seconds since 2001-01-01)
81
+ const secs = buf.readDoubleBE(pos);
82
+ result = new Date(Date.UTC(2001, 0, 1) + secs * 1000);
83
+ break;
84
+ }
85
+ case 0x4: { // data
86
+ const { count, dataPos } = readCount(buf, lo, pos);
87
+ result = Buffer.from(buf.subarray(dataPos, dataPos + count));
88
+ break;
89
+ }
90
+ case 0x5: { // ASCII string
91
+ const { count, dataPos } = readCount(buf, lo, pos);
92
+ result = buf.toString("ascii", dataPos, dataPos + count);
93
+ break;
94
+ }
95
+ case 0x6: { // UTF-16BE string
96
+ const { count, dataPos } = readCount(buf, lo, pos);
97
+ result = buf.toString("utf16le", dataPos, dataPos + count * 2);
98
+ // Stored big-endian; swap.
99
+ result = swapUtf16(buf.subarray(dataPos, dataPos + count * 2));
100
+ break;
101
+ }
102
+ case 0x8: { // UID
103
+ const n = lo + 1;
104
+ result = new UID(readUIntBE(buf, pos, n));
105
+ break;
106
+ }
107
+ case 0xa: { // array
108
+ const { count, dataPos } = readCount(buf, lo, pos);
109
+ const arr = [];
110
+ cache[index] = arr; // set before recursing (cycle-safe)
111
+ for (let i = 0; i < count; i++) {
112
+ const ref = readUIntBE(buf, dataPos + i * objectRefSize, objectRefSize);
113
+ arr.push(readObject(ref));
114
+ }
115
+ return arr;
116
+ }
117
+ case 0xd: { // dict
118
+ const { count, dataPos } = readCount(buf, lo, pos);
119
+ const dict = {};
120
+ cache[index] = dict;
121
+ const keysBase = dataPos;
122
+ const valsBase = dataPos + count * objectRefSize;
123
+ for (let i = 0; i < count; i++) {
124
+ const kRef = readUIntBE(buf, keysBase + i * objectRefSize, objectRefSize);
125
+ const vRef = readUIntBE(buf, valsBase + i * objectRefSize, objectRefSize);
126
+ const key = readObject(kRef);
127
+ dict[String(key)] = readObject(vRef);
128
+ }
129
+ return dict;
130
+ }
131
+ default:
132
+ throw new Error(`parseBplist: unknown object marker 0x${marker.toString(16)} at ${offsets[index]}`);
133
+ }
134
+ cache[index] = result;
135
+ return result;
136
+ }
137
+
138
+ return readObject(topObject);
139
+ }
140
+
141
+ // For collection/data/string markers, lo==0xf means the count follows as
142
+ // a separate int object.
143
+ function readCount(buf, lo, pos) {
144
+ if (lo !== 0x0f) return { count: lo, dataPos: pos };
145
+ const sizeMarker = buf.readUInt8(pos);
146
+ const n = 1 << (sizeMarker & 0x0f);
147
+ const count = readUIntBE(buf, pos + 1, n);
148
+ return { count, dataPos: pos + 1 + n };
149
+ }
150
+
151
+ function readUIntBE(buf, off, len) {
152
+ let n = 0;
153
+ for (let i = 0; i < len; i++) n = n * 256 + buf.readUInt8(off + i);
154
+ return n;
155
+ }
156
+
157
+ function readIntBE(buf, off, len) {
158
+ // bplist ints are unsigned for 1/2/4 bytes; 8-byte ints can be signed.
159
+ if (len <= 4) return readUIntBE(buf, off, len);
160
+ return Number(buf.readBigInt64BE(off));
161
+ }
162
+
163
+ function swapUtf16(beBuf) {
164
+ const swapped = Buffer.from(beBuf);
165
+ swapped.swap16();
166
+ return swapped.toString("utf16le");
167
+ }
168
+
169
+ /**
170
+ * Resolve an NSKeyedArchiver-shaped parsed plist into a plain object by
171
+ * following $top.root through the $objects table and replacing UID refs.
172
+ *
173
+ * @param {object} parsed — output of parseBplist on an NSKeyedArchiver blob
174
+ * @returns {*}
175
+ */
176
+ function unwrapNSKeyedArchiver(parsed) {
177
+ if (!parsed || !Array.isArray(parsed.$objects) || !parsed.$top) {
178
+ throw new Error("unwrapNSKeyedArchiver: not an NSKeyedArchiver plist");
179
+ }
180
+ const objects = parsed.$objects;
181
+ const seen = new Map();
182
+
183
+ function resolve(node) {
184
+ if (node instanceof UID) {
185
+ if (seen.has(node.UID)) return seen.get(node.UID);
186
+ const target = objects[node.UID];
187
+ const out = resolveValue(target, node.UID);
188
+ return out;
189
+ }
190
+ return resolveValue(node, null);
191
+ }
192
+
193
+ function resolveValue(node, selfUid) {
194
+ if (node === "$null") return null;
195
+ if (node instanceof UID) return resolve(node);
196
+ if (Buffer.isBuffer(node) || node instanceof Date) return node;
197
+ if (Array.isArray(node)) {
198
+ const arr = [];
199
+ if (selfUid != null) seen.set(selfUid, arr);
200
+ for (const el of node) arr.push(resolve(el));
201
+ return arr;
202
+ }
203
+ if (node && typeof node === "object") {
204
+ // NSDictionary/NSArray encoded form has NS.keys / NS.objects.
205
+ if (Array.isArray(node["NS.keys"]) && Array.isArray(node["NS.objects"])) {
206
+ const d = {};
207
+ if (selfUid != null) seen.set(selfUid, d);
208
+ node["NS.keys"].forEach((k, i) => {
209
+ d[String(resolve(k))] = resolve(node["NS.objects"][i]);
210
+ });
211
+ return d;
212
+ }
213
+ if (Array.isArray(node["NS.objects"])) {
214
+ const arr = [];
215
+ if (selfUid != null) seen.set(selfUid, arr);
216
+ node["NS.objects"].forEach((v) => arr.push(resolve(v)));
217
+ return arr;
218
+ }
219
+ const out = {};
220
+ if (selfUid != null) seen.set(selfUid, out);
221
+ for (const [k, v] of Object.entries(node)) {
222
+ if (k === "$class") continue;
223
+ out[k] = resolve(v);
224
+ }
225
+ return out;
226
+ }
227
+ return node;
228
+ }
229
+
230
+ return resolve(parsed.$top.root != null ? parsed.$top.root : parsed.$top);
231
+ }
232
+
233
+ module.exports = { parseBplist, unwrapNSKeyedArchiver, UID, BPLIST_MAGIC };
@@ -0,0 +1,315 @@
1
+ /**
2
+ * Phase 7.5b — iOS encrypted iTunes backup cryptography.
3
+ *
4
+ * Implements the well-documented iOS backup keybag format so an encrypted
5
+ * backup (Manifest.plist → IsEncrypted=true) can be decrypted with the
6
+ * user's backup password. Pieces:
7
+ *
8
+ * 1. parseKeybag() — TLV parser for the BackupKeyBag blob
9
+ * 2. deriveBackupKey() — password → key (single or double PBKDF2,
10
+ * the latter for iOS 10.2+ DPSL/DPIC keybags)
11
+ * 3. aesUnwrap/aesWrap() — RFC 3394 AES key wrap/unwrap (KAT-verified)
12
+ * 4. unwrapClassKeys() — unwrap each passcode-protected class key
13
+ * 5. unwrapEncryptionKey()— 4-byte-class + wrapped-key blob → file key
14
+ * 6. decryptCBC() — AES-256-CBC (zero IV) + truncate to size
15
+ *
16
+ * Every primitive is independently testable: aesUnwrap is checked against
17
+ * the official RFC 3394 test vectors, and the higher-level flow is checked
18
+ * with synthetic fixtures generated by the matching aesWrap + AES-CBC
19
+ * encrypt path. Byte-for-byte parity with a *real* Apple backup remains a
20
+ * device-E2E follow-up (no real encrypted backup available on Windows).
21
+ *
22
+ * References: iphone-dataprotection, mvt (mvt-project), iOSbackup (py).
23
+ */
24
+
25
+ "use strict";
26
+
27
+ const crypto = require("node:crypto");
28
+
29
+ // RFC 3394 default initial value for AES key wrap.
30
+ const RFC3394_IV = Buffer.from("A6A6A6A6A6A6A6A6", "hex");
31
+
32
+ // WRAP flag bit: class key is wrapped with the passcode-derived key.
33
+ const WRAP_PASSCODE = 2;
34
+
35
+ // Tags that belong to an individual class-key block (vs. keybag header).
36
+ const CLASSKEY_TAGS = new Set(["CLAS", "WRAP", "WPKY", "KTYP", "PBKY"]);
37
+ // CLAS / KTYP / WRAP decode as big-endian integers; the rest stay raw.
38
+ const INT_TAGS = new Set(["CLAS", "WRAP", "KTYP"]);
39
+
40
+ /**
41
+ * Parse the BackupKeyBag TLV blob.
42
+ *
43
+ * Layout: repeated (4-byte ASCII tag)(4-byte big-endian length)(value).
44
+ * The header carries SALT/ITER/DPSL/DPIC/WRAP/UUID; each subsequent
45
+ * `UUID` tag opens a new class-key block (CLAS/WRAP/WPKY/KTYP).
46
+ *
47
+ * @param {Buffer} buf
48
+ * @returns {{attrs:object, classKeys:Object<number,object>}}
49
+ */
50
+ function parseKeybag(buf) {
51
+ if (!Buffer.isBuffer(buf) || buf.length < 8) {
52
+ throw new Error("parseKeybag: keybag blob too short");
53
+ }
54
+ const attrs = {};
55
+ const classKeys = {};
56
+ let current = null;
57
+ let seenHeaderUuid = false;
58
+ let off = 0;
59
+
60
+ while (off + 8 <= buf.length) {
61
+ const tag = buf.toString("ascii", off, off + 4);
62
+ const len = buf.readUInt32BE(off + 4);
63
+ const valStart = off + 8;
64
+ const valEnd = valStart + len;
65
+ if (valEnd > buf.length) {
66
+ throw new Error(`parseKeybag: truncated value for tag ${tag}`);
67
+ }
68
+ const val = buf.subarray(valStart, valEnd);
69
+ off = valEnd;
70
+
71
+ if (tag === "UUID") {
72
+ if (!seenHeaderUuid) {
73
+ seenHeaderUuid = true;
74
+ attrs.UUID = Buffer.from(val);
75
+ } else {
76
+ // New class-key block.
77
+ if (current && current.CLAS !== undefined) classKeys[current.CLAS] = current;
78
+ current = { UUID: Buffer.from(val) };
79
+ }
80
+ continue;
81
+ }
82
+
83
+ if (current && CLASSKEY_TAGS.has(tag)) {
84
+ current[tag] = INT_TAGS.has(tag) ? readBeInt(val) : Buffer.from(val);
85
+ continue;
86
+ }
87
+
88
+ // Header attribute.
89
+ attrs[tag] = INT_TAGS.has(tag) || tag === "ITER" || tag === "DPIC" || tag === "VERS" || tag === "TYPE"
90
+ ? readBeInt(val)
91
+ : Buffer.from(val);
92
+ }
93
+ if (current && current.CLAS !== undefined) classKeys[current.CLAS] = current;
94
+
95
+ return { attrs, classKeys };
96
+ }
97
+
98
+ function readBeInt(buf) {
99
+ let n = 0;
100
+ for (const b of buf) n = n * 256 + b;
101
+ return n;
102
+ }
103
+
104
+ /**
105
+ * Derive the backup key from the password + keybag header.
106
+ *
107
+ * iOS 10.2+ keybags carry DPSL (salt) + DPIC (iterations) and require a
108
+ * two-stage derivation: PBKDF2-SHA256 then PBKDF2-SHA1. Older keybags use
109
+ * a single PBKDF2-SHA1 pass over SALT/ITER.
110
+ *
111
+ * @param {string|Buffer} password
112
+ * @param {object} attrs — from parseKeybag()
113
+ * @returns {Buffer} 32-byte key
114
+ */
115
+ function deriveBackupKey(password, attrs) {
116
+ const pwd = Buffer.isBuffer(password) ? password : Buffer.from(String(password), "utf-8");
117
+ if (!attrs || !Buffer.isBuffer(attrs.SALT) || !attrs.ITER) {
118
+ throw new Error("deriveBackupKey: keybag missing SALT/ITER");
119
+ }
120
+ let material = pwd;
121
+ if (Buffer.isBuffer(attrs.DPSL) && attrs.DPIC) {
122
+ material = crypto.pbkdf2Sync(pwd, attrs.DPSL, attrs.DPIC, 32, "sha256");
123
+ }
124
+ return crypto.pbkdf2Sync(material, attrs.SALT, attrs.ITER, 32, "sha1");
125
+ }
126
+
127
+ /**
128
+ * RFC 3394 AES key unwrap. `kek` is the key-encryption key (16/24/32
129
+ * bytes); `wrapped` is (n+1) 64-bit blocks. Throws if the integrity
130
+ * check value does not match — i.e. wrong password.
131
+ *
132
+ * @param {Buffer} kek
133
+ * @param {Buffer} wrapped
134
+ * @returns {Buffer} unwrapped key (n*8 bytes)
135
+ */
136
+ function aesUnwrap(kek, wrapped) {
137
+ if (!Buffer.isBuffer(wrapped) || wrapped.length % 8 !== 0 || wrapped.length < 16) {
138
+ throw new Error("aesUnwrap: wrapped key must be a multiple of 8 bytes (>=16)");
139
+ }
140
+ const n = wrapped.length / 8 - 1;
141
+ let A = Buffer.from(wrapped.subarray(0, 8));
142
+ const R = [];
143
+ for (let i = 0; i < n; i++) R.push(Buffer.from(wrapped.subarray(8 * (i + 1), 8 * (i + 2))));
144
+
145
+ for (let j = 5; j >= 0; j--) {
146
+ for (let i = n; i >= 1; i--) {
147
+ const t = n * j + i;
148
+ const At = Buffer.from(A);
149
+ xorCounter(At, t);
150
+ const B = aesEcbDecryptBlock(kek, Buffer.concat([At, R[i - 1]]));
151
+ A = Buffer.from(B.subarray(0, 8));
152
+ R[i - 1] = Buffer.from(B.subarray(8, 16));
153
+ }
154
+ }
155
+ if (!crypto.timingSafeEqual(A, RFC3394_IV)) {
156
+ throw new Error("aesUnwrap: integrity check failed (wrong password or corrupt key)");
157
+ }
158
+ return Buffer.concat(R);
159
+ }
160
+
161
+ /**
162
+ * RFC 3394 AES key wrap — inverse of aesUnwrap. Provided so tests (and
163
+ * any future re-wrap use) can build valid fixtures without a real backup.
164
+ *
165
+ * @param {Buffer} kek
166
+ * @param {Buffer} key — plaintext key (multiple of 8 bytes, >= 16)
167
+ * @returns {Buffer} wrapped (key.length + 8 bytes)
168
+ */
169
+ function aesWrap(kek, key) {
170
+ if (!Buffer.isBuffer(key) || key.length % 8 !== 0 || key.length < 16) {
171
+ throw new Error("aesWrap: key must be a multiple of 8 bytes (>=16)");
172
+ }
173
+ const n = key.length / 8;
174
+ let A = Buffer.from(RFC3394_IV);
175
+ const R = [];
176
+ for (let i = 0; i < n; i++) R.push(Buffer.from(key.subarray(8 * i, 8 * (i + 1))));
177
+
178
+ for (let j = 0; j <= 5; j++) {
179
+ for (let i = 1; i <= n; i++) {
180
+ const B = aesEcbEncryptBlock(kek, Buffer.concat([A, R[i - 1]]));
181
+ A = Buffer.from(B.subarray(0, 8));
182
+ const t = n * j + i;
183
+ xorCounter(A, t);
184
+ R[i - 1] = Buffer.from(B.subarray(8, 16));
185
+ }
186
+ }
187
+ return Buffer.concat([A, ...R]);
188
+ }
189
+
190
+ // XOR a 64-bit big-endian counter into the last bytes of an 8-byte buffer.
191
+ function xorCounter(buf8, t) {
192
+ // t fits in 53-bit safe-int range for any real keybag; spread over 8 bytes.
193
+ let v = t;
194
+ for (let k = 0; k < 8 && v > 0; k++) {
195
+ buf8[7 - k] ^= v & 0xff;
196
+ v = Math.floor(v / 256);
197
+ }
198
+ }
199
+
200
+ function aesEcbDecryptBlock(kek, block16) {
201
+ const d = crypto.createDecipheriv(ecbAlgoFor(kek), kek, null);
202
+ d.setAutoPadding(false);
203
+ return Buffer.concat([d.update(block16), d.final()]);
204
+ }
205
+
206
+ function aesEcbEncryptBlock(kek, block16) {
207
+ const c = crypto.createCipheriv(ecbAlgoFor(kek), kek, null);
208
+ c.setAutoPadding(false);
209
+ return Buffer.concat([c.update(block16), c.final()]);
210
+ }
211
+
212
+ function ecbAlgoFor(kek) {
213
+ switch (kek.length) {
214
+ case 16: return "aes-128-ecb";
215
+ case 24: return "aes-192-ecb";
216
+ case 32: return "aes-256-ecb";
217
+ default: throw new Error(`unsupported KEK length: ${kek.length}`);
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Unwrap every passcode-protected class key in place, attaching `.KEY`.
223
+ * Class keys that are device-protected only (no WRAP_PASSCODE bit) cannot
224
+ * be unwrapped from a backup and are left without `.KEY`.
225
+ *
226
+ * @param {Object<number,object>} classKeys
227
+ * @param {Buffer} backupKey
228
+ * @returns {Object<number,object>} same object (mutated)
229
+ */
230
+ function unwrapClassKeys(classKeys, backupKey) {
231
+ for (const clas of Object.keys(classKeys)) {
232
+ const ck = classKeys[clas];
233
+ if ((ck.WRAP & WRAP_PASSCODE) && Buffer.isBuffer(ck.WPKY)) {
234
+ ck.KEY = aesUnwrap(backupKey, ck.WPKY);
235
+ }
236
+ }
237
+ return classKeys;
238
+ }
239
+
240
+ /**
241
+ * Unwrap an EncryptionKey blob (`Manifest.plist.ManifestKey` or a file's
242
+ * `EncryptionKey`): 4-byte little-endian protection class followed by the
243
+ * wrapped key. Returns the unwrapped key bytes.
244
+ *
245
+ * @param {Object<number,object>} classKeys — with `.KEY` populated
246
+ * @param {Buffer} blob
247
+ * @returns {Buffer}
248
+ */
249
+ function unwrapEncryptionKey(classKeys, blob) {
250
+ if (!Buffer.isBuffer(blob) || blob.length < 4 + 16) {
251
+ throw new Error("unwrapEncryptionKey: blob too short");
252
+ }
253
+ const clas = blob.readUInt32LE(0);
254
+ const wrapped = blob.subarray(4);
255
+ const ck = classKeys[clas];
256
+ if (!ck || !Buffer.isBuffer(ck.KEY)) {
257
+ throw new Error(`unwrapEncryptionKey: no unwrapped class key for protection class ${clas}`);
258
+ }
259
+ return aesUnwrap(ck.KEY, wrapped);
260
+ }
261
+
262
+ /**
263
+ * AES-256-CBC decrypt with a zero IV (iOS backup convention), no padding,
264
+ * optionally truncated to `size` (the real file length stored in the
265
+ * Files metadata; ciphertext is block-padded).
266
+ *
267
+ * @param {Buffer} key — 16/24/32 bytes
268
+ * @param {Buffer} data — ciphertext, multiple of 16 bytes
269
+ * @param {number} [size] — truncate plaintext to this many bytes
270
+ * @returns {Buffer}
271
+ */
272
+ function decryptCBC(key, data, size) {
273
+ if (!Buffer.isBuffer(data) || data.length % 16 !== 0) {
274
+ throw new Error("decryptCBC: ciphertext length must be a multiple of 16");
275
+ }
276
+ const algo = key.length === 16 ? "aes-128-cbc" : key.length === 24 ? "aes-192-cbc" : "aes-256-cbc";
277
+ const iv = Buffer.alloc(16, 0);
278
+ const d = crypto.createDecipheriv(algo, key, iv);
279
+ d.setAutoPadding(false);
280
+ let out = Buffer.concat([d.update(data), d.final()]);
281
+ if (Number.isFinite(size) && size >= 0 && size < out.length) out = out.subarray(0, size);
282
+ return out;
283
+ }
284
+
285
+ /**
286
+ * Encrypt helper (zero-IV AES-CBC, PKCS-free block padding to 16) — used
287
+ * only by tests to build encrypted fixtures. Pads with zero bytes to the
288
+ * next 16-byte boundary, matching how we truncate on decrypt via `size`.
289
+ *
290
+ * @param {Buffer} key
291
+ * @param {Buffer} plaintext
292
+ * @returns {Buffer}
293
+ */
294
+ function encryptCBC(key, plaintext) {
295
+ const pad = (16 - (plaintext.length % 16)) % 16;
296
+ const padded = pad ? Buffer.concat([plaintext, Buffer.alloc(pad, 0)]) : plaintext;
297
+ const algo = key.length === 16 ? "aes-128-cbc" : key.length === 24 ? "aes-192-cbc" : "aes-256-cbc";
298
+ const iv = Buffer.alloc(16, 0);
299
+ const c = crypto.createCipheriv(algo, key, iv);
300
+ c.setAutoPadding(false);
301
+ return Buffer.concat([c.update(padded), c.final()]);
302
+ }
303
+
304
+ module.exports = {
305
+ RFC3394_IV,
306
+ WRAP_PASSCODE,
307
+ parseKeybag,
308
+ deriveBackupKey,
309
+ aesUnwrap,
310
+ aesWrap,
311
+ unwrapClassKeys,
312
+ unwrapEncryptionKey,
313
+ decryptCBC,
314
+ encryptCBC,
315
+ };