doc-codec 2.5.0 → 2.6.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 +7 -5
- package/dist/encryption.cjs +44 -10
- package/dist/encryption.d.cts +2 -2
- package/dist/encryption.d.ts +2 -2
- package/dist/encryption.js +45 -11
- package/dist/read.cjs +1 -2
- package/dist/read.js +2 -3
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -22,7 +22,7 @@ Built and shipped, on the read side:
|
|
|
22
22
|
- **`readDocContent`** — the whole chain, producing a `'wordprocessing'` `ContentDocument` of paragraphs, runs and tables.
|
|
23
23
|
- **`isDocBytes`** — distinguishes a `.doc` from the `.xls`, `.ppt` and OLE embeddings that share its container, by looking for a `WordDocument` stream carrying `FibBase.wIdent`.
|
|
24
24
|
- **Document metadata** — `title`/`subject`/`author`/`keywords`/`createdIso`/`modifiedIso` read from a `"\x05SummaryInformation"` stream when one is present (see [Metadata](#metadata)); `comments` and `lastPrintedIso` remain unread, since `LayoutMetadata` has no field for either.
|
|
25
|
-
- **RC4-encrypted documents, decrypted for real (ExaDev/documents.js#1113)** — `readDocContent`/`readDocStreams` take an optional `password`, verified against the Table stream's own `EncryptionHeader`
|
|
25
|
+
- **RC4-encrypted and XOR-obfuscated documents, decrypted for real (ExaDev/documents.js#1113, #922)** — `readDocContent`/`readDocStreams` take an optional `password`, verified against the Table stream's own `EncryptionHeader` for RC4, or against `FibBase.lKey` directly for XOR obfuscation (see [Encryption](#encryption)). RC4 CryptoAPI stays refused.
|
|
26
26
|
- **Numbering definitions** — `readDocContent`'s own `numbering` field: every list's glyph/format, level-text template, and start-at value, resolved from `PlfLst`/`PlfLfo` and keyed by the same `listId` a paragraph's `ContentParagraph.list.numId` already carries. See [Numbering definitions](#numbering-definitions), which also covers `writeDocContent`'s own inverse.
|
|
27
27
|
- **Every section's own page size and margins** — `prop/sep.ts`'s `readAllSectionProperties` resolves every `Sed` `PlcfSed` carries, each into its own `ContentSection.pageSize`/`.margins` (`sprmSXaPage`/`sprmSYaPage`/`sprmSDxaLeft`/`sprmSDxaRight`/`sprmSDyaTop`/`sprmSDyaBottom`), falling back field by field to Word's own new-document default (US Letter, one-inch margins) for a document carrying no `PlcfSed` at all, or for any one sprm a section leaves unstated. `read.ts`'s `splitIntoSections` groups the main document's own paragraphs by which section each falls in, per [MS-DOC] 2.8.26's own "each CP specifies the beginning of a range of text ... that constitutes a section" — a genuinely multi-section `.doc` now reads as multiple `ContentSection`s, each with its own real page geometry, rather than the whole document collapsing onto the first section's.
|
|
28
28
|
- **Footnotes, endnotes, and comments, as plain text** — `notes.ts`'s `readNoteBodies` resolves `PlcffndTxt`/`PlcfendTxt`/`PlcfandTxt` (each a CP-only plex dividing its own subdocument into stories, [MS-DOC]'s own "each CP ... specifies the beginning of a story ... the story ends immediately prior to the next CP") into `DocContent.footnotes`/`.endnotes`/`.comments`: one `{id, text}`/`{id, text}` entry per story, `id` a synthetic one-based ordinal, matching the same simplification `ooxml.js`'s own `DocxDocument.footnotes`/`.endnotes`/`.comments` already make for the identical constructs (plain text, no paragraph/run structure). A story's own trailing guard paragraph mark ("not considered part of the story contents", [MS-DOC]'s own words) is excluded from its text; a genuinely empty story reads as `""`.
|
|
@@ -45,7 +45,7 @@ Built and shipped, on the write side — see [Writing](#writing) for the full sc
|
|
|
45
45
|
| **Subdocuments, on write; text boxes, on either side** | Footnotes, endnotes, comments, and headers/footers are now read (see the three read-side bullets above); none is written — `writeDocContent` still only ever produces a main document with `ccpFtn`/`ccpHdd`/`ccpAtn`/`ccpEdn` all zero, and a `ContentDocument` carries nowhere on its own `ContentSection.blocks` to state a header/footer's own content distinctly from the body's, which a real inverse would need. Text boxes are not read or written in either direction: `PlcfTxbxTxt` divides its own subdocument into stories the identical way the note-text plexes above do, but a text box's own anchor rides the drawn-object mechanism ([Images](#images)'s own scope note), which this package does not resolve. |
|
|
46
46
|
| **Multiple sections, on write** | `readDocContent` resolves every section `PlcfSed` states, each with its own page size and margins (see the read-side bullet above). `writeDocContent` still refuses a `ContentDocument` with more than one section, rather than silently merging their content into what would read back as one — writing a real multi-section `PlcfSed`/`Sepx` array is unimplemented on the write side. |
|
|
47
47
|
| **Extended and user-defined document properties** | `title`/`subject`/`author`/`keywords`/`createdIso`/`modifiedIso` are read from and written to a `"\x05SummaryInformation"` stream when present (see [Metadata](#metadata)); the sibling `"\x05DocumentSummaryInformation"` stream (company, manager, and custom user-defined properties) is not read or written at all. |
|
|
48
|
-
| **Encryption** | RC4-encrypted
|
|
48
|
+
| **Encryption** | RC4-encrypted ([MS-DOC] 2.2.6.2, ExaDev/documents.js#1113) and XOR-obfuscated (2.2.6.1, #922) documents are both decrypted given a password -- `readDocContent`/`readDocStreams` take an optional `password`, verified against the Table stream's own EncryptionHeader for RC4, or against `FibBase.lKey` directly for XOR obfuscation (see [Encryption](#encryption) below). RC4 CryptoAPI (a different, newer EncryptionHeader shape) still refuses with a `DocUnsupportedError` rather than being read as plaintext or misread as the wrong scheme. `writeDocContent` never encrypts. |
|
|
49
49
|
| **`sprmPHugePapx` / `sprmPTableProps`** | Paragraph properties stored indirectly in the Data stream are not followed, so such a paragraph reads with fewer properties than it states. [MS-DOC] 2.4.3's own Overview of Tables text names `sprmPTableProps` as a real, legal alternative to `sprmTDefTable` some applications process — but a real producer's row mark is not shown to prefer it: a genuine LibreOffice-authored `.doc` table's own row mark states its TAP through the identical direct `sprmTDefTable` this package's reader and writer already use (confirmed by parsing a LibreOffice 26.2.5.2-authored table's raw `PapxFkp` bytes; see [ExaDev/documents.js#892](https://github.com/ExaDev/documents.js/issues/892)), matching 2.4.3's own compatibility guidance ("An application SHOULD use sprmTDefTable to define table cells for applications that do not process sprmPTableProps"). `writeDocContent` never writes an indirect Papx. |
|
|
50
50
|
| **Hyperlinks and fields** | `ContentRun.hyperlink`, footnote/comment/annotation references, and every other field or anchor character are read as plain text or dropped (see [What is converted](#what-is-converted)) and are not written. |
|
|
51
51
|
| **Every FIB field beyond what this package's own reader needs** | `writeDocContent` populates only the fc/lcb pairs its own reader consults (the style sheet, the two property bin tables, the Clx, the font table). Roughly 140 other `FibRgFcLcb97` pairs — `SttbfAssoc`, `Dop`, the printer-driver structures among them — are left zero, which is the format's own "undefined, MUST be ignored" contract for most of them, but not a certification that every third-party [MS-DOC] reader accepts the result; see `fib/write.ts`'s own note. |
|
|
@@ -204,7 +204,7 @@ Only the fixed SummaryInformation property set is read or written — the siblin
|
|
|
204
204
|
|
|
205
205
|
## Encryption
|
|
206
206
|
|
|
207
|
-
A `.doc` protected with a password to open uses one of three schemes [MS-DOC] 2.2.6 names, selected by `FibBase`'s own `fEncrypted`/`fObfuscated` flags: RC4 encryption ([MS-DOC] 2.2.6.2, this reader), XOR obfuscation (2.2.6.1), or RC4 CryptoAPI (2.2.6.3, a different, newer `EncryptionHeader` shape). `readDocContent`/`readDocStreams` take an optional `password`, ignored for an unencrypted document; a missing or incorrect password against an
|
|
207
|
+
A `.doc` protected with a password to open uses one of three schemes [MS-DOC] 2.2.6 names, selected by `FibBase`'s own `fEncrypted`/`fObfuscated` flags: RC4 encryption ([MS-DOC] 2.2.6.2, this reader), XOR obfuscation (2.2.6.1, this reader), or RC4 CryptoAPI (2.2.6.3, a different, newer `EncryptionHeader` shape). `readDocContent`/`readDocStreams` take an optional `password`, ignored for an unencrypted document; a missing or incorrect password against an encrypted or obfuscated one throws `DocUnsupportedError` rather than returning a partial or garbled document, and so does an RC4-CryptoAPI-encrypted one regardless of password, since that scheme alone is not implemented.
|
|
208
208
|
|
|
209
209
|
```ts
|
|
210
210
|
import { readDocContent } from "doc-codec";
|
|
@@ -212,12 +212,14 @@ import { readDocContent } from "doc-codec";
|
|
|
212
212
|
const document = readDocContent(bytes, "correct horse battery staple");
|
|
213
213
|
```
|
|
214
214
|
|
|
215
|
-
[MS-DOC] 2.2.6.2's own `EncryptionHeader` _is_ [MS-OFFCRYPTO] 2.3.6.1's RC4 encryption header — byte-identical to what `xls-codec`'s own `FilePass` record carries (ExaDev/documents.js#1108), so `src/encryption.ts` needs no new cryptography, only `archive-codec`'s shared `deriveOfficeRc4BaseHash`/`decryptOfficeRc4` primitives and the doc-specific container layout, which differs from BIFF8's in three real ways rather than being a copy of xls-codec's own scheme:
|
|
215
|
+
[MS-DOC] 2.2.6.2's own `EncryptionHeader` _is_ [MS-OFFCRYPTO] 2.3.6.1's RC4 encryption header — byte-identical to what `xls-codec`'s own `FilePass` record carries (ExaDev/documents.js#1108), so `src/encryption.ts` needs no new cryptography for RC4, only `archive-codec`'s shared `deriveOfficeRc4BaseHash`/`decryptOfficeRc4` primitives and the doc-specific container layout, which differs from BIFF8's in three real ways rather than being a copy of xls-codec's own scheme:
|
|
216
216
|
|
|
217
217
|
- **Location.** The `EncryptionHeader` sits unencrypted at the very start of the Table stream (`0Table`/`1Table`, whichever `FibBase.fWhichTblStm` selects), its own byte length given by `FibBase.lKey` — unlike `FilePass`, an inline record within the Workbook stream.
|
|
218
218
|
- **Re-keying interval.** 512 bytes, not `xls-codec`'s 1024 (`archive-codec`'s `OFFICE_RC4_DOC_BLOCK_SIZE`) — a real [MS-DOC]-specific value, confirmed against Apache POI's `BinaryRC4Decryptor` independently of `xls-codec`'s own `Biff8DecryptingStream`.
|
|
219
219
|
- **Per-stream block-zero origin.** WordDocument and Table are each encrypted independently, each with its own block-number counter starting at zero at that stream's own byte 0 — unlike `xls-codec`'s single continuous Workbook-stream offset. WordDocument's own unencrypted prefix is a fixed 68 bytes ([MS-DOC] 2.2.6.1/2.2.6.2's own stated literal); Table's is `FibBase.lKey`, since the `EncryptionHeader` occupying it has to be readable before any key can be derived at all. The Data stream is also encrypted in full per the spec, but this reader does not read the Data stream at all today, so decrypting it is out of scope until something needs to.
|
|
220
220
|
|
|
221
|
+
XOR obfuscation (Method 2) needs `archive-codec`'s own `createXorObfuscationKey`/`createXorObfuscationPasswordVerifier`/`createXorObfuscationArray`/`decryptXorObfuscationMethod2` (see [`archive-codec`'s own XOR obfuscation section](../archive-codec/README.md#xor-obfuscation-xlsdoc)) — genuinely simpler than RC4's own container layout, in one real way: `FibBase.lKey` carries the 32-bit password verifier itself here (its high 16 bits `createXorObfuscationKey`'s own output, its low 16 bits `createXorObfuscationPasswordVerifier`'s), not a Table-stream `EncryptionHeader` byte length, so the Table stream needs no unencrypted prefix at all and is obfuscated in full from its own byte 0 — confirmed against LibreOffice's own WW8 import (`ww8par.cxx`'s `DecryptXOR`, called with no prior seek for the Table stream, unlike WordDocument's own explicit 68-byte copy-then-decrypt). WordDocument's own 68-byte unencrypted prefix is unchanged from the RC4 case.
|
|
222
|
+
|
|
221
223
|
`writeDocContent` never encrypts.
|
|
222
224
|
|
|
223
225
|
## Writing
|
|
@@ -324,7 +326,7 @@ const written = writeDocContent({
|
|
|
324
326
|
});
|
|
325
327
|
```
|
|
326
328
|
|
|
327
|
-
`readDocContent` throws a `DocFormatError` when the bytes do not conform to [MS-DOC], and a `DocUnsupportedError` when they conform but use a feature this package deliberately refuses rather than approximates (
|
|
329
|
+
`readDocContent` throws a `DocFormatError` when the bytes do not conform to [MS-DOC], and a `DocUnsupportedError` when they conform but use a feature this package deliberately refuses rather than approximates (RC4 CryptoAPI encryption, an encrypted or obfuscated document given no password or the wrong one, or the `sprmPChgTabs` sentinel above). `writeDocContent` throws a `DocUnsupportedError` for a document, section count, or block kind outside its own scope (see [Writing](#writing)) and a `DocFormatError` for a value that would need a property out of a sprm's own operand range (a font size or indent too large to fit its 2-byte operand, for instance).
|
|
328
330
|
|
|
329
331
|
## Worker-isomorphic
|
|
330
332
|
|
package/dist/encryption.cjs
CHANGED
|
@@ -44,27 +44,61 @@ function verifyPassword(baseHash, header) {
|
|
|
44
44
|
if (!(computedHash.length === decryptedVerifierHash.length && computedHash.every((byte, index) => byte === decryptedVerifierHash[index]))) throw new require_errors.DocUnsupportedError("incorrect password for RC4-encrypted document");
|
|
45
45
|
}
|
|
46
46
|
/** Decrypts everything after `prefixLength` bytes of `stream`, leaving the prefix itself untouched -- WORD_DOCUMENT_UNENCRYPTED_PREFIX for WordDocument, FibBase.lKey for Table, each stream's own block-number counter starting fresh at its own byte 0 (this file's own top comment, point 3). */
|
|
47
|
-
function
|
|
47
|
+
function decryptStreamRc4(baseHash, stream, prefixLength) {
|
|
48
48
|
const decrypted = new Uint8Array(stream.length);
|
|
49
49
|
decrypted.set(stream.subarray(0, prefixLength), 0);
|
|
50
50
|
decrypted.set((0, archive_codec.decryptOfficeRc4)(baseHash, prefixLength, stream.subarray(prefixLength), archive_codec.OFFICE_RC4_DOC_BLOCK_SIZE), prefixLength);
|
|
51
51
|
return decrypted;
|
|
52
52
|
}
|
|
53
|
-
/**
|
|
54
|
-
|
|
55
|
-
*
|
|
56
|
-
* Throws `DocUnsupportedError` 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.
|
|
57
|
-
*/
|
|
58
|
-
function decryptDocStreams(wordDocument, table, password) {
|
|
59
|
-
if (password === void 0) throw new require_errors.DocUnsupportedError("this document is RC4-encrypted ([MS-DOC] 2.2.6.2); call readDocContent with a password to decrypt it");
|
|
53
|
+
/** Decrypts an RC4-encrypted (fEncrypted=1, fObfuscated=0) document's WordDocument and Table streams given the password, verifying it first against the Table stream's own EncryptionHeader. */
|
|
54
|
+
function decryptDocStreamsRc4(wordDocument, table, password) {
|
|
60
55
|
const header = readRc4Header(table);
|
|
61
56
|
const baseHash = (0, archive_codec.deriveOfficeRc4BaseHash)(password, header.salt);
|
|
62
57
|
verifyPassword(baseHash, header);
|
|
63
58
|
const lKey = require_bytes.readUint32LE(wordDocument, 14);
|
|
64
59
|
return {
|
|
65
|
-
wordDocument:
|
|
66
|
-
table:
|
|
60
|
+
wordDocument: decryptStreamRc4(baseHash, wordDocument, WORD_DOCUMENT_UNENCRYPTED_PREFIX),
|
|
61
|
+
table: decryptStreamRc4(baseHash, table, lKey)
|
|
67
62
|
};
|
|
68
63
|
}
|
|
64
|
+
/** Decrypts everything after `prefixLength` bytes of `stream` against Method 2's own transform, leaving the prefix itself untouched -- `initialIndex` is `prefixLength % 16`, the XorArrayIndex the decrypted span's own first byte starts at (confirmed against LibreOffice's own `ww8par.cxx` `DecryptXOR`, whose `InitCipher(); Skip(nSt)` is exactly this: reset to 0, then advance by the skipped prefix's own length mod 16). */
|
|
65
|
+
function decryptStreamXor(array, stream, prefixLength) {
|
|
66
|
+
const decrypted = new Uint8Array(stream.length);
|
|
67
|
+
decrypted.set(stream.subarray(0, prefixLength), 0);
|
|
68
|
+
decrypted.set((0, archive_codec.decryptXorObfuscationMethod2)(array, stream.subarray(prefixLength), prefixLength % archive_codec.XOR_OBFUSCATION_ARRAY_LENGTH), prefixLength);
|
|
69
|
+
return decrypted;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Decrypts an XOR-obfuscated (fEncrypted=1, fObfuscated=1) document's WordDocument and Table streams given the password, verifying it first against FibBase's own lKey field -- not a Table-stream EncryptionHeader the way RC4 needs, see this file's own top comment for why. The Table stream carries no unencrypted prefix under this scheme, unlike RC4's own FibBase.lKey-byte EncryptionHeader; Data (also obfuscated per [MS-DOC], from its own byte 0) is out of scope, matching decryptDocStreamsRc4 and this package's own read.ts, which does not read the Data stream at all.
|
|
73
|
+
*/
|
|
74
|
+
function decryptDocStreamsXor(wordDocument, table, password) {
|
|
75
|
+
const lKey = require_bytes.readUint32LE(wordDocument, 14);
|
|
76
|
+
const headerKey = lKey >>> 16 & 65535;
|
|
77
|
+
const headerVerifier = lKey & 65535;
|
|
78
|
+
let computedKey;
|
|
79
|
+
let computedVerifier;
|
|
80
|
+
try {
|
|
81
|
+
computedKey = (0, archive_codec.createXorObfuscationKey)(password);
|
|
82
|
+
computedVerifier = (0, archive_codec.createXorObfuscationPasswordVerifier)(password);
|
|
83
|
+
} catch (error) {
|
|
84
|
+
if (error instanceof RangeError) throw new require_errors.DocUnsupportedError("incorrect password for XOR-obfuscated document");
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
if (computedKey !== headerKey || computedVerifier !== headerVerifier) throw new require_errors.DocUnsupportedError("incorrect password for XOR-obfuscated document");
|
|
88
|
+
const array = (0, archive_codec.createXorObfuscationArray)(password, archive_codec.XOR_OBFUSCATION_ROTATE_DISTANCE_METHOD2);
|
|
89
|
+
return {
|
|
90
|
+
wordDocument: decryptStreamXor(array, wordDocument, WORD_DOCUMENT_UNENCRYPTED_PREFIX),
|
|
91
|
+
table: decryptStreamXor(array, table, 0)
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Decrypts an encrypted document's WordDocument and Table streams given the password, dispatching on `fObfuscated` between [MS-DOC] 2.2.6.2's RC4 encryption header and 2.2.6.1's XOR obfuscation (Method 2).
|
|
96
|
+
*
|
|
97
|
+
* Throws `DocUnsupportedError` 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.
|
|
98
|
+
*/
|
|
99
|
+
function decryptDocStreams(wordDocument, table, password, fObfuscated) {
|
|
100
|
+
if (password === void 0) throw new require_errors.DocUnsupportedError(`this document is ${fObfuscated ? "XOR-obfuscated ([MS-DOC] 2.2.6.1)" : "RC4-encrypted ([MS-DOC] 2.2.6.2)"}; call readDocContent with a password to decrypt it`);
|
|
101
|
+
return fObfuscated ? decryptDocStreamsXor(wordDocument, table, password) : decryptDocStreamsRc4(wordDocument, table, password);
|
|
102
|
+
}
|
|
69
103
|
//#endregion
|
|
70
104
|
exports.decryptDocStreams = decryptDocStreams;
|
package/dist/encryption.d.cts
CHANGED
|
@@ -4,10 +4,10 @@ interface DecryptedDocStreams {
|
|
|
4
4
|
readonly table: Uint8Array<ArrayBuffer>;
|
|
5
5
|
}
|
|
6
6
|
/**
|
|
7
|
-
* Decrypts an
|
|
7
|
+
* Decrypts an encrypted document's WordDocument and Table streams given the password, dispatching on `fObfuscated` between [MS-DOC] 2.2.6.2's RC4 encryption header and 2.2.6.1's XOR obfuscation (Method 2).
|
|
8
8
|
*
|
|
9
9
|
* Throws `DocUnsupportedError` 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.
|
|
10
10
|
*/
|
|
11
|
-
declare function decryptDocStreams(wordDocument: Uint8Array<ArrayBuffer>, table: Uint8Array<ArrayBuffer>, password: string | undefined): DecryptedDocStreams;
|
|
11
|
+
declare function decryptDocStreams(wordDocument: Uint8Array<ArrayBuffer>, table: Uint8Array<ArrayBuffer>, password: string | undefined, fObfuscated: boolean): DecryptedDocStreams;
|
|
12
12
|
//#endregion
|
|
13
13
|
export { DecryptedDocStreams, decryptDocStreams };
|
package/dist/encryption.d.ts
CHANGED
|
@@ -4,10 +4,10 @@ interface DecryptedDocStreams {
|
|
|
4
4
|
readonly table: Uint8Array<ArrayBuffer>;
|
|
5
5
|
}
|
|
6
6
|
/**
|
|
7
|
-
* Decrypts an
|
|
7
|
+
* Decrypts an encrypted document's WordDocument and Table streams given the password, dispatching on `fObfuscated` between [MS-DOC] 2.2.6.2's RC4 encryption header and 2.2.6.1's XOR obfuscation (Method 2).
|
|
8
8
|
*
|
|
9
9
|
* Throws `DocUnsupportedError` 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.
|
|
10
10
|
*/
|
|
11
|
-
declare function decryptDocStreams(wordDocument: Uint8Array<ArrayBuffer>, table: Uint8Array<ArrayBuffer>, password: string | undefined): DecryptedDocStreams;
|
|
11
|
+
declare function decryptDocStreams(wordDocument: Uint8Array<ArrayBuffer>, table: Uint8Array<ArrayBuffer>, password: string | undefined, fObfuscated: boolean): DecryptedDocStreams;
|
|
12
12
|
//#endregion
|
|
13
13
|
export { DecryptedDocStreams, decryptDocStreams };
|
package/dist/encryption.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { DocFormatError, DocUnsupportedError } from "./errors.js";
|
|
2
2
|
import { readUint16LE, readUint32LE } from "./bytes.js";
|
|
3
3
|
import "./fib/offsets.js";
|
|
4
|
-
import { OFFICE_RC4_DOC_BLOCK_SIZE, OFFICE_RC4_VERIFIER_LENGTH, decryptOfficeRc4, deriveOfficeRc4BaseHash, md5 } from "archive-codec";
|
|
4
|
+
import { OFFICE_RC4_DOC_BLOCK_SIZE, OFFICE_RC4_VERIFIER_LENGTH, XOR_OBFUSCATION_ARRAY_LENGTH, XOR_OBFUSCATION_ROTATE_DISTANCE_METHOD2, createXorObfuscationArray, createXorObfuscationKey, createXorObfuscationPasswordVerifier, decryptOfficeRc4, decryptXorObfuscationMethod2, deriveOfficeRc4BaseHash, md5 } from "archive-codec";
|
|
5
5
|
//#region src/encryption.ts
|
|
6
6
|
/** [MS-DOC] 2.2.6.2's own EncryptionHeader field layout, byte offsets within the Table stream's own first FibBase.lKey bytes: EncryptionVersionInfo (vMajor/vMinor, 2 bytes each) at 0, then Salt/EncryptedVerifier/EncryptedVerifierHash, each OFFICE_RC4_VERIFIER_LENGTH (16) bytes, back to back. */
|
|
7
7
|
const HEADER_OFFSET = {
|
|
@@ -43,27 +43,61 @@ function verifyPassword(baseHash, header) {
|
|
|
43
43
|
if (!(computedHash.length === decryptedVerifierHash.length && computedHash.every((byte, index) => byte === decryptedVerifierHash[index]))) throw new DocUnsupportedError("incorrect password for RC4-encrypted document");
|
|
44
44
|
}
|
|
45
45
|
/** Decrypts everything after `prefixLength` bytes of `stream`, leaving the prefix itself untouched -- WORD_DOCUMENT_UNENCRYPTED_PREFIX for WordDocument, FibBase.lKey for Table, each stream's own block-number counter starting fresh at its own byte 0 (this file's own top comment, point 3). */
|
|
46
|
-
function
|
|
46
|
+
function decryptStreamRc4(baseHash, stream, prefixLength) {
|
|
47
47
|
const decrypted = new Uint8Array(stream.length);
|
|
48
48
|
decrypted.set(stream.subarray(0, prefixLength), 0);
|
|
49
49
|
decrypted.set(decryptOfficeRc4(baseHash, prefixLength, stream.subarray(prefixLength), OFFICE_RC4_DOC_BLOCK_SIZE), prefixLength);
|
|
50
50
|
return decrypted;
|
|
51
51
|
}
|
|
52
|
-
/**
|
|
53
|
-
|
|
54
|
-
*
|
|
55
|
-
* Throws `DocUnsupportedError` 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.
|
|
56
|
-
*/
|
|
57
|
-
function decryptDocStreams(wordDocument, table, password) {
|
|
58
|
-
if (password === void 0) throw new DocUnsupportedError("this document is RC4-encrypted ([MS-DOC] 2.2.6.2); call readDocContent with a password to decrypt it");
|
|
52
|
+
/** Decrypts an RC4-encrypted (fEncrypted=1, fObfuscated=0) document's WordDocument and Table streams given the password, verifying it first against the Table stream's own EncryptionHeader. */
|
|
53
|
+
function decryptDocStreamsRc4(wordDocument, table, password) {
|
|
59
54
|
const header = readRc4Header(table);
|
|
60
55
|
const baseHash = deriveOfficeRc4BaseHash(password, header.salt);
|
|
61
56
|
verifyPassword(baseHash, header);
|
|
62
57
|
const lKey = readUint32LE(wordDocument, 14);
|
|
63
58
|
return {
|
|
64
|
-
wordDocument:
|
|
65
|
-
table:
|
|
59
|
+
wordDocument: decryptStreamRc4(baseHash, wordDocument, WORD_DOCUMENT_UNENCRYPTED_PREFIX),
|
|
60
|
+
table: decryptStreamRc4(baseHash, table, lKey)
|
|
66
61
|
};
|
|
67
62
|
}
|
|
63
|
+
/** Decrypts everything after `prefixLength` bytes of `stream` against Method 2's own transform, leaving the prefix itself untouched -- `initialIndex` is `prefixLength % 16`, the XorArrayIndex the decrypted span's own first byte starts at (confirmed against LibreOffice's own `ww8par.cxx` `DecryptXOR`, whose `InitCipher(); Skip(nSt)` is exactly this: reset to 0, then advance by the skipped prefix's own length mod 16). */
|
|
64
|
+
function decryptStreamXor(array, stream, prefixLength) {
|
|
65
|
+
const decrypted = new Uint8Array(stream.length);
|
|
66
|
+
decrypted.set(stream.subarray(0, prefixLength), 0);
|
|
67
|
+
decrypted.set(decryptXorObfuscationMethod2(array, stream.subarray(prefixLength), prefixLength % XOR_OBFUSCATION_ARRAY_LENGTH), prefixLength);
|
|
68
|
+
return decrypted;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Decrypts an XOR-obfuscated (fEncrypted=1, fObfuscated=1) document's WordDocument and Table streams given the password, verifying it first against FibBase's own lKey field -- not a Table-stream EncryptionHeader the way RC4 needs, see this file's own top comment for why. The Table stream carries no unencrypted prefix under this scheme, unlike RC4's own FibBase.lKey-byte EncryptionHeader; Data (also obfuscated per [MS-DOC], from its own byte 0) is out of scope, matching decryptDocStreamsRc4 and this package's own read.ts, which does not read the Data stream at all.
|
|
72
|
+
*/
|
|
73
|
+
function decryptDocStreamsXor(wordDocument, table, password) {
|
|
74
|
+
const lKey = readUint32LE(wordDocument, 14);
|
|
75
|
+
const headerKey = lKey >>> 16 & 65535;
|
|
76
|
+
const headerVerifier = lKey & 65535;
|
|
77
|
+
let computedKey;
|
|
78
|
+
let computedVerifier;
|
|
79
|
+
try {
|
|
80
|
+
computedKey = createXorObfuscationKey(password);
|
|
81
|
+
computedVerifier = createXorObfuscationPasswordVerifier(password);
|
|
82
|
+
} catch (error) {
|
|
83
|
+
if (error instanceof RangeError) throw new DocUnsupportedError("incorrect password for XOR-obfuscated document");
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
if (computedKey !== headerKey || computedVerifier !== headerVerifier) throw new DocUnsupportedError("incorrect password for XOR-obfuscated document");
|
|
87
|
+
const array = createXorObfuscationArray(password, XOR_OBFUSCATION_ROTATE_DISTANCE_METHOD2);
|
|
88
|
+
return {
|
|
89
|
+
wordDocument: decryptStreamXor(array, wordDocument, WORD_DOCUMENT_UNENCRYPTED_PREFIX),
|
|
90
|
+
table: decryptStreamXor(array, table, 0)
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Decrypts an encrypted document's WordDocument and Table streams given the password, dispatching on `fObfuscated` between [MS-DOC] 2.2.6.2's RC4 encryption header and 2.2.6.1's XOR obfuscation (Method 2).
|
|
95
|
+
*
|
|
96
|
+
* Throws `DocUnsupportedError` 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.
|
|
97
|
+
*/
|
|
98
|
+
function decryptDocStreams(wordDocument, table, password, fObfuscated) {
|
|
99
|
+
if (password === void 0) throw new DocUnsupportedError(`this document is ${fObfuscated ? "XOR-obfuscated ([MS-DOC] 2.2.6.1)" : "RC4-encrypted ([MS-DOC] 2.2.6.2)"}; call readDocContent with a password to decrypt it`);
|
|
100
|
+
return fObfuscated ? decryptDocStreamsXor(wordDocument, table, password) : decryptDocStreamsRc4(wordDocument, table, password);
|
|
101
|
+
}
|
|
68
102
|
//#endregion
|
|
69
103
|
export { decryptDocStreams };
|
package/dist/read.cjs
CHANGED
|
@@ -39,8 +39,7 @@ function readDocStreams(bytes, password) {
|
|
|
39
39
|
let wordDocument = wordDocumentStream.bytes;
|
|
40
40
|
let table = tableStream.bytes;
|
|
41
41
|
if (flags.fEncrypted) {
|
|
42
|
-
|
|
43
|
-
const decrypted = require_encryption.decryptDocStreams(wordDocument, table, password);
|
|
42
|
+
const decrypted = require_encryption.decryptDocStreams(wordDocument, table, password, flags.fObfuscated);
|
|
44
43
|
wordDocument = decrypted.wordDocument;
|
|
45
44
|
table = decrypted.table;
|
|
46
45
|
}
|
package/dist/read.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { DocFormatError
|
|
1
|
+
import { DocFormatError } from "./errors.js";
|
|
2
2
|
import { slice } from "./bytes.js";
|
|
3
3
|
import { DATA_STREAM, SUMMARY_INFORMATION_STREAM, WORD_DOCUMENT_STREAM } from "./detect.js";
|
|
4
4
|
import { decryptDocStreams } from "./encryption.js";
|
|
@@ -38,8 +38,7 @@ function readDocStreams(bytes, password) {
|
|
|
38
38
|
let wordDocument = wordDocumentStream.bytes;
|
|
39
39
|
let table = tableStream.bytes;
|
|
40
40
|
if (flags.fEncrypted) {
|
|
41
|
-
|
|
42
|
-
const decrypted = decryptDocStreams(wordDocument, table, password);
|
|
41
|
+
const decrypted = decryptDocStreams(wordDocument, table, password, flags.fObfuscated);
|
|
43
42
|
wordDocument = decrypted.wordDocument;
|
|
44
43
|
table = decrypted.table;
|
|
45
44
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "doc-codec",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.6.0",
|
|
4
4
|
"description": "A hand-written reader for the Word Binary File Format ([MS-DOC], .doc) against the shared document-schema.js content pivot: FIB parsing, piece-table text reconstruction, and CHPX/PAPX formatting exceptions.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -78,7 +78,7 @@
|
|
|
78
78
|
"license": "MIT",
|
|
79
79
|
"packageManager": "pnpm@11.6.0",
|
|
80
80
|
"dependencies": {
|
|
81
|
-
"archive-codec": "1.
|
|
81
|
+
"archive-codec": "1.10.0",
|
|
82
82
|
"document-schema.js": "7.5.0"
|
|
83
83
|
},
|
|
84
84
|
"devDependencies": {
|