@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.
- 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 +25 -0
- 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 +11 -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
|
@@ -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
|
+
};
|