xls-codec 4.9.0 → 4.10.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/README.md CHANGED
@@ -62,7 +62,7 @@ Each deliberate rather than overlooked:
62
62
  - **Conditional formatting, CF12's filter-dispatched template rules resolved for real (ExaDev/documents.js#1106).** `ct` 0x05 ("filter") dispatches further through `icfTemplate`, alongside a 16-byte `CFExTemplateParams` block always present regardless of `ct`. Of that block's five variants, only two need real parsing -- `CFExFilterParams` (top10's own `fTop`/`fPercent`/`iParam`) and `CFExAveragesTemplateParams` (the aboveAverage family's own standard-deviation count) -- since `CFExDefaultTemplateParams` (duplicateValues/uniqueValues/the four blank/error conditions) is 16 reserved bytes and `CFExDateTemplateParams`'s own `dateOp` field is a fixed restatement of `icfTemplate` for all ten date/time periods, so both dispatch directly off `icfTemplate` with no further byte reading. Unlike colour scale/data bar/icon set, [MS-XLS] does not force a `ct` 0x05 rule's own `cbDxf` to zero, so its `DXFN12` can carry a genuine font/fill override; that structure resolves through the identical `parseDxfStyle` base `CF`'s own `DXFN` already uses. A malformed `iParam` of zero degrades a top10 rule to absent rather than promoting a `rank` `ContentSheetConditionalFormatSchema` itself requires to be positive.
