test: LibreOffice opens what we encrypt, and we open what it encrypts

Five legs in tests/lo-password.test.ts, driven through the repo's first UNO
helper because --convert-to cannot pass a password; self-skips without soffice
or python3-uno and runs with npm run test:lo. The probed format details live in
docs/architecture/encryption.md.
This commit is contained in:
Steffen Becker committed 2026-09-04 19:30:04 +02:00
1 parent 2a20f1d107
commit b3b8e58bd4
8 files changed
+228 -4

No files matched your search

+3 -3
View File
@@ -89,10 +89,9 @@ src/
editor/
extensions.ts – the TipTap extension registry
extensions/ – the custom TipTap/ProseMirror extensions, one file per feature
utils/ – framework-free helpers (fontDetect, specialChars, wordCount,
orderedListTypes, historyLog, colorDebug)
utils/ – framework-free helpers (fontDetect, specialChars, wordCount, orderedListTypes, historyLog, colorDebug)
math/ – formula AST: LaTeX parser/serializer, MathML, OMML
export/ import/ spell/ storage/ styles/ – ODF+DOCX I/O, spell-check, persistence, named styles
export/ import/ spell/ storage/ styles/ crypto/ – ODF+DOCX I/O, spell-check, persistence, named styles, password protection
templates/ – built-in document templates: registry + one localized module each
styles/ – global.css + editor.css
```
@@ -117,4 +116,5 @@ Directory-level `CLAUDE.md` files load automatically when you touch that directo
| Text effects, indents, tab stops, list markers, date fields | `docs/architecture/formatting.md` |
| Formulas (LaTeX model, MathML, OMML, the math font) | `docs/architecture/formulas.md` |
| Footnotes and endnotes (model, page-foot reservation) | `docs/architecture/notes.md` |
| Password protection of the saved file | `docs/architecture/encryption.md` |
| Headless browser + PDF-export debugging | `docs/headless-testing.md` |
+69
View File
@@ -0,0 +1,69 @@
# 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.
## 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.
+1 -1
View File
@@ -17,7 +17,7 @@
"check": "svelte-check --tsconfig ./tsconfig.json",
"test": "vitest run",
"test:watch": "vitest",
"test:lo": "vitest run tests/lo-roundtrip.test.ts tests/lo-render-consistency.test.ts",
"test:lo": "vitest run tests/lo-roundtrip.test.ts tests/lo-render-consistency.test.ts tests/lo-password.test.ts",
"test:smoke": "node tests/smoke/run.mjs",
"test:coverage": "vitest run --coverage",
"test:parity": "node tests/render-parity/run.mjs",
+3
View File
@@ -3,6 +3,9 @@
`odt.ts` (ODF), `docx.ts` (Word), `pdf.ts` (html2canvas + jsPDF), `saveFile.ts`.
Frames, tables and pagination have their own deep-dives in `docs/architecture/`.
A password set for the document is applied in `saveFile.ts`'s two write primitives, after
every pass here and after the template conversion — see `docs/architecture/encryption.md`.
`saveFile.ts` picks the path: `showSaveFilePicker` where there is one, else a download —
Gecko has none and Brave disables the whole File System Access API by default. The first
fallback download shows a one-time hint (`edentext-download-hint`) that the browser's
+2
View File
@@ -2,6 +2,8 @@
`odt.ts` + `styleResolver.ts` (ODF), `docx.ts` + `docxStyles.ts` (Word),
`imageFormats.ts` (shared). The DOCX importer mirrors the ODF one section for section.
A password-protected file is decrypted before either importer sees it — see
`docs/architecture/encryption.md`.
## ODF import (`odt.ts`, `styleResolver.ts`)
+3
View File
@@ -1,5 +1,8 @@
# `src/lib/storage/`
The autosaved document (`autosave.ts`) stays **plain text** even when the document is
password-protected: the protection is on the saved file, not on the browser copy.
## Page layout settings
- **`pageMargins.ts`** — `PageMargins` in **cm** (default `{ top: 2, bottom: 2, left: 2, right: 2 }` — LibreOffice's; clamped 0–10). `applyMarginVars` sets `--user-margin-*` (px) on `:root`; `PX_PER_CM = 96/2.54`. Optional `mirrored` (ODF `style:page-usage="mirrored"`, Word `w:mirrorMargins`) makes left/right the **inner/outer** pair, which an even page swaps: `--user-margin-mirror` carries the difference, `pageBreaks.ts` insets an even page's blocks by it and `HeaderFooterLayer.svelte` moves the band. The key is **absent**, never `false`, when off — margins are compared whole in the round-trip tests.
+68
View File
@@ -0,0 +1,68 @@
// Password protection against LibreOffice, both ways: it opens what we encrypt, and we
// open what it encrypts. Needs soffice and python3-uno; self-skips without either, so
// plain `npm test` / CI stay green.
import { describe, it, expect } from 'vitest';
import { writeFileSync, readFileSync, mkdirSync } from 'node:fs';
import { execFileSync, execSync } from 'node:child_process';
import { buildOdt } from '../src/lib/export/odt';
import { buildDocx } from '../src/lib/export/docx';
import { importOdt } from '../src/lib/import/odt';
import { importDocx } from '../src/lib/import/docx';
import { encryptOdf, decryptOdf } from '../src/lib/crypto/odf';
import { encryptOoxml, decryptOoxml } from '../src/lib/crypto/ooxml';
function has(command: string): boolean {
try { execSync(command, { stdio: 'ignore' }); return true; }
catch { return false; }
}
const READY = has('command -v soffice') && has('python3 -c "import uno"');
const DIR = '/tmp/lo-password';
const PW = 'geheim123';
const TEXT = 'Ein Satz hinter Schloss und Riegel';
const doc = { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: TEXT }] }] };
// Runs LibreOffice on the file; the text it reads back comes out on stdout.
function libreOffice(args: string[]): string {
return execFileSync('python3', ['tests/lo/password.py', ...args], {
encoding: 'utf8', timeout: 180000, stdio: ['ignore', 'pipe', 'pipe'],
});
}
describe.skipIf(!READY)('password protection against LibreOffice (needs soffice + python3-uno)', () => {
it('opens our encrypted .odt', { timeout: 180000 }, async () => {
mkdirSync(DIR, { recursive: true });
writeFileSync(`${DIR}/ours.odt`, await encryptOdf(await buildOdt(doc), PW));
expect(libreOffice(['--in', `${DIR}/ours.odt`, '--password', PW])).toContain(TEXT);
});
it('opens our encrypted .docx', { timeout: 180000 }, async () => {
mkdirSync(DIR, { recursive: true });
writeFileSync(`${DIR}/ours.docx`, await encryptOoxml(await buildDocx(doc), PW));
expect(libreOffice(['--in', `${DIR}/ours.docx`, '--password', PW])).toContain(TEXT);
});
it('refuses our encrypted .odt with the wrong password', { timeout: 180000 }, async () => {
mkdirSync(DIR, { recursive: true });
writeFileSync(`${DIR}/ours.odt`, await encryptOdf(await buildOdt(doc), PW));
expect(() => libreOffice(['--in', `${DIR}/ours.odt`, '--password', 'falsch'])).toThrow();
});
it('reads the .odt LibreOffice encrypts', { timeout: 180000 }, async () => {
mkdirSync(DIR, { recursive: true });
writeFileSync(`${DIR}/plain.odt`, await buildOdt(doc));
libreOffice(['--in', `${DIR}/plain.odt`, '--out', `${DIR}/lo.odt`,
'--filter', 'writer8', '--store-password', PW]);
const plain = await decryptOdf(new Uint8Array(readFileSync(`${DIR}/lo.odt`)), PW);
expect(JSON.stringify(importOdt(plain).content)).toContain(TEXT);
});
it('reads the .docx LibreOffice encrypts', { timeout: 180000 }, async () => {
mkdirSync(DIR, { recursive: true });
writeFileSync(`${DIR}/plain.odt`, await buildOdt(doc));
libreOffice(['--in', `${DIR}/plain.odt`, '--out', `${DIR}/lo.docx`,
'--filter', 'MS Word 2007 XML', '--store-password', PW]);
const plain = await decryptOoxml(new Uint8Array(readFileSync(`${DIR}/lo.docx`)), PW);
expect(JSON.stringify(importDocx(plain).content)).toContain(TEXT);
});
});
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env python3
"""Open (and optionally re-save) a document through LibreOffice with a password.
`soffice --convert-to` cannot pass one, so this drives LibreOffice over UNO instead.
Prints the document's text on success and exits nonzero when the file will not open.
"""
import argparse
import os
import random
import subprocess
import sys
import tempfile
import time
import uno
from com.sun.star.beans import PropertyValue
def props(**kw):
return tuple(PropertyValue(Name=k, Value=v) for k, v in kw.items())
def connect(port, profile):
socket = f'socket,host=localhost,port={port};urp;StarOffice.ComponentContext'
proc = subprocess.Popen(
['soffice', '--headless', '--norestore', f'--accept={socket}',
f'-env:UserInstallation=file://{profile}'],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
local = uno.getComponentContext()
for _ in range(120):
try:
resolver = local.ServiceManager.createInstanceWithContext(
'com.sun.star.bridge.UnoUrlResolver', local)
return proc, resolver.resolve('uno:' + socket)
except Exception:
time.sleep(0.5)
proc.kill()
raise SystemExit('LibreOffice did not start')
def main():
ap = argparse.ArgumentParser()
ap.add_argument('--in', dest='source', required=True)
ap.add_argument('--password', default='')
ap.add_argument('--out')
ap.add_argument('--filter', default='writer8')
ap.add_argument('--store-password', default='')
args = ap.parse_args()
port = random.randint(2100, 2999)
profile = tempfile.mkdtemp(prefix='lo-password-')
proc, ctx = connect(port, profile)
try:
desktop = ctx.ServiceManager.createInstanceWithContext('com.sun.star.frame.Desktop', ctx)
load = {'Hidden': True}
if args.password:
load['Password'] = args.password
doc = desktop.loadComponentFromURL(
'file://' + os.path.abspath(args.source), '_blank', 0, props(**load))
if doc is None:
raise SystemExit('could not open the document')
print(doc.Text.getString()[:200])
if args.out:
store = {'FilterName': args.filter}
if args.store_password:
store['Password'] = args.store_password
doc.storeToURL('file://' + os.path.abspath(args.out), props(**store))
doc.close(False)
finally:
try:
ctx.ServiceManager.createInstanceWithContext(
'com.sun.star.frame.Desktop', ctx).terminate()
except Exception:
pass
proc.wait(timeout=30)
if __name__ == '__main__':
sys.exit(main())