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.
73 lines
4.2 KiB
Markdown
73 lines
4.2 KiB
Markdown
# 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 import`s 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.
|