@patrickdk77/office-defuse 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +317 -2
- package/dist/biff.d.ts +62 -0
- package/dist/biff.js +95 -0
- package/dist/cfb.d.ts +58 -0
- package/dist/cfb.js +456 -0
- package/dist/context.d.ts +66 -0
- package/dist/context.js +121 -0
- package/dist/crc32.d.ts +8 -0
- package/dist/crc32.js +58 -0
- package/dist/engine.d.ts +5 -0
- package/dist/engine.js +200 -0
- package/dist/errors.d.ts +19 -0
- package/dist/errors.js +33 -0
- package/dist/fields.d.ts +29 -0
- package/dist/fields.js +97 -0
- package/dist/findings.d.ts +25 -0
- package/dist/findings.js +188 -0
- package/dist/formats.d.ts +10 -0
- package/dist/formats.js +46 -0
- package/dist/formulas.d.ts +22 -0
- package/dist/formulas.js +77 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +30 -0
- package/dist/index.mjs +3 -0
- package/dist/io.d.ts +23 -0
- package/dist/io.js +139 -0
- package/dist/links.d.ts +26 -0
- package/dist/links.js +145 -0
- package/dist/msg.d.ts +7 -0
- package/dist/msg.js +97 -0
- package/dist/objects.d.ts +42 -0
- package/dist/objects.js +134 -0
- package/dist/odf.d.ts +3 -0
- package/dist/odf.js +297 -0
- package/dist/offcrypto.d.ts +31 -0
- package/dist/offcrypto.js +408 -0
- package/dist/ole.d.ts +12 -0
- package/dist/ole.js +127 -0
- package/dist/oleobj.d.ts +74 -0
- package/dist/oleobj.js +198 -0
- package/dist/ooxml.d.ts +8 -0
- package/dist/ooxml.js +468 -0
- package/dist/package.d.ts +3 -0
- package/dist/package.js +21 -0
- package/dist/ppt.d.ts +8 -0
- package/dist/ppt.js +241 -0
- package/dist/rtf.d.ts +8 -0
- package/dist/rtf.js +539 -0
- package/dist/score.d.ts +17 -0
- package/dist/score.js +63 -0
- package/dist/types.d.ts +274 -0
- package/dist/types.js +152 -0
- package/dist/word.d.ts +9 -0
- package/dist/word.js +160 -0
- package/dist/xls.d.ts +27 -0
- package/dist/xls.js +174 -0
- package/dist/xlsb.d.ts +19 -0
- package/dist/xlsb.js +101 -0
- package/dist/xml.d.ts +256 -0
- package/dist/xml.js +1105 -0
- package/dist/zip-writer.d.ts +38 -0
- package/dist/zip-writer.js +182 -0
- package/dist/zip.d.ts +55 -0
- package/dist/zip.js +535 -0
- package/package.json +96 -4
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.Rc4 = exports.DEFAULT_PASSWORD = void 0;
|
|
37
|
+
exports.unlockWorkbook = unlockWorkbook;
|
|
38
|
+
exports.openPackage = openPackage;
|
|
39
|
+
const crypto = __importStar(require("node:crypto"));
|
|
40
|
+
const errors_1 = require("./errors");
|
|
41
|
+
const oleobj_1 = require("./oleobj");
|
|
42
|
+
const types_1 = require("./types");
|
|
43
|
+
/** The password Excel opens a workbook with when nobody set one to open it (MS-OFFCRYPTO 2.4.3). */
|
|
44
|
+
exports.DEFAULT_PASSWORD = 'VelvetSweatshop';
|
|
45
|
+
/** MS-OFFCRYPTO allows a spin count of at most 10,000,000. */
|
|
46
|
+
const MAX_SPIN = 10_000_000;
|
|
47
|
+
/** More of a Standard EncryptionInfo than its header and verifier take, which are a few hundred bytes. */
|
|
48
|
+
const STANDARD_INFO = 4096;
|
|
49
|
+
/** A source's bytes from `from` to `to`, in pieces. */
|
|
50
|
+
async function* chunks(source, from, to) {
|
|
51
|
+
for (let at = from; at < to; at += 65536)
|
|
52
|
+
yield source.read(at, Math.min(65536, to - at));
|
|
53
|
+
}
|
|
54
|
+
const required = (reason, data = {}) => new errors_1.StopError(types_1.OfficeCategory.Encrypted, types_1.OfficeDetail.PasswordRequired, `No password at hand opens the file: ${reason}`, { ...data, reason });
|
|
55
|
+
/** A file encrypted with an algorithm this reader does not decrypt. It never reaches OpenSSL. */
|
|
56
|
+
const unsupported = (algorithm, data = {}) => new errors_1.StopError(types_1.OfficeCategory.Encrypted, types_1.OfficeDetail.UnsupportedAlgorithm, `The file is encrypted with ${algorithm}, which this reader does not decrypt`, { ...data, algorithm });
|
|
57
|
+
const HASHES = new Map([
|
|
58
|
+
['SHA1', 'sha1'],
|
|
59
|
+
['SHA256', 'sha256'],
|
|
60
|
+
['SHA384', 'sha384'],
|
|
61
|
+
['SHA512', 'sha512'],
|
|
62
|
+
]);
|
|
63
|
+
const AES = new Map([
|
|
64
|
+
[128, { cbc: 'aes-128-cbc', ecb: 'aes-128-ecb', detail: types_1.OfficeDetail.Aes128, bits: 128 }],
|
|
65
|
+
[192, { cbc: 'aes-192-cbc', ecb: 'aes-192-ecb', detail: types_1.OfficeDetail.Aes192, bits: 192 }],
|
|
66
|
+
[256, { cbc: 'aes-256-cbc', ecb: 'aes-256-ecb', detail: types_1.OfficeDetail.Aes256, bits: 256 }],
|
|
67
|
+
]);
|
|
68
|
+
/** CALG_AES_128, CALG_AES_192 and CALG_AES_256, by key size. */
|
|
69
|
+
const STANDARD_AES = new Map([
|
|
70
|
+
[128, 0x660e],
|
|
71
|
+
[192, 0x660f],
|
|
72
|
+
[256, 0x6610],
|
|
73
|
+
]);
|
|
74
|
+
/** A value from the file for a message, cut short. */
|
|
75
|
+
const shown = (v) => (v ?? 'none').slice(0, 40);
|
|
76
|
+
/** The passwords tried, in order: the caller's, then Excel's default. */
|
|
77
|
+
function passwords(ctx) {
|
|
78
|
+
const out = ctx.settings.password !== undefined ? [{ password: ctx.settings.password, supplied: true }] : [];
|
|
79
|
+
return [...out, { password: exports.DEFAULT_PASSWORD, supplied: false }];
|
|
80
|
+
}
|
|
81
|
+
/** Which password opened the file, and the cipher, as findings. */
|
|
82
|
+
function opened(ctx, supplied, cipher, location, bits) {
|
|
83
|
+
ctx.report(types_1.OfficeCategory.Encrypted, supplied ? types_1.OfficeDetail.UserPassword : types_1.OfficeDetail.DefaultPassword, location, supplied ? {} : { password: exports.DEFAULT_PASSWORD });
|
|
84
|
+
ctx.report(types_1.OfficeCategory.Encrypted, cipher, location, { bits });
|
|
85
|
+
}
|
|
86
|
+
const utf16le = (s) => Buffer.from(s, 'utf16le');
|
|
87
|
+
const le32 = (n) => {
|
|
88
|
+
const b = Buffer.alloc(4);
|
|
89
|
+
b.writeUInt32LE(n >>> 0);
|
|
90
|
+
return b;
|
|
91
|
+
};
|
|
92
|
+
const hash = (algorithm, ...parts) => {
|
|
93
|
+
const h = crypto.createHash(algorithm);
|
|
94
|
+
for (const p of parts)
|
|
95
|
+
h.update(p);
|
|
96
|
+
return h.digest();
|
|
97
|
+
};
|
|
98
|
+
/** RC4, which OpenSSL 3 leaves out of its default provider. */
|
|
99
|
+
class Rc4 {
|
|
100
|
+
s = new Uint8Array(256);
|
|
101
|
+
i = 0;
|
|
102
|
+
j = 0;
|
|
103
|
+
constructor(key) {
|
|
104
|
+
for (let k = 0; k < 256; k++)
|
|
105
|
+
this.s[k] = k;
|
|
106
|
+
for (let k = 0, j = 0; k < 256; k++) {
|
|
107
|
+
j = (j + this.s[k] + key[k % key.length]) & 255;
|
|
108
|
+
[this.s[k], this.s[j]] = [this.s[j], this.s[k]];
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/** XORs the next keystream bytes into a copy of `data`. */
|
|
112
|
+
crypt(data) {
|
|
113
|
+
const out = new Uint8Array(data.length);
|
|
114
|
+
for (let k = 0; k < data.length; k++) {
|
|
115
|
+
this.i = (this.i + 1) & 255;
|
|
116
|
+
this.j = (this.j + this.s[this.i]) & 255;
|
|
117
|
+
[this.s[this.i], this.s[this.j]] = [this.s[this.j], this.s[this.i]];
|
|
118
|
+
out[k] = data[k] ^ this.s[(this.s[this.i] + this.s[this.j]) & 255];
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
exports.Rc4 = Rc4;
|
|
124
|
+
/** Office binary document RC4 (MS-OFFCRYPTO 2.3.6): MD5, a 40-bit truncation and the salt. */
|
|
125
|
+
function rc4Key(password, salt) {
|
|
126
|
+
const h0 = hash('md5', utf16le(password)).subarray(0, 5);
|
|
127
|
+
const buffer = Buffer.concat(Array.from({ length: 16 }, () => Buffer.concat([h0, salt])));
|
|
128
|
+
const h1 = hash('md5', buffer).subarray(0, 5);
|
|
129
|
+
return block => hash('md5', h1, le32(block));
|
|
130
|
+
}
|
|
131
|
+
/** RC4 CryptoAPI (MS-OFFCRYPTO 2.3.5.2): SHA-1 of the salt and password, then of the block number. A 40-bit key is padded to 128 bits. */
|
|
132
|
+
function cryptoApiKey(password, salt, bits) {
|
|
133
|
+
const h0 = hash('sha1', salt, utf16le(password));
|
|
134
|
+
return block => {
|
|
135
|
+
const h = hash('sha1', h0, le32(block));
|
|
136
|
+
return bits === 40 ? Buffer.concat([h.subarray(0, 5), Buffer.alloc(11)]) : h.subarray(0, bits / 8);
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Opens a workbook that its FILEPASS record (MS-XLS 2.4.117) encrypts with RC4 or RC4 CryptoAPI, with the caller's
|
|
141
|
+
* password or VelvetSweatshop. The records after FILEPASS are encrypted in 1024-byte blocks of the stream, each with its
|
|
142
|
+
* own key. Their headers are not, but take keystream bytes, and some records are never encrypted.
|
|
143
|
+
*/
|
|
144
|
+
function unlockWorkbook(ctx, filePass, stream) {
|
|
145
|
+
const r = new oleobj_1.ByteReader(filePass.body);
|
|
146
|
+
let key;
|
|
147
|
+
let cipher = types_1.OfficeDetail.Rc4_40;
|
|
148
|
+
let bits = 40;
|
|
149
|
+
try {
|
|
150
|
+
const type = r.u16();
|
|
151
|
+
if (type !== 1)
|
|
152
|
+
throw unsupported('XOR obfuscation', { stream });
|
|
153
|
+
const major = r.u16();
|
|
154
|
+
const minor = r.u16();
|
|
155
|
+
if (major === 1 && minor === 1) {
|
|
156
|
+
const salt = r.bytes(16);
|
|
157
|
+
const verifier = r.bytes(16);
|
|
158
|
+
const verifierHash = r.bytes(16);
|
|
159
|
+
for (const p of passwords(ctx)) {
|
|
160
|
+
const k = rc4Key(p.password, salt);
|
|
161
|
+
const plain = new Rc4(k(0)).crypt(Buffer.concat([verifier, verifierHash]));
|
|
162
|
+
if (hash('md5', plain.subarray(0, 16)).equals(Buffer.from(plain.subarray(16, 32)))) {
|
|
163
|
+
key = k;
|
|
164
|
+
opened(ctx, p.supplied, types_1.OfficeDetail.Rc4_40, stream, 40);
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
else if ((major === 2 || major === 3 || major === 4) && minor === 2) {
|
|
170
|
+
r.skip(4);
|
|
171
|
+
const headerSize = r.u32();
|
|
172
|
+
const header = new oleobj_1.ByteReader(r.bytes(headerSize));
|
|
173
|
+
header.skip(8);
|
|
174
|
+
const algorithm = header.u32();
|
|
175
|
+
header.skip(4);
|
|
176
|
+
bits = header.u32() || 40;
|
|
177
|
+
if (algorithm !== 0x6801 || bits < 40 || bits > 128 || bits % 8)
|
|
178
|
+
throw unsupported(`CryptoAPI algorithm 0x${algorithm.toString(16)} with a ${bits}-bit key`, { stream });
|
|
179
|
+
cipher = bits === 40 ? types_1.OfficeDetail.Rc4_40 : bits === 128 ? types_1.OfficeDetail.Rc4_128 : types_1.OfficeDetail.Rc4Other;
|
|
180
|
+
const saltSize = r.u32();
|
|
181
|
+
const salt = r.bytes(saltSize);
|
|
182
|
+
const verifier = r.bytes(16);
|
|
183
|
+
const hashSize = r.u32();
|
|
184
|
+
const verifierHash = r.bytes(Math.min(hashSize, r.left));
|
|
185
|
+
for (const p of passwords(ctx)) {
|
|
186
|
+
const k = cryptoApiKey(p.password, salt, bits);
|
|
187
|
+
const plain = new Rc4(k(0)).crypt(Buffer.concat([verifier, verifierHash]));
|
|
188
|
+
if (hash('sha1', plain.subarray(0, 16)).equals(Buffer.from(plain.subarray(16, 36)))) {
|
|
189
|
+
key = k;
|
|
190
|
+
opened(ctx, p.supplied, cipher, stream, bits);
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
else
|
|
196
|
+
throw unsupported(`encryption version ${major}.${minor}`, { stream });
|
|
197
|
+
}
|
|
198
|
+
catch (e) {
|
|
199
|
+
if (e instanceof oleobj_1.Truncated)
|
|
200
|
+
throw required('a FILEPASS record cut short', { stream });
|
|
201
|
+
throw e;
|
|
202
|
+
}
|
|
203
|
+
if (!key)
|
|
204
|
+
throw required('the workbook is encrypted with a password that is not VelvetSweatshop', { stream });
|
|
205
|
+
return biffDecrypt(key);
|
|
206
|
+
}
|
|
207
|
+
/** Records MS-XLS 2.2.10 never encrypts: BOF, FILEPASS, UsrExcl, FileLock, InterfaceHdr, RRDInfo and RRDHead. */
|
|
208
|
+
const PLAIN = new Set([0x0809, 0x002f, 0x0194, 0x0195, 0x00e1, 0x0196, 0x0138]);
|
|
209
|
+
/** Only the records after FILEPASS reach the function it returns. */
|
|
210
|
+
function biffDecrypt(key) {
|
|
211
|
+
let block = -1;
|
|
212
|
+
let stream = new Uint8Array(0);
|
|
213
|
+
const keystream = (b) => {
|
|
214
|
+
if (b !== block) {
|
|
215
|
+
block = b;
|
|
216
|
+
stream = new Rc4(key(b)).crypt(new Uint8Array(1024));
|
|
217
|
+
}
|
|
218
|
+
return stream;
|
|
219
|
+
};
|
|
220
|
+
return r => {
|
|
221
|
+
if (PLAIN.has(r.type))
|
|
222
|
+
return r.body;
|
|
223
|
+
const out = new Uint8Array(r.body);
|
|
224
|
+
// BoundSheet8's lbPlyPos stays plain, so a reader can find each sheet without the key.
|
|
225
|
+
const from = r.type === 0x0085 ? 4 : 0;
|
|
226
|
+
for (let k = from; k < out.length; k++) {
|
|
227
|
+
const at = r.offset + 4 + k;
|
|
228
|
+
out[k] ^= keystream(Math.floor(at / 1024))[at % 1024];
|
|
229
|
+
}
|
|
230
|
+
return out;
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Inspects an encrypted OOXML package: EncryptionInfo (MS-OFFCRYPTO 2.3.4) names the cipher, and the caller's password
|
|
235
|
+
* or VelvetSweatshop gives the key. The package it decrypts goes through the OOXML rules, read in place: Agile
|
|
236
|
+
* encryption works in 4,096-byte segments and Standard in 16-byte blocks, so nothing is decrypted that is not read.
|
|
237
|
+
*/
|
|
238
|
+
async function openPackage(ctx, cfb, info, pkg) {
|
|
239
|
+
const source = cfb.source(info);
|
|
240
|
+
const version = await source.read(0, 8);
|
|
241
|
+
const major = version.length >= 4 ? version[0] | (version[1] << 8) : 0;
|
|
242
|
+
const minor = version.length >= 4 ? version[2] | (version[3] << 8) : 0;
|
|
243
|
+
let cipher;
|
|
244
|
+
// Agile's XML goes to the tokenizer in pieces. Standard's binary header is a few hundred bytes.
|
|
245
|
+
if (major === 4 && minor === 4)
|
|
246
|
+
cipher = await agile(ctx, chunks(source, 8, info.size));
|
|
247
|
+
else if ((major === 3 || major === 4) && minor === 2)
|
|
248
|
+
cipher = standard(await source.read(8, STANDARD_INFO));
|
|
249
|
+
else
|
|
250
|
+
throw unsupported(`encryption version ${major}.${minor}`, { stream: info.name });
|
|
251
|
+
const head = await cfb.source(pkg).read(0, 8);
|
|
252
|
+
if (head.length < 8)
|
|
253
|
+
throw required('an EncryptedPackage stream cut short', { stream: pkg.name });
|
|
254
|
+
const size = Number(Buffer.from(head).readBigUInt64LE(0));
|
|
255
|
+
if (size > pkg.size - 8)
|
|
256
|
+
throw required('an EncryptedPackage stream shorter than the size it gives', { stream: pkg.name });
|
|
257
|
+
for (const p of passwords(ctx)) {
|
|
258
|
+
(0, errors_1.checkTime)(ctx.settings.deadline);
|
|
259
|
+
const key = cipher.key(p.password);
|
|
260
|
+
if (!key)
|
|
261
|
+
continue;
|
|
262
|
+
opened(ctx, p.supplied, cipher.cipher.detail, pkg.name, cipher.cipher.bits);
|
|
263
|
+
const data = cfb.source(pkg);
|
|
264
|
+
return { source: cipher.source((offset, length) => data.read(8 + offset, length), size, key), size };
|
|
265
|
+
}
|
|
266
|
+
throw required('the package is encrypted with a password that is not VelvetSweatshop', { stream: pkg.name });
|
|
267
|
+
}
|
|
268
|
+
/** AES without padding over whole blocks: ECB, or CBC from `iv`. */
|
|
269
|
+
function aes(cipher, key, data, iv) {
|
|
270
|
+
const d = crypto.createDecipheriv(iv ? cipher.cbc : cipher.ecb, key, iv ?? null);
|
|
271
|
+
d.setAutoPadding(false);
|
|
272
|
+
const whole = data.subarray(0, data.length - (data.length % 16));
|
|
273
|
+
return Buffer.concat([d.update(whole), d.final()]);
|
|
274
|
+
}
|
|
275
|
+
/** Standard encryption (MS-OFFCRYPTO 2.3.4.5): AES-ECB with a key from SHA-1 over 50,000 rounds. */
|
|
276
|
+
function standard(b) {
|
|
277
|
+
const r = new oleobj_1.ByteReader(b);
|
|
278
|
+
try {
|
|
279
|
+
const headerSize = r.u32();
|
|
280
|
+
const header = new oleobj_1.ByteReader(r.bytes(headerSize));
|
|
281
|
+
header.skip(8);
|
|
282
|
+
const algorithm = header.u32();
|
|
283
|
+
const hashAlgorithm = header.u32();
|
|
284
|
+
const bits = header.u32();
|
|
285
|
+
// CALG_AES_128, _192 and _256, which name their key size, with SHA-1.
|
|
286
|
+
const cipher = AES.get(bits);
|
|
287
|
+
if (!cipher || algorithm !== STANDARD_AES.get(bits) || (hashAlgorithm !== 0x8004 && hashAlgorithm !== 0))
|
|
288
|
+
throw unsupported(`Standard encryption with algorithm 0x${algorithm.toString(16)}, hash 0x${hashAlgorithm.toString(16)} and a ${bits}-bit key`);
|
|
289
|
+
r.skip(4);
|
|
290
|
+
const salt = r.bytes(16);
|
|
291
|
+
const verifier = r.bytes(16);
|
|
292
|
+
r.skip(4);
|
|
293
|
+
const verifierHash = r.bytes(32);
|
|
294
|
+
const keyOf = (password) => {
|
|
295
|
+
let h = hash('sha1', salt, utf16le(password));
|
|
296
|
+
for (let i = 0; i < 50000; i++)
|
|
297
|
+
h = hash('sha1', le32(i), h);
|
|
298
|
+
const final = hash('sha1', h, le32(0));
|
|
299
|
+
const x = (fill) => {
|
|
300
|
+
const buf = Buffer.alloc(64, fill);
|
|
301
|
+
for (let i = 0; i < final.length; i++)
|
|
302
|
+
buf[i] ^= final[i];
|
|
303
|
+
return hash('sha1', buf);
|
|
304
|
+
};
|
|
305
|
+
return Buffer.concat([x(0x36), x(0x5c)]).subarray(0, bits / 8);
|
|
306
|
+
};
|
|
307
|
+
return {
|
|
308
|
+
cipher,
|
|
309
|
+
key(password) {
|
|
310
|
+
const key = keyOf(password);
|
|
311
|
+
const v = aes(cipher, key, verifier);
|
|
312
|
+
const vh = aes(cipher, key, verifierHash);
|
|
313
|
+
return hash('sha1', v).equals(Buffer.from(vh.subarray(0, 20))) ? key : undefined;
|
|
314
|
+
},
|
|
315
|
+
source: (read, size, key) => blockSource(size, 16, async (index, count) => aes(cipher, key, await read(index * 16, count * 16))),
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
catch (e) {
|
|
319
|
+
if (e instanceof oleobj_1.Truncated)
|
|
320
|
+
throw required('a Standard EncryptionInfo cut short');
|
|
321
|
+
throw e;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
/** Agile encryption (MS-OFFCRYPTO 2.3.4.10): an XML descriptor, a password key encryptor, and AES-CBC in 4,096-byte segments. */
|
|
325
|
+
async function agile(ctx, xml) {
|
|
326
|
+
const found = {};
|
|
327
|
+
const ENCRYPTION = 'http://schemas.microsoft.com/office/2006/encryption';
|
|
328
|
+
const PASSWORD = 'http://schemas.microsoft.com/office/2006/keyEncryptor/password';
|
|
329
|
+
await ctx.parseXml('EncryptionInfo', xml, {
|
|
330
|
+
start(el) {
|
|
331
|
+
const all = Object.fromEntries(el.attributes.filter(a => a.ns === '').map(a => [a.local, a.value]));
|
|
332
|
+
if (el.ns === ENCRYPTION && el.local === 'keyData')
|
|
333
|
+
found.keyData ??= all;
|
|
334
|
+
else if (el.ns === PASSWORD && el.local === 'encryptedKey')
|
|
335
|
+
found.encryptedKey ??= all;
|
|
336
|
+
},
|
|
337
|
+
});
|
|
338
|
+
const kd = found.keyData;
|
|
339
|
+
const ek = found.encryptedKey;
|
|
340
|
+
if (!kd || !ek)
|
|
341
|
+
throw required('an Agile descriptor without key data or a password key');
|
|
342
|
+
/** The hash and cipher a key data or key encryptor element names, when both are on the allowlist. */
|
|
343
|
+
const algorithms = (a) => {
|
|
344
|
+
const h = HASHES.get(a.hashAlgorithm);
|
|
345
|
+
const c = AES.get(Number(a.keyBits));
|
|
346
|
+
if (!h || !c || a.cipherAlgorithm !== 'AES' || a.cipherChaining !== 'ChainingModeCBC' || Number(a.blockSize) !== 16)
|
|
347
|
+
throw unsupported(`Agile encryption with ${shown(a.cipherAlgorithm)} in ${shown(a.cipherChaining)}, a ${shown(a.keyBits)}-bit key and ${shown(a.hashAlgorithm)}`);
|
|
348
|
+
return { h, c };
|
|
349
|
+
};
|
|
350
|
+
const { h: dh, c: dc } = algorithms(kd);
|
|
351
|
+
const { h: eh, c: ec } = algorithms(ek);
|
|
352
|
+
const spin = Number(ek.spinCount);
|
|
353
|
+
if (!Number.isInteger(spin) || spin < 0 || spin > MAX_SPIN)
|
|
354
|
+
throw required('an Agile spin count past 10,000,000');
|
|
355
|
+
const b64 = (s) => Buffer.from(s ?? '', 'base64');
|
|
356
|
+
const pad = (b, n, fill) => (b.length >= n ? b.subarray(0, n) : Buffer.concat([b, Buffer.alloc(n - b.length, fill)]));
|
|
357
|
+
const salt = b64(ek.saltValue);
|
|
358
|
+
const BLOCKS = { input: Buffer.from('fea7d2763b4b9e79', 'hex'), value: Buffer.from('d7aa0f6d3061344e', 'hex'), key: Buffer.from('146e0be7abacd0d6', 'hex') };
|
|
359
|
+
const dsalt = b64(kd.saltValue);
|
|
360
|
+
return {
|
|
361
|
+
cipher: dc,
|
|
362
|
+
key(password) {
|
|
363
|
+
let h = hash(eh, salt, utf16le(password));
|
|
364
|
+
for (let i = 0; i < spin; i++) {
|
|
365
|
+
if (i % 65536 === 0)
|
|
366
|
+
(0, errors_1.checkTime)(ctx.settings.deadline);
|
|
367
|
+
h = hash(eh, le32(i), h);
|
|
368
|
+
}
|
|
369
|
+
const derive = (block) => pad(hash(eh, h, block), ec.bits / 8, 0x36);
|
|
370
|
+
const iv = pad(salt, 16, 0x36);
|
|
371
|
+
const input = aes(ec, derive(BLOCKS.input), b64(ek.encryptedVerifierHashInput), iv).subarray(0, Number(ek.saltSize) || 16);
|
|
372
|
+
const expected = aes(ec, derive(BLOCKS.value), b64(ek.encryptedVerifierHashValue), iv);
|
|
373
|
+
const got = hash(eh, input);
|
|
374
|
+
if (!got.equals(Buffer.from(expected.subarray(0, got.length))))
|
|
375
|
+
return undefined;
|
|
376
|
+
return aes(ec, derive(BLOCKS.key), b64(ek.encryptedKeyValue), iv).subarray(0, dc.bits / 8);
|
|
377
|
+
},
|
|
378
|
+
source: (read, size, key) => blockSource(size, 4096, async (index, count) => {
|
|
379
|
+
const raw = await read(index * 4096, count * 4096);
|
|
380
|
+
const out = [];
|
|
381
|
+
for (let k = 0; k * 4096 < raw.length; k++)
|
|
382
|
+
out.push(aes(dc, key, raw.subarray(k * 4096, (k + 1) * 4096), pad(hash(dh, dsalt, le32(index + k)), 16, 0x36)));
|
|
383
|
+
return Buffer.concat(out);
|
|
384
|
+
}),
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
/** A source over decrypted data, decrypting the whole blocks each read covers, and keeping the last ones decrypted. */
|
|
388
|
+
function blockSource(size, unit, decrypt) {
|
|
389
|
+
let cached;
|
|
390
|
+
return {
|
|
391
|
+
size: async () => size,
|
|
392
|
+
async read(offset, length) {
|
|
393
|
+
const end = Math.min(size, offset + length);
|
|
394
|
+
const first = Math.floor(offset / unit);
|
|
395
|
+
const last = Math.floor((end - 1) / unit);
|
|
396
|
+
let bytes;
|
|
397
|
+
if (cached && cached.index <= first && cached.index + cached.bytes.length / unit > last)
|
|
398
|
+
bytes = cached.bytes.subarray((first - cached.index) * unit);
|
|
399
|
+
else {
|
|
400
|
+
// A small read decrypts 64 KiB from where it starts, since the ZIP reader reads on from there.
|
|
401
|
+
const count = Math.max(last - first + 1, Math.ceil(65536 / unit));
|
|
402
|
+
bytes = await decrypt(first, count);
|
|
403
|
+
cached = { index: first, bytes };
|
|
404
|
+
}
|
|
405
|
+
return bytes.subarray(offset - first * unit, end - first * unit);
|
|
406
|
+
},
|
|
407
|
+
};
|
|
408
|
+
}
|
package/dist/ole.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { CompoundFile } from './cfb';
|
|
2
|
+
import type { RunContext } from './context';
|
|
3
|
+
/** The families a compound file can hold, by the stream or storage that marks each. */
|
|
4
|
+
type Family = 'word' | 'excel' | 'powerpoint' | 'visio' | 'outlook' | 'publisher' | 'project' | 'encrypted';
|
|
5
|
+
/** What the root of a compound file holds, read by the names of its children, ignoring case as MS-CFB does. */
|
|
6
|
+
export declare function families(cfb: CompoundFile): Family[];
|
|
7
|
+
/**
|
|
8
|
+
* Inspects a compound file: the rules every compound file gets, over its whole tree, and then those of the format
|
|
9
|
+
* its root names.
|
|
10
|
+
*/
|
|
11
|
+
export declare function inspectCompound(ctx: RunContext, cfb: CompoundFile): Promise<void>;
|
|
12
|
+
export {};
|
package/dist/ole.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.families = families;
|
|
4
|
+
exports.inspectCompound = inspectCompound;
|
|
5
|
+
const errors_1 = require("./errors");
|
|
6
|
+
const msg_1 = require("./msg");
|
|
7
|
+
const objects_1 = require("./objects");
|
|
8
|
+
const offcrypto_1 = require("./offcrypto");
|
|
9
|
+
const package_1 = require("./package");
|
|
10
|
+
const ppt_1 = require("./ppt");
|
|
11
|
+
const types_1 = require("./types");
|
|
12
|
+
const word_1 = require("./word");
|
|
13
|
+
const xls_1 = require("./xls");
|
|
14
|
+
const zip_1 = require("./zip");
|
|
15
|
+
const f = (format, mimeType) => ({ format, mimeType });
|
|
16
|
+
const upper = (e) => e.name.toUpperCase();
|
|
17
|
+
const PUBLISHER_CLSIDS = new Set(['00021200-0000-0000-00C0-000000000046', '00021201-0000-0000-00C0-000000000046']);
|
|
18
|
+
/** What the root of a compound file holds, read by the names of its children, ignoring case as MS-CFB does. */
|
|
19
|
+
function families(cfb) {
|
|
20
|
+
const names = new Map(cfb.root.children.map(c => [upper(c), c]));
|
|
21
|
+
const stream = (n) => names.get(n)?.kind === 'stream';
|
|
22
|
+
const out = [];
|
|
23
|
+
if (stream('ENCRYPTIONINFO') && stream('ENCRYPTEDPACKAGE'))
|
|
24
|
+
out.push('encrypted');
|
|
25
|
+
if (stream('WORDDOCUMENT'))
|
|
26
|
+
out.push('word');
|
|
27
|
+
if (stream('WORKBOOK') || stream('BOOK'))
|
|
28
|
+
out.push('excel');
|
|
29
|
+
if (stream('POWERPOINT DOCUMENT'))
|
|
30
|
+
out.push('powerpoint');
|
|
31
|
+
if (stream('VISIODOCUMENT'))
|
|
32
|
+
out.push('visio');
|
|
33
|
+
if (stream('__PROPERTIES_VERSION1.0'))
|
|
34
|
+
out.push('outlook');
|
|
35
|
+
// Publisher 98 and later keep their content in a Quill storage. Publisher 95 has none, and names itself by class id.
|
|
36
|
+
if (names.get('QUILL')?.kind === 'storage' || (PUBLISHER_CLSIDS.has(cfb.root.clsid) && stream('CONTENTS')))
|
|
37
|
+
out.push('publisher');
|
|
38
|
+
// Project keeps its data in a storage named by three spaces and a number, beside a Props stream.
|
|
39
|
+
if ([...names.keys()].some(n => /^PROPS\d*$/.test(n)) && cfb.root.children.some(c => c.kind === 'storage' && /^ {3}\d+$/.test(c.name)))
|
|
40
|
+
out.push('project');
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Inspects a compound file: the rules every compound file gets, over its whole tree, and then those of the format
|
|
45
|
+
* its root names.
|
|
46
|
+
*/
|
|
47
|
+
async function inspectCompound(ctx, cfb) {
|
|
48
|
+
const found = families(cfb);
|
|
49
|
+
// Word 2007 writes a Word document beside an encrypted package, for readers that cannot decrypt it.
|
|
50
|
+
const main = found.filter(x => !(x === 'word' && found.includes('encrypted')));
|
|
51
|
+
if (main.length > 1)
|
|
52
|
+
throw new errors_1.StopError(types_1.OfficeCategory.Package, types_1.OfficeDetail.AmbiguousFormat, `The compound file holds ${main.join(' and ')} streams`, { reason: `${main.join(' and ')} streams` });
|
|
53
|
+
if (main.length === 0)
|
|
54
|
+
throw new errors_1.StopError(types_1.OfficeCategory.Package, types_1.OfficeDetail.Unrecognized, 'The compound file holds no Office document stream', { reason: 'a compound file of another kind' });
|
|
55
|
+
const family = main[0];
|
|
56
|
+
ctx.format = formatOf(family, cfb);
|
|
57
|
+
await (0, objects_1.compoundRules)(ctx, cfb, '', false);
|
|
58
|
+
const stream = (name) => {
|
|
59
|
+
const e = cfb.child(cfb.root, name);
|
|
60
|
+
return e?.kind === 'stream' ? e : undefined;
|
|
61
|
+
};
|
|
62
|
+
// Beside an encrypted package, Word 2007 writes a WordDocument stream for older readers, of zeros in the files at
|
|
63
|
+
// hand. Those readers read it if it holds a document, so it gets the Word rules when it does.
|
|
64
|
+
const word = stream('WordDocument');
|
|
65
|
+
if (word) {
|
|
66
|
+
const r = await (0, word_1.wordRules)(ctx, cfb, word, family === 'encrypted');
|
|
67
|
+
if (r?.template && family === 'word')
|
|
68
|
+
ctx.format = f('dot', 'application/msword');
|
|
69
|
+
}
|
|
70
|
+
if (family === 'excel') {
|
|
71
|
+
// Excel 97 to 2003 reads Workbook, and Excel 5.0 and 95 read Book. A file can hold both.
|
|
72
|
+
let template = false;
|
|
73
|
+
let addIn = false;
|
|
74
|
+
for (const name of ['Workbook', 'Book']) {
|
|
75
|
+
const s = stream(name);
|
|
76
|
+
if (!s)
|
|
77
|
+
continue;
|
|
78
|
+
const r = await (0, xls_1.excelRules)(ctx, cfb, s, offcrypto_1.unlockWorkbook);
|
|
79
|
+
template ||= r.template;
|
|
80
|
+
addIn ||= r.addIn;
|
|
81
|
+
}
|
|
82
|
+
if (addIn)
|
|
83
|
+
ctx.format = f('xla', 'application/vnd.ms-excel');
|
|
84
|
+
else if (template)
|
|
85
|
+
ctx.format = f('xlt', 'application/vnd.ms-excel');
|
|
86
|
+
// Excel keeps the data of a workbook's ActiveX controls in one Ctls stream.
|
|
87
|
+
const controls = stream('Ctls');
|
|
88
|
+
if (controls && controls.size > 0)
|
|
89
|
+
ctx.report(types_1.OfficeCategory.Control, types_1.OfficeDetail.ActiveX, controls.name, { stream: controls.name });
|
|
90
|
+
}
|
|
91
|
+
if (family === 'outlook')
|
|
92
|
+
await (0, msg_1.messageRules)(ctx, cfb, cfb.root);
|
|
93
|
+
const ppt = stream('PowerPoint Document');
|
|
94
|
+
if (family === 'powerpoint' && ppt)
|
|
95
|
+
await (0, ppt_1.powerPointRules)(ctx, cfb, ppt);
|
|
96
|
+
const info = stream('EncryptionInfo');
|
|
97
|
+
const pkg = stream('EncryptedPackage');
|
|
98
|
+
if (family === 'encrypted' && info && pkg) {
|
|
99
|
+
// The package goes through the OOXML rules once a password opens it, and names the format.
|
|
100
|
+
const { source } = await (0, offcrypto_1.openPackage)(ctx, cfb, info, pkg);
|
|
101
|
+
const zip = await zip_1.ZipArchive.open(source, ctx.settings, ctx.settings.deadline);
|
|
102
|
+
for (const n of zip.notes)
|
|
103
|
+
ctx.report(types_1.OfficeCategory.Container, n.detail, pkg.name, { bytes: n.bytes });
|
|
104
|
+
await (0, package_1.inspectPackage)(ctx.withZip(zip));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
function formatOf(family, cfb) {
|
|
108
|
+
switch (family) {
|
|
109
|
+
case 'word':
|
|
110
|
+
return f('doc', 'application/msword');
|
|
111
|
+
case 'excel':
|
|
112
|
+
return f('xls', 'application/vnd.ms-excel');
|
|
113
|
+
case 'powerpoint':
|
|
114
|
+
return f('ppt', 'application/vnd.ms-powerpoint');
|
|
115
|
+
case 'visio':
|
|
116
|
+
return f('vsd', 'application/vnd.visio');
|
|
117
|
+
case 'outlook':
|
|
118
|
+
return cfb.root.clsid === '0006F046-0000-0000-C000-000000000046' ? f('oft', 'application/vnd.ms-outlook') : f('msg', 'application/vnd.ms-outlook');
|
|
119
|
+
case 'publisher':
|
|
120
|
+
return f('pub', 'application/x-mspublisher');
|
|
121
|
+
case 'project':
|
|
122
|
+
return f('mpp', 'application/vnd.ms-project');
|
|
123
|
+
default:
|
|
124
|
+
// An encrypted package names its format only once it is decrypted.
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
}
|
package/dist/oleobj.d.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/** Thrown by ByteReader when a structure runs past the end of its bytes. Parsers catch it and keep what they read. */
|
|
2
|
+
export declare class Truncated extends Error {
|
|
3
|
+
}
|
|
4
|
+
/** Runs `read`. A structure that runs past its data gives what `partial` returns instead, and any other error goes up. */
|
|
5
|
+
export declare function bounded<T>(read: () => T, partial: () => T): T;
|
|
6
|
+
/** Little-endian reads over bytes in memory, bounded by their end. */
|
|
7
|
+
export declare class ByteReader {
|
|
8
|
+
readonly b: Uint8Array;
|
|
9
|
+
pos: number;
|
|
10
|
+
constructor(b: Uint8Array);
|
|
11
|
+
get left(): number;
|
|
12
|
+
private need;
|
|
13
|
+
u8(): number;
|
|
14
|
+
u16(): number;
|
|
15
|
+
u32(): number;
|
|
16
|
+
bytes(n: number): Uint8Array;
|
|
17
|
+
skip(n: number): void;
|
|
18
|
+
/** An ANSI string ended by a NUL within `max` bytes, read as Latin-1. */
|
|
19
|
+
ansiz(max?: number): string;
|
|
20
|
+
/** A 32-bit byte count, then that many bytes of ANSI text whose last is a NUL. */
|
|
21
|
+
lengthPrefixedAnsi(max?: number): string;
|
|
22
|
+
/** A 32-bit count of UTF-16 code units, then the units, the last of which may be a NUL. */
|
|
23
|
+
lengthPrefixedUnicode(max?: number): string;
|
|
24
|
+
}
|
|
25
|
+
export declare const latin1: (b: Uint8Array) => string;
|
|
26
|
+
export declare const utf16: (b: Uint8Array) => string;
|
|
27
|
+
/** The class an OLE object names in its CompObj stream (MS-OLEDS 2.3.8): the user type and the ProgID. */
|
|
28
|
+
export interface CompObj {
|
|
29
|
+
userType?: string;
|
|
30
|
+
progId?: string;
|
|
31
|
+
}
|
|
32
|
+
export declare function parseCompObj(b: Uint8Array): CompObj;
|
|
33
|
+
/** What a moniker names: a URL moniker's URL, or a file moniker's path. */
|
|
34
|
+
export interface MonikerTarget {
|
|
35
|
+
target: string;
|
|
36
|
+
/** A URL moniker, whose target loads from wherever it names, however it is spelled. */
|
|
37
|
+
url: boolean;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The address a moniker names: a URL moniker's URL, a file moniker's path, or the first of a composite's that names
|
|
41
|
+
* one. Other monikers name nothing this reads, and give ''. The reader stops after the moniker it reads.
|
|
42
|
+
*/
|
|
43
|
+
export declare function readMoniker(r: ByteReader, depth?: number): MonikerTarget;
|
|
44
|
+
/** What an OLE stream (MS-OLEDS 2.3.3) says: whether the object is a link, and what it links to. */
|
|
45
|
+
export interface OleStreamInfo extends MonikerTarget {
|
|
46
|
+
linked: boolean;
|
|
47
|
+
}
|
|
48
|
+
export declare function parseOleStream(b: Uint8Array): OleStreamInfo;
|
|
49
|
+
/** An embedded file in Packager's native data, which oleobj reads the same way. */
|
|
50
|
+
export interface PackagedFile {
|
|
51
|
+
fileName: string;
|
|
52
|
+
sourcePath: string;
|
|
53
|
+
tempPath: string;
|
|
54
|
+
/** False when the data names a file it does not hold, a link. */
|
|
55
|
+
embedded: boolean;
|
|
56
|
+
size: number;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Packager's native data, as oleobj reads it: a 16-bit type, the label and source path ended by NULs, eight bytes,
|
|
60
|
+
* the temporary path ended by a NUL, and the size of the file's data. Without the size it is a link. Undefined when it
|
|
61
|
+
* does not read that far.
|
|
62
|
+
*/
|
|
63
|
+
export declare function parsePackage(b: Uint8Array): PackagedFile | undefined;
|
|
64
|
+
/** An OLE 1.0 object (MS-OLEDS 2.2), as RTF's objdata holds one. */
|
|
65
|
+
export interface Ole1Object {
|
|
66
|
+
/** 1 linked, 2 embedded, 5 a static picture. */
|
|
67
|
+
formatId: number;
|
|
68
|
+
className: string;
|
|
69
|
+
topicName: string;
|
|
70
|
+
itemName: string;
|
|
71
|
+
/** The native data of an embedded object. */
|
|
72
|
+
data?: Uint8Array;
|
|
73
|
+
}
|
|
74
|
+
export declare function parseOle1(b: Uint8Array): Ole1Object | undefined;
|