Skip to content

Stream OOXML decryption output (without the olefile workaround) - #103

Open
poofeg wants to merge 1 commit into
nolze:masterfrom
poofeg:stream-ooxml-decryption-without-workaround
Open

poofeg wants to merge 1 commit into
nolze:masterfrom
poofeg:stream-ooxml-decryption-without-workaround

Conversation

@poofeg

@poofeg poofeg commented Aug 20, 2026

Copy link
Copy Markdown

Same goal as #102, but without the _OleStreamReader workaround: this version relies on the lazy OleStream from olefile (decalage2/olefile#177), so OOXMLFile.decrypt() reads EncryptedPackage directly via openstream().

Closes #101

Problem

OOXMLFile.decrypt() materialized the entire decrypted OOXML package in memory and copied it several times: the agile/standard decrypt methods accumulated output in a BytesIO and returned a second copy, and the result was copied again into a BytesIO just to run zipfile.is_zipfile. Peak RSS was ~4–5× the input size.

Changes

  • msoffcrypto/method/ecma376_agile.py: extracted the 4096-byte segment loop into a streaming generator decrypt_stream(...); decrypt(...) remains as a thin b"".join(...) wrapper (public API unchanged).
  • msoffcrypto/method/ecma376_standard.py: added decrypt_stream(...) feeding AES-ECB incrementally in chunks and yielding exactly totalSize plaintext bytes; decrypt(...) unchanged.
  • msoffcrypto/format/ooxml.py: OOXMLFile.decrypt() streams decrypted bytes to outfile chunk-by-chunk; the post-decryption check now validates only the end-of-central-directory record in the last 65,557 bytes (_is_zipfile_tail) instead of zipfile.is_zipfile(io.BytesIO(obuf)), so no full-payload copy is made. Same InvalidKeyError on failure.
  • tests/test_ooxml_streaming.py: streaming output is byte-identical to the buffered decrypt(), works with a non-seekable output, and a wrong password still raises InvalidKeyError.

Compatibility note

This version intentionally omits the _OleStreamReader workaround from #102 and expects the lazy OleStream from decalage2/olefile#177 (currently unreleased). Once that lands, the minimum olefile version in pyproject.toml should be bumped accordingly. Until then, users on released olefile (0.47) will still get the eagerly buffered stream — use #102 for a fix that works with any olefile version.

Public API is unchanged: OOXMLFile.decrypt(outfile), OfficeFile.decrypt(outfile) and the method-level decrypt() keep their signatures.

Tests

pytest → 29 passed.

Memory

Measured with /usr/bin/time -l on a 1.79 GB agile-encrypted .ppsx:

  • before: ~4.95 GiB peak RSS
  • after: ~0.88 GiB peak RSS

The remaining ~0.88 GiB comes from olefile.OleFileIO.__init__ parsing the OLE structure (loadfat building the FAT with array + array), which is fixed separately in decalage2/olefile#176.

Stream decrypted bytes to the output file instead of buffering the whole
payload, and validate the zip tail without copying it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

OOXML decrypt() holds the entire decrypted package in memory — add a streaming path

1 participant