63
63
  - **Conditional formatting, `containsText`/`notContainsText`/`beginsWith`/`endsWith` and `CFEx`'s own legacy-`CF` extension resolved for real (ExaDev/documents.js#1100).** `icfTemplate` 0x0008 ("Contains text") is the one filter-dispatched template that is NOT actually a `ct` 0x05 rule: `CFExTextTemplateParams` carries only `ctp`, naming which of the four text sub-types a rule is, and neither it nor `ct` 0x05's own `CFFilter` `rgbCT` has anywhere to carry the literal search text (confirmed against a second, independent transcription of both structures -- [kinkou/unxls](https://github.com/kinkou/unxls)'s own `cfextexttemplateparams`/`cffilter` readers, which read `CFFilter` as an unconditional fixed six bytes with no variable trailer). Excel instead expresses these four rule kinds as a genuine `ct` 0x02 formula condition -- either directly on a `CF12` record, or (to stay evaluable by pre-2007 Excel, which silently skips an unrecognised `CF12`/`CondFmt12` "future record" wholesale) via a `CFEx` record ([MS-XLS] 2.4.63) extending an ordinary legacy `CF` -- with the literal text present as a string-constant (`PtgStr`) operand somewhere in the formula's own token stream, matching Excel's real, independently-confirmed generated formula for each sub-type (`NOT(ISERROR(SEARCH("text",cell)))` for `containsText`, and the `ISERROR`/`LEFT`+`LEN`/`RIGHT`+`LEN` equivalents for the other three -- LibreOffice's own `xecontent.cxx` `GetFixedFormula` emits exactly these shapes for its xlsx compatibility formula). `readCfTextFilterRule` (`src/workbook/conditional-format-12.ts`) extracts that operand via `ptg.ts`'s `extractFirstStringLiteral`, reached from both `readCf12`'s own `ct` 0x02 branch and `readCfEx` (`src/workbook/conditional-format-ex.ts`). A `CFEx` record with `fIsCF12` set (extending a genuine `CF12` record rather than a legacy `CF`) stays unread: that `CF12` carries no ranges of its own -- a `CondFmt12` normally supplies them -- and this reader has no established link back to one for it. `CFEx`'s own `nID` cross-references the `nID` field [MS-XLS] 2.1.7.20.6's own worksheet-substream grammar places on every `CondFmt` record (every `CFEx` on a sheet follows every `CondFmt`/`CondFmt12` group, per that same grammar), so `workbook/sheet.ts` keeps a per-`nID` map of each group's own resolved ranges and raw `CF` operands as it walks the substream, for a later `CFEx` to resolve its own `icf` index against.
64
64
  - **Not read at all:** a `CFEx` record that extends a genuine `CF12` record rather than a legacy `CF` (see above). Defined names (`Lbl`) are read only for the two built-in ones a sheet's print range and repeated header bands live in ([Print settings](#print-settings)); a user-defined name has nowhere to land in `document-schema.js`'s spreadsheet model, so it is skipped.
65
- - **RC4-encrypted workbooks, decrypted for real (ExaDev/documents.js#1108).** A `FilePass` record ([MS-XLS] 2.4.117) means every record after it is ciphertext under one of three schemes; this reader implements exactly one, the [MS-OFFCRYPTO] 2.3.6.1 "RC4 encryption header" -- `readXlsContent`/`readXls` take an optional `password`, verify it against the header's own `EncryptedVerifier`/`EncryptedVerifierHash` pair, then decrypt every subsequent record's data (`src/workbook/encryption.ts`) using the RC4/MD5 primitives [`archive-codec`](../archive-codec/README.md#legacy-office-encryption) shares with `doc-codec`/`ppt-codec`'s own eventual integration. [MS-XLS] 2.2.10's own exclusion list -- `BOF`, `FilePass`, and four shared-workbook revision-tracking records this package otherwise never reads, plus `BoundSheet8`'s own `lbPlyPos` field specifically -- is honoured exactly, re-derived independently in this package's own tests rather than trusted by construction. A missing or incorrect password throws rather than returning a garbled document; the newer "RC4 CryptoAPI encryption header" scheme and XOR obfuscation are explicitly out of scope, tracked separately on [#922](https://github.com/ExaDev/documents.js/issues/922).
65
+ - **RC4-encrypted and XOR-obfuscated workbooks, decrypted for real (ExaDev/documents.js#1108, #922).** A `FilePass` record ([MS-XLS] 2.4.117) means every record after it is ciphertext under one of three schemes; this reader implements two of them, dispatching on the record's own `wEncryptionType` -- the [MS-OFFCRYPTO] 2.3.6.1 "RC4 encryption header" and 2.3.7's own XOR obfuscation (Method 1) -- `readXlsContent`/`readXls` take an optional `password`, verify it against the header's own fields (RC4's `EncryptedVerifier`/`EncryptedVerifierHash`, decrypted then compared; XOR obfuscation's `key`/`verificationBytes`, plain unencrypted checksums compared directly), then decrypt every subsequent record's data (`src/workbook/encryption.ts`) using the RC4/MD5 and XOR-obfuscation primitives [`archive-codec`](../archive-codec/README.md#xor-obfuscation-xlsdoc) shares with `doc-codec`. XOR obfuscation's own per-record `XorArrayIndex` -- `(streamOffset + recordDataLength) % 16`, `recordDataLength` always the record's own full declared size regardless of how much of a given span is actually being decrypted -- is confirmed against a genuine Excel-generated fixture, not just the published spec text (see `archive-codec`'s own account of why that spec text alone is not trustworthy here). [MS-XLS] 2.2.10's own exclusion list -- `BOF`, `FilePass`, and four shared-workbook revision-tracking records this package otherwise never reads, plus `BoundSheet8`'s own `lbPlyPos` field specifically -- is honoured exactly for both schemes, re-derived independently in this package's own tests rather than trusted by construction. A missing or incorrect password throws rather than returning a garbled document; the newer "RC4 CryptoAPI encryption header" scheme remains explicitly out of scope (ppt-codec's own encryption, tracked separately on [#1116](https://github.com/ExaDev/documents.js/issues/1116)).
66
66
 
67
67
  This package is wired into `documents.js`'s conversion registry (`xlsToPdf`/`pdfToXls`, `convertDocument("xls", ...)`, and every same-variant spreadsheet bridge) — see that package's own README Fidelity table for exactly which pairs route and which don't. Remaining read+write scope gaps (formula writing and per-cell font) are tracked on [#815](https://github.com/ExaDev/documents.js/issues/815).
68
68
 
package/dist/content.cjs CHANGED
@@ -83,7 +83,7 @@ function mapPrintSettings(raw, names) {
83
83
  /**
84
84
  * Reads a .xls file's bytes into a ContentDocument.
85
85
  *
86
- * The counterpart of ooxml.js's readXlsxContent, producing the same shape from the older format. `password` decrypts a workbook protected by [MS-XLS] 2.4.117's FilePass record under the [MS-OFFCRYPTO] 2.3.6.1 RC4 encryption header scheme -- see workbook/encryption.ts. It is ignored for an unencrypted workbook, and a missing or incorrect password against an encrypted one throws rather than returning a partial or garbled document.
86
+ * The counterpart of ooxml.js's readXlsxContent, producing the same shape from the older format. `password` decrypts a workbook protected by [MS-XLS] 2.4.117's FilePass record, under either the [MS-OFFCRYPTO] 2.3.6.1 RC4 encryption header scheme or 2.3.7's XOR obfuscation -- see workbook/encryption.ts. It is ignored for an unencrypted workbook, and a missing or incorrect password against an encrypted one throws rather than returning a partial or garbled document.
87
87
  */
88
88
  function readXlsContent(bytes, password) {
89
89
  const { workbook, metadata } = require_container.readWorkbookStreams(bytes);
@@ -11,7 +11,7 @@ type XlsContentDocument = Extract<ContentDocument, {
11
11
  /**
12
12
  * Reads a .xls file's bytes into a ContentDocument.
13
13
  *
14
- * The counterpart of ooxml.js's readXlsxContent, producing the same shape from the older format. `password` decrypts a workbook protected by [MS-XLS] 2.4.117's FilePass record under the [MS-OFFCRYPTO] 2.3.6.1 RC4 encryption header scheme -- see workbook/encryption.ts. It is ignored for an unencrypted workbook, and a missing or incorrect password against an encrypted one throws rather than returning a partial or garbled document.
14
+ * The counterpart of ooxml.js's readXlsxContent, producing the same shape from the older format. `password` decrypts a workbook protected by [MS-XLS] 2.4.117's FilePass record, under either the [MS-OFFCRYPTO] 2.3.6.1 RC4 encryption header scheme or 2.3.7's XOR obfuscation -- see workbook/encryption.ts. It is ignored for an unencrypted workbook, and a missing or incorrect password against an encrypted one throws rather than returning a partial or garbled document.
15
15
  */
16
16
  declare function readXlsContent(bytes: Uint8Array<ArrayBuffer>, password?: string): XlsContentDocument;
17
17
  /** The tree-form read: readXlsContent composed with the schema's own structural transform, exactly as ooxml.js's readXlsx wraps readXlsxContent. */
package/dist/content.d.ts CHANGED
@@ -11,7 +11,7 @@ type XlsContentDocument = Extract<ContentDocument, {
11
11
  /**
12
12
  * Reads a .xls file's bytes into a ContentDocument.
13
13
  *
14
- * The counterpart of ooxml.js's readXlsxContent, producing the same shape from the older format. `password` decrypts a workbook protected by [MS-XLS] 2.4.117's FilePass record under the [MS-OFFCRYPTO] 2.3.6.1 RC4 encryption header scheme -- see workbook/encryption.ts. It is ignored for an unencrypted workbook, and a missing or incorrect password against an encrypted one throws rather than returning a partial or garbled document.
14
+ * The counterpart of ooxml.js's readXlsxContent, producing the same shape from the older format. `password` decrypts a workbook protected by [MS-XLS] 2.4.117's FilePass record, under either the [MS-OFFCRYPTO] 2.3.6.1 RC4 encryption header scheme or 2.3.7's XOR obfuscation -- see workbook/encryption.ts. It is ignored for an unencrypted workbook, and a missing or incorrect password against an encrypted one throws rather than returning a partial or garbled document.
15
15
  */
16
16
  declare function readXlsContent(bytes: Uint8Array<ArrayBuffer>, password?: string): XlsContentDocument;
17
17
  /** The tree-form read: readXlsContent composed with the schema's own structural transform, exactly as ooxml.js's readXlsx wraps readXlsxContent. */
package/dist/content.js CHANGED
@@ -82,7 +82,7 @@ function mapPrintSettings(raw, names) {
82
82
  /**
83
83
  * Reads a .xls file's bytes into a ContentDocument.
84
84
  *
85
- * The counterpart of ooxml.js's readXlsxContent, producing the same shape from the older format. `password` decrypts a workbook protected by [MS-XLS] 2.4.117's FilePass record under the [MS-OFFCRYPTO] 2.3.6.1 RC4 encryption header scheme -- see workbook/encryption.ts. It is ignored for an unencrypted workbook, and a missing or incorrect password against an encrypted one throws rather than returning a partial or garbled document.
85
+ * The counterpart of ooxml.js's readXlsxContent, producing the same shape from the older format. `password` decrypts a workbook protected by [MS-XLS] 2.4.117's FilePass record, under either the [MS-OFFCRYPTO] 2.3.6.1 RC4 encryption header scheme or 2.3.7's XOR obfuscation -- see workbook/encryption.ts. It is ignored for an unencrypted workbook, and a missing or incorrect password against an encrypted one throws rather than returning a partial or garbled document.
86
86
  */
87
87
  function readXlsContent(bytes, password) {
88
88
  const { workbook, metadata } = readWorkbookStreams(bytes);
@@ -4,7 +4,8 @@ const require_biff_records = require("../biff/records.cjs");
4
4
  const require_biff_cursor = require("../biff/cursor.cjs");
5
5
  let archive_codec = require("archive-codec");
6
6
  //#region src/workbook/encryption.ts
7
- /** [MS-XLS] 2.4.117's own wEncryptionType: RC4 encryption, as opposed to 0x0000 (XOR obfuscation, out of scope). */
7
+ /** [MS-XLS] 2.4.117's own wEncryptionType. */
8
+ const ENCRYPTION_TYPE_XOR = 0;
8
9
  const ENCRYPTION_TYPE_RC4 = 1;
9
10
  /** [MS-OFFCRYPTO] 2.3.6.1's own EncryptionVersionInfo: vMajor/vMinor MUST both be 1 for the "RC4 encryption header" this module reads. vMajor 2/3/4 with vMinor 2 names the RC4 CryptoAPI encryption header instead (2.3.5.1), a different, unimplemented header shape. */
10
11
  const RC4_HEADER_VERSION_MAJOR = 1;
@@ -21,29 +22,28 @@ const NEVER_ENCRYPTED_RECORD_TYPES = /* @__PURE__ */ new Set([
21
22
  406,
22
23
  312
23
24
  ]);
24
- /** Reads FilePass's own fields, rejecting the two encryption schemes this module does not implement with a specific message rather than a generic parse failure. */
25
- function readFilePassRc4Header(filePassRecord) {
25
+ /** Reads FilePass's own fields, dispatching on wEncryptionType, and rejecting RC4 CryptoAPI (the one scheme this module does not implement) with a specific message rather than a generic parse failure. */
26
+ function readFilePassHeader(filePassRecord) {
26
27
  const cursor = new require_biff_cursor.BlockCursor([filePassRecord.data]);
27
28
  const encryptionType = cursor.u16();
28
- if (encryptionType !== ENCRYPTION_TYPE_RC4) throw new require_biff_records.BiffFormatError(`workbook uses FilePass wEncryptionType 0x${encryptionType.toString(16).padStart(4, "0")} (XOR obfuscation), which this reader does not decrypt`);
29
+ if (encryptionType === ENCRYPTION_TYPE_XOR) return {
30
+ kind: "xor",
31
+ key: cursor.u16(),
32
+ verificationBytes: cursor.u16()
33
+ };
34
+ if (encryptionType !== ENCRYPTION_TYPE_RC4) throw new require_biff_records.BiffFormatError(`workbook uses FilePass wEncryptionType 0x${encryptionType.toString(16).padStart(4, "0")}, which this reader does not decrypt`);
29
35
  const versionMajor = cursor.u16();
30
36
  const versionMinor = cursor.u16();
31
37
  if (versionMajor !== RC4_HEADER_VERSION_MAJOR || versionMinor !== RC4_HEADER_VERSION_MINOR) throw new require_biff_records.BiffFormatError(`workbook uses RC4 CryptoAPI encryption (EncryptionVersionInfo ${versionMajor}.${versionMinor}), which this reader does not decrypt`);
32
38
  return {
39
+ kind: "rc4",
33
40
  salt: cursor.take(archive_codec.OFFICE_RC4_VERIFIER_LENGTH),
34
41
  encryptedVerifier: cursor.take(archive_codec.OFFICE_RC4_VERIFIER_LENGTH),
35
42
  encryptedVerifierHash: cursor.take(archive_codec.OFFICE_RC4_VERIFIER_LENGTH)
36
43
  };
37
44
  }
38
- /** [MS-OFFCRYPTO] 2.3.6.4's own password verification: block 0's key decrypts EncryptedVerifier, and MD5 of the result must equal the same block's decryption of EncryptedVerifierHash. Both verifier fields sit in one continuous keystream starting at position 0, not two independently-reset ones -- though decryptOfficeRc4 regenerates its keystream fresh from block start on every call regardless, so decrypting them as two separate 16-byte calls at offsets 0 and 16 is equivalent to one 32-byte call, not merely close to it. */
39
- function verifyOfficeRc4Password(baseHash, header) {
40
- const decryptedVerifier = (0, archive_codec.decryptOfficeRc4)(baseHash, 0, header.encryptedVerifier);
41
- const decryptedVerifierHash = (0, archive_codec.decryptOfficeRc4)(baseHash, archive_codec.OFFICE_RC4_VERIFIER_LENGTH, header.encryptedVerifierHash);
42
- const computedHash = (0, archive_codec.md5)(decryptedVerifier);
43
- if (!(computedHash.length === decryptedVerifierHash.length && computedHash.every((byte, index) => byte === decryptedVerifierHash[index]))) throw new require_biff_records.BiffFormatError("incorrect password for RC4-encrypted workbook");
44
- }
45
- /** Decrypts one record's own data in place against the derived base hash, honouring every [MS-XLS] 2.2.10 exclusion: a never-encrypted record's data is returned unchanged, BoundSheet8's own lbPlyPos prefix is preserved while the rest of its data is decrypted, and everything else is decrypted whole. `record.offset` is the record's own header start, so its data begins `HEADER_SIZE` bytes further into the stream -- the position [MS-OFFCRYPTO]'s block-keyed keystream is defined against. */
46
- function decryptRecord(record, baseHash) {
45
+ /** Decrypts one record's own data against the derived RC4 base hash, honouring every [MS-XLS] 2.2.10 exclusion: a never-encrypted record's data is returned unchanged, BoundSheet8's own lbPlyPos prefix is preserved while the rest of its data is decrypted, and everything else is decrypted whole. `record.offset` is the record's own header start, so its data begins `HEADER_SIZE` bytes further into the stream -- the position [MS-OFFCRYPTO]'s block-keyed keystream is defined against. */
46
+ function decryptRecordRc4(record, baseHash) {
47
47
  if (NEVER_ENCRYPTED_RECORD_TYPES.has(record.type)) return record;
48
48
  const dataOffset = record.offset + 4;
49
49
  if (record.type === 133) {
@@ -62,17 +62,64 @@ function decryptRecord(record, baseHash) {
62
62
  data: (0, archive_codec.decryptOfficeRc4)(baseHash, dataOffset, record.data)
63
63
  };
64
64
  }
65
+ /** [MS-XLS] 2.2.10's own XorArrayIndex rule for a record's decrypted span starting `spanOffset` bytes into the Workbook stream: `(streamOffset + recordDataLength) % 16`, where `recordDataLength` is the record's own FULL declared data length -- not the length of `spanOffset`'s own remaining span, which for BoundSheet8 is 4 bytes shorter than the record's own declared size. Confirmed against Apache POI's own `XORDecryptor.invokeCipher` comment ("XorArrayIndex = (FileOffset + Data.Length) % 16") and LibreOffice's `XclImpBiff5Decrypter::OnUpdate`, and directly against a real Excel-generated XOR-obfuscated fixture -- see archive-codec's own crypto/xor-obfuscation.test.ts. */
66
+ function xorArrayIndexFor(spanOffset, recordDataLength) {
67
+ return (spanOffset + recordDataLength) % archive_codec.XOR_OBFUSCATION_ARRAY_LENGTH;
68
+ }
69
+ /** Decrypts one record's own data against the derived XOR obfuscation array, honouring the same [MS-XLS] 2.2.10 exclusions decryptRecordRc4 does. */
70
+ function decryptRecordXor(record, array) {
71
+ if (NEVER_ENCRYPTED_RECORD_TYPES.has(record.type)) return record;
72
+ const dataOffset = record.offset + 4;
73
+ if (record.type === 133) {
74
+ const lbPlyPos = record.data.subarray(0, BOUNDSHEET8_LBPLYPOS_SIZE);
75
+ const spanOffset = dataOffset + BOUNDSHEET8_LBPLYPOS_SIZE;
76
+ const decryptedRest = (0, archive_codec.decryptXorObfuscationMethod1)(array, record.data.subarray(BOUNDSHEET8_LBPLYPOS_SIZE), xorArrayIndexFor(spanOffset, record.data.length));
77
+ const data = new Uint8Array(record.data.length);
78
+ data.set(lbPlyPos, 0);
79
+ data.set(decryptedRest, BOUNDSHEET8_LBPLYPOS_SIZE);
80
+ return {
81
+ ...record,
82
+ data
83
+ };
84
+ }
85
+ return {
86
+ ...record,
87
+ data: (0, archive_codec.decryptXorObfuscationMethod1)(array, record.data, xorArrayIndexFor(dataOffset, record.data.length))
88
+ };
89
+ }
90
+ /** [MS-OFFCRYPTO] 2.3.6.4's own password verification: block 0's key decrypts EncryptedVerifier, and MD5 of the result must equal the same block's decryption of EncryptedVerifierHash. Both verifier fields sit in one continuous keystream starting at position 0, not two independently-reset ones -- though decryptOfficeRc4 regenerates its keystream fresh from block start on every call regardless, so decrypting them as two separate 16-byte calls at offsets 0 and 16 is equivalent to one 32-byte call, not merely close to it. */
91
+ function decryptWorkbookRecordsRc4(records, header, password) {
92
+ const baseHash = (0, archive_codec.deriveOfficeRc4BaseHash)(password, header.salt);
93
+ const decryptedVerifier = (0, archive_codec.decryptOfficeRc4)(baseHash, 0, header.encryptedVerifier);
94
+ const decryptedVerifierHash = (0, archive_codec.decryptOfficeRc4)(baseHash, archive_codec.OFFICE_RC4_VERIFIER_LENGTH, header.encryptedVerifierHash);
95
+ const computedHash = (0, archive_codec.md5)(decryptedVerifier);
96
+ if (!(computedHash.length === decryptedVerifierHash.length && computedHash.every((byte, index) => byte === decryptedVerifierHash[index]))) throw new require_biff_records.BiffFormatError("incorrect password for RC4-encrypted workbook");
97
+ return records.map((record) => decryptRecordRc4(record, baseHash));
98
+ }
99
+ /** Verifies the password against both of XORObfuscation's own fields (its `key` and `verificationBytes`, matching Apache POI's own `XORDecryptor.verifyPassword`, which checks both rather than either alone) before decrypting every record. */
100
+ function decryptWorkbookRecordsXor(records, header, password) {
101
+ let computedKey;
102
+ let computedVerifier;
103
+ try {
104
+ computedKey = (0, archive_codec.createXorObfuscationKey)(password);
105
+ computedVerifier = (0, archive_codec.createXorObfuscationPasswordVerifier)(password);
106
+ } catch (error) {
107
+ if (error instanceof RangeError) throw new require_biff_records.BiffFormatError("incorrect password for XOR-obfuscated workbook");
108
+ throw error;
109
+ }
110
+ if (computedKey !== header.key || computedVerifier !== header.verificationBytes) throw new require_biff_records.BiffFormatError("incorrect password for XOR-obfuscated workbook");
111
+ const array = (0, archive_codec.createXorObfuscationArray)(password, archive_codec.XOR_OBFUSCATION_ROTATE_DISTANCE_METHOD1);
112
+ return records.map((record) => decryptRecordXor(record, array));
113
+ }
65
114
  /**
66
- * Decrypts every record of a workbook stream protected by [MS-OFFCRYPTO] 2.3.6.1's RC4 encryption header, given the `FilePass` record already located within `records` and the password to decrypt it with.
115
+ * Decrypts every record of a workbook stream protected by [MS-XLS] 2.4.117's FilePass record, under whichever of the two schemes it names (the [MS-OFFCRYPTO] 2.3.6.1 RC4 encryption header, or 2.3.7's XOR obfuscation), given the `FilePass` record already located within `records` and the password to decrypt it with.
67
116
  *
68
- * Throws `BiffFormatError` for a missing password, an incorrect one, or an encryption scheme this module does not implement (RC4 CryptoAPI, XOR obfuscation) -- there is no partial or best-effort result to return in any of those cases.
117
+ * Throws `BiffFormatError` for a missing password, an incorrect one, or an encryption scheme this module does not implement (RC4 CryptoAPI) -- there is no partial or best-effort result to return in any of those cases.
69
118
  */
70
119
  function decryptWorkbookRecords(records, filePassRecord, password) {
71
- const header = readFilePassRc4Header(filePassRecord);
72
- if (password === void 0) throw new require_biff_records.BiffFormatError("workbook is RC4-encrypted (FilePass record); call readXlsContent with a password to decrypt it");
73
- const baseHash = (0, archive_codec.deriveOfficeRc4BaseHash)(password, header.salt);
74
- verifyOfficeRc4Password(baseHash, header);
75
- return records.map((record) => decryptRecord(record, baseHash));
120
+ const header = readFilePassHeader(filePassRecord);
121
+ if (password === void 0) throw new require_biff_records.BiffFormatError(`workbook is ${header.kind === "xor" ? "XOR-obfuscated" : "RC4-encrypted"} (FilePass record); call readXlsContent with a password to decrypt it`);
122
+ return header.kind === "xor" ? decryptWorkbookRecordsXor(records, header, password) : decryptWorkbookRecordsRc4(records, header, password);
76
123
  }
77
124
  //#endregion
78
125
  exports.decryptWorkbookRecords = decryptWorkbookRecords;
@@ -1,9 +1,9 @@
1
1
  import { n as BiffRecord } from "../records-G82UEVq8.cjs";
2
2
  //#region src/workbook/encryption.d.ts
3
3
  /**
4
- * Decrypts every record of a workbook stream protected by [MS-OFFCRYPTO] 2.3.6.1's RC4 encryption header, given the `FilePass` record already located within `records` and the password to decrypt it with.
4
+ * Decrypts every record of a workbook stream protected by [MS-XLS] 2.4.117's FilePass record, under whichever of the two schemes it names (the [MS-OFFCRYPTO] 2.3.6.1 RC4 encryption header, or 2.3.7's XOR obfuscation), given the `FilePass` record already located within `records` and the password to decrypt it with.
5
5
  *
6
- * Throws `BiffFormatError` for a missing password, an incorrect one, or an encryption scheme this module does not implement (RC4 CryptoAPI, XOR obfuscation) -- there is no partial or best-effort result to return in any of those cases.
6
+ * Throws `BiffFormatError` for a missing password, an incorrect one, or an encryption scheme this module does not implement (RC4 CryptoAPI) -- there is no partial or best-effort result to return in any of those cases.
7
7
  */
8
8
  declare function decryptWorkbookRecords(records: readonly BiffRecord[], filePassRecord: BiffRecord, password: string | undefined): readonly BiffRecord[];
9
9
  //#endregion
@@ -1,9 +1,9 @@
1
1
  import { n as BiffRecord } from "../records-G82UEVq8.js";
2
2
  //#region src/workbook/encryption.d.ts
3
3
  /**
4
- * Decrypts every record of a workbook stream protected by [MS-OFFCRYPTO] 2.3.6.1's RC4 encryption header, given the `FilePass` record already located within `records` and the password to decrypt it with.
4
+ * Decrypts every record of a workbook stream protected by [MS-XLS] 2.4.117's FilePass record, under whichever of the two schemes it names (the [MS-OFFCRYPTO] 2.3.6.1 RC4 encryption header, or 2.3.7's XOR obfuscation), given the `FilePass` record already located within `records` and the password to decrypt it with.
5
5
  *
6
- * Throws `BiffFormatError` for a missing password, an incorrect one, or an encryption scheme this module does not implement (RC4 CryptoAPI, XOR obfuscation) -- there is no partial or best-effort result to return in any of those cases.
6
+ * Throws `BiffFormatError` for a missing password, an incorrect one, or an encryption scheme this module does not implement (RC4 CryptoAPI) -- there is no partial or best-effort result to return in any of those cases.
7
7
  */
8
8
  declare function decryptWorkbookRecords(records: readonly BiffRecord[], filePassRecord: BiffRecord, password: string | undefined): readonly BiffRecord[];
9
9
  //#endregion
@@ -1,9 +1,10 @@
1
1
  import { RECORD_BOF } from "../biff/record-types.js";
2
2
  import { BiffFormatError } from "../biff/records.js";
3
3
  import { BlockCursor } from "../biff/cursor.js";
4
- import { OFFICE_RC4_VERIFIER_LENGTH, decryptOfficeRc4, deriveOfficeRc4BaseHash, md5 } from "archive-codec";
4
+ import { OFFICE_RC4_VERIFIER_LENGTH, XOR_OBFUSCATION_ARRAY_LENGTH, XOR_OBFUSCATION_ROTATE_DISTANCE_METHOD1, createXorObfuscationArray, createXorObfuscationKey, createXorObfuscationPasswordVerifier, decryptOfficeRc4, decryptXorObfuscationMethod1, deriveOfficeRc4BaseHash, md5 } from "archive-codec";
5
5
  //#region src/workbook/encryption.ts
6
- /** [MS-XLS] 2.4.117's own wEncryptionType: RC4 encryption, as opposed to 0x0000 (XOR obfuscation, out of scope). */
6
+ /** [MS-XLS] 2.4.117's own wEncryptionType. */
7
+ const ENCRYPTION_TYPE_XOR = 0;
7
8
  const ENCRYPTION_TYPE_RC4 = 1;
8
9
  /** [MS-OFFCRYPTO] 2.3.6.1's own EncryptionVersionInfo: vMajor/vMinor MUST both be 1 for the "RC4 encryption header" this module reads. vMajor 2/3/4 with vMinor 2 names the RC4 CryptoAPI encryption header instead (2.3.5.1), a different, unimplemented header shape. */
9
10
  const RC4_HEADER_VERSION_MAJOR = 1;
@@ -20,29 +21,28 @@ const NEVER_ENCRYPTED_RECORD_TYPES = /* @__PURE__ */ new Set([
20
21
  406,
21
22
  312
22
23
  ]);
23
- /** Reads FilePass's own fields, rejecting the two encryption schemes this module does not implement with a specific message rather than a generic parse failure. */
24
- function readFilePassRc4Header(filePassRecord) {
24
+ /** Reads FilePass's own fields, dispatching on wEncryptionType, and rejecting RC4 CryptoAPI (the one scheme this module does not implement) with a specific message rather than a generic parse failure. */
25
+ function readFilePassHeader(filePassRecord) {
25
26
  const cursor = new BlockCursor([filePassRecord.data]);
26
27
  const encryptionType = cursor.u16();
27
- if (encryptionType !== ENCRYPTION_TYPE_RC4) throw new BiffFormatError(`workbook uses FilePass wEncryptionType 0x${encryptionType.toString(16).padStart(4, "0")} (XOR obfuscation), which this reader does not decrypt`);
28
+ if (encryptionType === ENCRYPTION_TYPE_XOR) return {
29
+ kind: "xor",
30
+ key: cursor.u16(),
31
+ verificationBytes: cursor.u16()
32
+ };
33
+ if (encryptionType !== ENCRYPTION_TYPE_RC4) throw new BiffFormatError(`workbook uses FilePass wEncryptionType 0x${encryptionType.toString(16).padStart(4, "0")}, which this reader does not decrypt`);
28
34
  const versionMajor = cursor.u16();
29
35
  const versionMinor = cursor.u16();
30
36
  if (versionMajor !== RC4_HEADER_VERSION_MAJOR || versionMinor !== RC4_HEADER_VERSION_MINOR) throw new BiffFormatError(`workbook uses RC4 CryptoAPI encryption (EncryptionVersionInfo ${versionMajor}.${versionMinor}), which this reader does not decrypt`);
31
37
  return {
38
+ kind: "rc4",
32
39
  salt: cursor.take(OFFICE_RC4_VERIFIER_LENGTH),
33
40
  encryptedVerifier: cursor.take(OFFICE_RC4_VERIFIER_LENGTH),
34
41
  encryptedVerifierHash: cursor.take(OFFICE_RC4_VERIFIER_LENGTH)
35
42
  };
36
43
  }
37
- /** [MS-OFFCRYPTO] 2.3.6.4's own password verification: block 0's key decrypts EncryptedVerifier, and MD5 of the result must equal the same block's decryption of EncryptedVerifierHash. Both verifier fields sit in one continuous keystream starting at position 0, not two independently-reset ones -- though decryptOfficeRc4 regenerates its keystream fresh from block start on every call regardless, so decrypting them as two separate 16-byte calls at offsets 0 and 16 is equivalent to one 32-byte call, not merely close to it. */
38
- function verifyOfficeRc4Password(baseHash, header) {
39
- const decryptedVerifier = decryptOfficeRc4(baseHash, 0, header.encryptedVerifier);
40
- const decryptedVerifierHash = decryptOfficeRc4(baseHash, OFFICE_RC4_VERIFIER_LENGTH, header.encryptedVerifierHash);
41
- const computedHash = md5(decryptedVerifier);
42
- if (!(computedHash.length === decryptedVerifierHash.length && computedHash.every((byte, index) => byte === decryptedVerifierHash[index]))) throw new BiffFormatError("incorrect password for RC4-encrypted workbook");
43
- }
44
- /** Decrypts one record's own data in place against the derived base hash, honouring every [MS-XLS] 2.2.10 exclusion: a never-encrypted record's data is returned unchanged, BoundSheet8's own lbPlyPos prefix is preserved while the rest of its data is decrypted, and everything else is decrypted whole. `record.offset` is the record's own header start, so its data begins `HEADER_SIZE` bytes further into the stream -- the position [MS-OFFCRYPTO]'s block-keyed keystream is defined against. */
45
- function decryptRecord(record, baseHash) {
44
+ /** Decrypts one record's own data against the derived RC4 base hash, honouring every [MS-XLS] 2.2.10 exclusion: a never-encrypted record's data is returned unchanged, BoundSheet8's own lbPlyPos prefix is preserved while the rest of its data is decrypted, and everything else is decrypted whole. `record.offset` is the record's own header start, so its data begins `HEADER_SIZE` bytes further into the stream -- the position [MS-OFFCRYPTO]'s block-keyed keystream is defined against. */
45
+ function decryptRecordRc4(record, baseHash) {
46
46
  if (NEVER_ENCRYPTED_RECORD_TYPES.has(record.type)) return record;
47
47
  const dataOffset = record.offset + 4;
48
48
  if (record.type === 133) {
@@ -61,17 +61,64 @@ function decryptRecord(record, baseHash) {
61
61
  data: decryptOfficeRc4(baseHash, dataOffset, record.data)
62
62
  };
63
63
  }
64
+ /** [MS-XLS] 2.2.10's own XorArrayIndex rule for a record's decrypted span starting `spanOffset` bytes into the Workbook stream: `(streamOffset + recordDataLength) % 16`, where `recordDataLength` is the record's own FULL declared data length -- not the length of `spanOffset`'s own remaining span, which for BoundSheet8 is 4 bytes shorter than the record's own declared size. Confirmed against Apache POI's own `XORDecryptor.invokeCipher` comment ("XorArrayIndex = (FileOffset + Data.Length) % 16") and LibreOffice's `XclImpBiff5Decrypter::OnUpdate`, and directly against a real Excel-generated XOR-obfuscated fixture -- see archive-codec's own crypto/xor-obfuscation.test.ts. */
65
+ function xorArrayIndexFor(spanOffset, recordDataLength) {
66
+ return (spanOffset + recordDataLength) % XOR_OBFUSCATION_ARRAY_LENGTH;
67
+ }
68
+ /** Decrypts one record's own data against the derived XOR obfuscation array, honouring the same [MS-XLS] 2.2.10 exclusions decryptRecordRc4 does. */
69
+ function decryptRecordXor(record, array) {
70
+ if (NEVER_ENCRYPTED_RECORD_TYPES.has(record.type)) return record;
71
+ const dataOffset = record.offset + 4;
72
+ if (record.type === 133) {
73
+ const lbPlyPos = record.data.subarray(0, BOUNDSHEET8_LBPLYPOS_SIZE);
74
+ const spanOffset = dataOffset + BOUNDSHEET8_LBPLYPOS_SIZE;
75
+ const decryptedRest = decryptXorObfuscationMethod1(array, record.data.subarray(BOUNDSHEET8_LBPLYPOS_SIZE), xorArrayIndexFor(spanOffset, record.data.length));
76
+ const data = new Uint8Array(record.data.length);
77
+ data.set(lbPlyPos, 0);
78
+ data.set(decryptedRest, BOUNDSHEET8_LBPLYPOS_SIZE);
79
+ return {
80
+ ...record,
81
+ data
82
+ };
83
+ }
84
+ return {
85
+ ...record,
86
+ data: decryptXorObfuscationMethod1(array, record.data, xorArrayIndexFor(dataOffset, record.data.length))
87
+ };
88
+ }
89
+ /** [MS-OFFCRYPTO] 2.3.6.4's own password verification: block 0's key decrypts EncryptedVerifier, and MD5 of the result must equal the same block's decryption of EncryptedVerifierHash. Both verifier fields sit in one continuous keystream starting at position 0, not two independently-reset ones -- though decryptOfficeRc4 regenerates its keystream fresh from block start on every call regardless, so decrypting them as two separate 16-byte calls at offsets 0 and 16 is equivalent to one 32-byte call, not merely close to it. */
90
+ function decryptWorkbookRecordsRc4(records, header, password) {
91
+ const baseHash = deriveOfficeRc4BaseHash(password, header.salt);
92
+ const decryptedVerifier = decryptOfficeRc4(baseHash, 0, header.encryptedVerifier);
93
+ const decryptedVerifierHash = decryptOfficeRc4(baseHash, OFFICE_RC4_VERIFIER_LENGTH, header.encryptedVerifierHash);
94
+ const computedHash = md5(decryptedVerifier);
95
+ if (!(computedHash.length === decryptedVerifierHash.length && computedHash.every((byte, index) => byte === decryptedVerifierHash[index]))) throw new BiffFormatError("incorrect password for RC4-encrypted workbook");
96
+ return records.map((record) => decryptRecordRc4(record, baseHash));
97
+ }
98
+ /** Verifies the password against both of XORObfuscation's own fields (its `key` and `verificationBytes`, matching Apache POI's own `XORDecryptor.verifyPassword`, which checks both rather than either alone) before decrypting every record. */
99
+ function decryptWorkbookRecordsXor(records, header, password) {
100
+ let computedKey;
101
+ let computedVerifier;
102
+ try {
103
+ computedKey = createXorObfuscationKey(password);
104
+ computedVerifier = createXorObfuscationPasswordVerifier(password);
105
+ } catch (error) {
106
+ if (error instanceof RangeError) throw new BiffFormatError("incorrect password for XOR-obfuscated workbook");
107
+ throw error;
108
+ }
109
+ if (computedKey !== header.key || computedVerifier !== header.verificationBytes) throw new BiffFormatError("incorrect password for XOR-obfuscated workbook");
110
+ const array = createXorObfuscationArray(password, XOR_OBFUSCATION_ROTATE_DISTANCE_METHOD1);
111
+ return records.map((record) => decryptRecordXor(record, array));
112
+ }
64
113
  /**
65
- * Decrypts every record of a workbook stream protected by [MS-OFFCRYPTO] 2.3.6.1's RC4 encryption header, given the `FilePass` record already located within `records` and the password to decrypt it with.
114
+ * Decrypts every record of a workbook stream protected by [MS-XLS] 2.4.117's FilePass record, under whichever of the two schemes it names (the [MS-OFFCRYPTO] 2.3.6.1 RC4 encryption header, or 2.3.7's XOR obfuscation), given the `FilePass` record already located within `records` and the password to decrypt it with.
66
115
  *
67
- * Throws `BiffFormatError` for a missing password, an incorrect one, or an encryption scheme this module does not implement (RC4 CryptoAPI, XOR obfuscation) -- there is no partial or best-effort result to return in any of those cases.
116
+ * Throws `BiffFormatError` for a missing password, an incorrect one, or an encryption scheme this module does not implement (RC4 CryptoAPI) -- there is no partial or best-effort result to return in any of those cases.
68
117
  */
69
118
  function decryptWorkbookRecords(records, filePassRecord, password) {
70
- const header = readFilePassRc4Header(filePassRecord);
71
- if (password === void 0) throw new BiffFormatError("workbook is RC4-encrypted (FilePass record); call readXlsContent with a password to decrypt it");
72
- const baseHash = deriveOfficeRc4BaseHash(password, header.salt);
73
- verifyOfficeRc4Password(baseHash, header);
74
- return records.map((record) => decryptRecord(record, baseHash));
119
+ const header = readFilePassHeader(filePassRecord);
120
+ if (password === void 0) throw new BiffFormatError(`workbook is ${header.kind === "xor" ? "XOR-obfuscated" : "RC4-encrypted"} (FilePass record); call readXlsContent with a password to decrypt it`);
121
+ return header.kind === "xor" ? decryptWorkbookRecordsXor(records, header, password) : decryptWorkbookRecordsRc4(records, header, password);
75
122
  }
76
123
  //#endregion
77
124
  export { decryptWorkbookRecords };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xls-codec",
3
- "version": "4.9.0",
3
+ "version": "4.10.0",
4
4
  "description": "Hand-written reader for the legacy Excel Binary File Format (.xls, BIFF8) as specified by [MS-XLS], mapping a workbook's record stream onto the shared document-schema.js spreadsheet model - the .xls codec for the documents.js family.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -70,7 +70,7 @@
70
70
  },
71
71
  "packageManager": "pnpm@11.6.0",
72
72
  "dependencies": {
73
- "archive-codec": "1.9.2",
73
+ "archive-codec": "1.10.0",
74
74
  "document-schema.js": "7.5.0",
75
75
  "excel-number-format": "1.1.1"
76
76
  },