diff --git a/CLAUDE.md b/CLAUDE.md index 8589cab..fa820cf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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` | diff --git a/docs/architecture/encryption.md b/docs/architecture/encryption.md new file mode 100644 index 0000000..0ea1b27 --- /dev/null +++ b/docs/architecture/encryption.md @@ -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. diff --git a/package.json b/package.json index 9a148af..19d6120 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/lib/export/CLAUDE.md b/src/lib/export/CLAUDE.md index 87108c2..037cc13 100644 --- a/src/lib/export/CLAUDE.md +++ b/src/lib/export/CLAUDE.md @@ -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 diff --git a/src/lib/import/CLAUDE.md b/src/lib/import/CLAUDE.md index 3f41be2..4530ca5 100644 --- a/src/lib/import/CLAUDE.md +++ b/src/lib/import/CLAUDE.md @@ -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`) diff --git a/src/lib/storage/CLAUDE.md b/src/lib/storage/CLAUDE.md index 12a81f0..02441b6 100644 --- a/src/lib/storage/CLAUDE.md +++ b/src/lib/storage/CLAUDE.md @@ -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. diff --git a/tests/lo-password.test.ts b/tests/lo-password.test.ts new file mode 100644 index 0000000..7dc7a12 --- /dev/null +++ b/tests/lo-password.test.ts @@ -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); + }); +}); diff --git a/tests/lo/password.py b/tests/lo/password.py new file mode 100644 index 0000000..1f0b215 --- /dev/null +++ b/tests/lo/password.py @@ -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())