Files
Steffen Becker cb0db7dd96 fix: a reload keeps the document protected, and the first save asks for the password
The password itself is never stored, so a reload left the document open and
the next save wrote the protected file unencrypted without a word. The fact
of the protection now survives; the save dialog asks for the password again
or lets the protection go, and backing out cancels the save.
2026-09-04 19:30:12 +02:00

4.2 KiB

Password protection

src/lib/crypto/ encrypts the saved file and decrypts an opened one. Everything below was probed against LibreOffice 25.2 rather than read off a specification — the two places where the file alone does not say what happens are marked.

  • protect.ts — the only module the app and export/saveFile.ts import. isProtected, encryptPackage, decryptPackage; it sniffs the container and await imports the format module, so neither @noble/hashes nor @noble/ciphers enters the main bundle.
  • odf.ts, ooxml.ts, cfb.ts (the compound file OOXML encryption is wrapped in), errors.ts (the two canonical messages, matched by i18n/importMessages.ts).

Crypto comes from @noble/* except PBKDF2, which WebCrypto does natively and much faster.

Where it sits

Encryption is the last thing to touch the bytes: writeHandle/download in export/saveFile.ts, after every post-processing pass and after template.ts has turned an .odt into an .ott. Decryption is the first: the top of applyImport in App.svelte, before convertUnsupportedImages, which would otherwise unzip an encrypted archive and find nothing. The password lives in docPassword for the session only — the autosaved copy in localStorage stays plain text, as both reference word processors' recovery copies do. What survives a reload is the fact of the protection (edentext-doc-protected): the first save then asks for the password again, or for the protection to be lifted, so a reload never turns a protected file into an open one.

ODF

What we write (LibreOffice's default since 24.8, "wholesome" encryption): three entries — mimetype (stored, plain, first), encrypted-package, META-INF/manifest.xml (plain). The whole inner package is raw-deflated, then AES-256-GCM encrypted; the entry is IV ‖ ciphertext ‖ tag. The repeated 12-byte nonce ahead of the ciphertext appears in no manifest — it comes from LibreOffice's NSS cipher context, which compares it on reading. Key: argon2id(SHA-256(password), salt, t=3, m=64 MiB, p=4) → 32 bytes. There is no checksum; the GCM tag is the password check.

What we also read: the per-entry form of ODF 1.2/1.3. Each entry except mimetype and the manifest is raw-deflated, then AES-256-CBC with W3C padding (the last byte counts the added bytes), stored. Key: PBKDF2(SHA-256(password), salt, iterations, 32) with HMAC-SHA-1 as the PRF. manifest:checksum is SHA-256 over the first 1024 bytes of the decrypted, still deflated stream — the wrong-password test. LibreOffice writes 100000 iterations per entry, and 600000 for a whole-package document.

Blowfish CFB (OpenOffice ≤ 2.x) is refused with UNSUPPORTED_ENCRYPTION.

The CRC32 in the zip entry of an encrypted stream covers the ciphertext, so fflate's zipSync(..., { level: 0 }) writes exactly what LibreOffice does — no own zip writer needed.

OOXML

An encrypted .docx is not a zip: it is a compound file holding EncryptionInfo and EncryptedPackage. cfb.ts writes and reads that container, MiniFAT included — EncryptionInfo is a few hundred bytes and therefore lives in the mini stream.

What we write: agile encryption (EncryptionInfo 4.4) with Word's own parameters — spinCount 100000, SHA-512, AES-256-CBC, 16-byte blocks and salts, dataIntegrity HMAC. The package is encrypted in 4096-byte segments, each under H(keySalt ‖ LE32(index)), behind an 8-byte plain length.

What we also read: standard encryption (3.2), which LibreOffice writes — one AES-ECB key from 50000 SHA-1 rounds, password checked against the verifier. RC4 and XOR are refused.

LibreOffice writes no \x06DataSpaces streams and we write none either. LibreOffice opens ours; that Word does too is the one claim here not verified in this container.

Verifying

npm run test:lo includes tests/lo-password.test.ts: LibreOffice opens our encrypted .odt and .docx, refuses ours with a wrong password, and we read back what it encrypts in both formats. It drives LibreOffice through tests/lo/password.py (UNO — --convert-to cannot pass a password) and self-skips without soffice or python3-uno. The unit tests in tests/unit/{odf,ooxml}-encryption.test.ts and cfb-container.test.ts need neither.