From cb0db7dd9627fe775045657e6bdff5aecea89acc Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Thu, 3 Sep 2026 23:50:06 +0200 Subject: [PATCH] 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. --- docs/architecture/encryption.md | 5 ++- src/App.svelte | 46 +++++++++++++++++++++--- src/lib/components/PasswordDialog.svelte | 5 ++- src/lib/i18n/locales/de.ts | 1 + src/lib/i18n/locales/en.ts | 1 + src/lib/storage/CLAUDE.md | 1 + src/lib/storage/documentName.ts | 13 +++++++ 7 files changed, 65 insertions(+), 7 deletions(-) diff --git a/docs/architecture/encryption.md b/docs/architecture/encryption.md index 0ea1b27..7dc63cb 100644 --- a/docs/architecture/encryption.md +++ b/docs/architecture/encryption.md @@ -19,7 +19,10 @@ Encryption is the **last** thing to touch the bytes: `writeHandle`/`download` in .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. +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 diff --git a/src/App.svelte b/src/App.svelte index d3422f5..c90f902 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -38,7 +38,7 @@ import { DEFAULT_NOTE_SETTINGS } from './lib/storage/noteSettings'; import { builtinStyleSheet, type StyleFamily } from './lib/styles/styleSheet'; import { loadHfDoc, saveHfDoc, loadHfDistances, saveHfDistances, loadDifferentFirstPage, saveDifferentFirstPage, loadDifferentOddEven, saveDifferentOddEven, hfIsEmpty, DEFAULT_HF_DISTANCES, loadExtraHfSections, saveExtraHfSections, type HfDoc, type HfZone, type HfDistances, type HfSet } from './lib/storage/headerFooter'; - import { loadDocName, saveDocName, loadDocFormat, saveDocFormat, stripOdtExtension, sanitizeNameForFile, deriveFilename, filenameFor, type DocumentFormat } from './lib/storage/documentName'; + import { loadDocName, saveDocName, loadDocFormat, saveDocFormat, stripOdtExtension, sanitizeNameForFile, deriveFilename, filenameFor, loadDocProtected, saveDocProtected, type DocumentFormat } from './lib/storage/documentName'; import { loadDocProperties, saveDocProperties, EMPTY_DOC_PROPERTIES, type DocProperties } from './lib/storage/docProperties'; import { loadHyphenation, saveHyphenation } from './lib/storage/hyphenation'; import { loadPageNumbering, savePageNumbering, DEFAULT_PAGE_NUMBERING, type PageNumbering } from './lib/storage/pageNumbering'; @@ -340,6 +340,7 @@ $effect(() => { saveDocName(documentName); saveDocFormat(documentFormat); + saveDocProtected(docProtected); }); function setZoom(value: number) { @@ -497,7 +498,11 @@ // The password the document is saved with. Session-only: it is never written to // localStorage, and the autosaved copy there stays unencrypted. let docPassword: string | null = $state(null); + // Set after a reload: the document is protected, but the password went with the session. + let passwordLost = $state(loadDocProtected()); + let docProtected = $derived(docPassword !== null || passwordLost); let passwordSetOpen = $state(false); + let setResolve: ((decided: boolean) => void) | null = null; let passwordAskOpen = $state(false); let passwordWrong = $state(false); let askResolve: ((password: string | null) => void) | null = null; @@ -603,6 +608,7 @@ fileHandle = null; documentFormat = 'odt'; docPassword = null; + passwordLost = false; // Styles and note settings live in the document, so a new one starts from the built-ins setStyleSheet(builtinStyleSheet()); setNoteSettings(DEFAULT_NOTE_SETTINGS); @@ -662,6 +668,29 @@ resolve?.(password); } + function applyPassword(password: string | null): void { + docPassword = password; + passwordLost = false; + setResolve?.(true); + setResolve = null; + } + + function cancelPasswordSet(): void { + setResolve?.(false); + setResolve = null; + } + + // A reload keeps the document marked protected but not its password: the first save + // asks for it again, or for the protection to be lifted, rather than writing the + // file open. False = the user backed out, and the save with it. + function ensurePassword(): Promise { + if (!passwordLost) return Promise.resolve(true); + return new Promise((resolve) => { + setResolve = resolve; + passwordSetOpen = true; + }); + } + // Ask until the password fits or the user gives up; null means give up. async function unprotect(bytes: Uint8Array): Promise<{ bytes: Uint8Array; password: string } | null> { for (;;) { @@ -774,6 +803,7 @@ // The file's password belongs to the document once it is actually the open one, // and an unprotected file drops the previous document's. docPassword = password; + passwordLost = false; if (sourceName) recentFiles = await rememberRecentFile(sourceName, isTemplate ? null : handle); // Warn about fonts the document uses but the browser can't render, so text @@ -874,6 +904,7 @@ async function handleSave() { if (!editor) return; exportMenuOpen = false; + if (!(await ensurePassword())) return; const json = editor.getJSON() as TiptapNode; try { // A document opened as .docx round-trips through the same format, like both @@ -903,6 +934,7 @@ async function handleSaveAs() { if (!editor) return; exportMenuOpen = false; + if (!(await ensurePassword())) return; const json = editor.getJSON() as TiptapNode; try { const { buildOdt } = await import('./lib/export/odt'); @@ -923,6 +955,7 @@ async function handleSaveTemplate() { if (!editor) return; exportMenuOpen = false; + if (!(await ensurePassword())) return; const json = editor.getJSON() as TiptapNode; try { await saveAsTemplate(async (kind) => { @@ -976,6 +1009,7 @@ async function handleSaveDocx() { if (!editor || docxBusy) return; exportMenuOpen = false; + if (!(await ensurePassword())) return; docxBusy = true; try { const json = editor.getJSON() as TiptapNode; @@ -1248,7 +1282,7 @@ onAbout={() => (aboutOpen = true)} onDocProperties={() => (docPropsOpen = true)} onProtect={() => (passwordSetOpen = true)} - hasPassword={docPassword !== null} + hasPassword={docProtected} onAutoCorrect={() => (autoCorrectOpen = true)} onAutoText={() => (autoTextOpen = true)} onNewComment={addComment} @@ -1376,7 +1410,7 @@ {/if}
{t().docProps.title}