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:
1 parent
2a20f1d107
commit
b3b8e58bd4
8 files changed
+228
-4
No files matched your search
@@ -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` |
|
||||
@@ -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
@@ -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,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,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`)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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())
|
||||
Reference in new issue
Block a user