Skip to content

fix(document): refuse to save a package we only hold decrypted - #813

Merged
andiwand merged 1 commit into
mainfrom
fix/no-plaintext-save-of-a-decrypted-package
Sep 3, 2026
Merged

fix(document): refuse to save a package we only hold decrypted#813
andiwand merged 1 commit into
mainfrom
fix/no-plaintext-save-of-a-decrypted-package

Conversation

@andiwand

@andiwand andiwand commented Sep 3, 2026

Copy link
Copy Markdown
Member

🤖 Generated with Claude Code

Towards #64.

The hole

odf::Document::is_savable returned !encrypted, which is true for is_savable(false) whatever the document was decoded from — a decrypted package included. And save walked the decrypted filesystem, stripping every manifest:encryption-data node on the way out (odf_document.cpp, under a bare // TODO).

So: open a password-protected ODF with its password, save it, and you get a plaintext package. No error, no warning, no flag on the resulting file. The content leaves the protection its author asked for, and the only trace is that it opens without a password afterwards. .docx had the same shape — ooxml/text's save rebuilds the zip from the decrypted filesystem.

test/src/document_test.cpp::edit_ods_diff was quietly relying on exactly this: it decrypted pages.ods, edited it, saved, and reopened the result without a password.

The change

internal::Document carries the EncryptionState its file was decoded at — the two file classes already track it — and exposes is_decrypted(). The two engines that can save consult it:

bool Document::is_savable(const bool encrypted) const noexcept {
  return !encrypted && !is_decrypted();
}

save guards on it as well, not just is_savable. Document::save(path) checks is_savable at the public boundary, but save(std::ostream &) and save_to_memory() do not, so the guard has to sit in the engine to close all three. Every other engine inherits the base's is_savable == false and is unaffected.

The manifest rewrite goes away with it. It existed only to make this save "work"; a package that was never encrypted has no manifest:encryption-data to strip, so the branch was dead the moment the decrypted case became an error — and the manifest is now copied through like any other part instead of being re-serialised.

This does not implement encrypted save (#64). It stops the silent downgrade until that lands.

Breaking

A caller that opened a protected document and saved it now gets UnsupportedOperation where it used to get a plaintext file. That is the point, and the changelog says so. No signature changes.

Verified

edit_ods_diff now applies its diff in memory and asserts there; the round trip through a file is already covered by the odt and docx cases. New Document.a_decrypted_package_is_not_savable pins all three save overloads plus is_savable, and that no file is left behind.

Full suite: 1407 passed, 6 pre-existing skips. No rendering code is touched, so the reference output is unchanged and no pin moves. Changed TUs re-checked under -Werror.

@andiwand
andiwand force-pushed the fix/no-plaintext-save-of-a-decrypted-package branch from a718908 to e8c1abc Compare September 3, 2026 19:16
`is_savable(false)` was true for a document decrypted from a password-protected
package, and odf's `save` stripped every `manifest:encryption-data` on the way
out. Opening a protected file with its password and saving it therefore produced
a plaintext package, silently: the content left the protection its author asked
for, and nothing said so. `.docx` had the same shape, rebuilding the zip from the
decrypted filesystem.

A document now carries the `EncryptionState` its file was decoded at, and the two
savable engines refuse one that was decrypted. Every public `save` overload
consults `is_savable` before it writes — the stream and memory ones did not — so
the refusal reaches all of them. The manifest rewrite goes with it: nothing else
ever had encryption data to strip.

Re-encrypting on save is #64 and unaffected; this only stops the hole in the
meantime.

Towards #64.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UDi21mwuKRGvGiFcGwETtS
@andiwand
andiwand force-pushed the fix/no-plaintext-save-of-a-decrypted-package branch from e8c1abc to 7fbed17 Compare September 3, 2026 19:17
@andiwand
andiwand merged commit 9b54f1f into main Sep 3, 2026
25 checks passed
@andiwand
andiwand deleted the fix/no-plaintext-save-of-a-decrypted-package branch September 3, 2026 19:18
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.

1 participant