From 5cf5a45682490f97d4fa86da99d3291e055bc31c Mon Sep 17 00:00:00 2001 From: eezd Date: Sat, 17 Oct 2026 14:51:08 +0800 Subject: [PATCH] Add client-side note encryption - WebCrypto keyring (PBKDF2 600k + AES-GCM master key) and per-note/per-image keys - Encrypt/decrypt commands convert the note and its own images, deleting the originals - Unlock, setup and change-password dialogs; manual lock; lock on page close - Encrypted images are decrypted to blob URLs; pasted images are encrypted before upload - Rewrite image links in the browser when renaming an encrypted note - Skip local drafts for encrypted notes and confirm before closing with unsaved changes - Show encrypted notes as locked with an explanation outside secure contexts --- web/src/app/EditorPane.vue | 14 +- web/src/app/Icon.vue | 1 + web/src/app/KeyDialog.vue | 86 +++++++++++ web/src/app/QuickSwitcher.vue | 10 +- web/src/app/TabBar.vue | 3 +- web/src/app/TrashDialog.vue | 2 +- web/src/app/TreeNode.vue | 7 +- web/src/app/Workspace.vue | 3 + web/src/app/api.ts | 35 +++++ web/src/app/app.css | 4 + web/src/app/crypto.test.ts | 108 ++++++++++++++ web/src/app/crypto.ts | 200 ++++++++++++++++++++++++++ web/src/app/encryption.ts | 100 +++++++++++++ web/src/app/paths.ts | 33 ++++- web/src/app/workspace.ts | 256 ++++++++++++++++++++++++++++++--- web/src/editor/index.ts | 5 +- web/src/editor/livepreview.ts | 7 +- web/src/editor/widgets.ts | 5 +- web/src/markdown/links.test.ts | 22 +++ web/src/markdown/links.ts | 37 +++++ 20 files changed, 899 insertions(+), 39 deletions(-) create mode 100644 web/src/app/KeyDialog.vue create mode 100644 web/src/app/crypto.test.ts create mode 100644 web/src/app/crypto.ts create mode 100644 web/src/app/encryption.ts create mode 100644 web/src/markdown/links.test.ts create mode 100644 web/src/markdown/links.ts diff --git a/web/src/app/EditorPane.vue b/web/src/app/EditorPane.vue index b548114..01e5aa9 100644 --- a/web/src/app/EditorPane.vue +++ b/web/src/app/EditorPane.vue @@ -3,7 +3,8 @@ import { EditorState, type StateEffect, type Text } from '@codemirror/state' import type { EditorView } from '@codemirror/view' import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { createEditorView } from '../editor' -import { activeTab, attachView, editorStateOf, noteRefOf, revealPending, storeViewState, ws } from './workspace' +import { keyState } from './encryption' +import { activeTab, attachView, editorStateOf, noteRefOf, revealPending, storeViewState, unlockNotes, ws } from './workspace' const host = ref() const tab = computed(activeTab) @@ -28,6 +29,8 @@ function show() { scrollPositions.set(shown, { doc: view.state.doc, snapshot: view.scrollSnapshot(), top: view.scrollDOM.scrollTop }) } if (!ref || !state) { + // 锁定后视图中不能留下已解密的内容 + if (shown) view.setState(EditorState.create()) shown = undefined return } @@ -60,10 +63,17 @@ watch(() => [ws.active, tab.value?.status], show, { flush: 'post' }) diff --git a/web/src/app/Icon.vue b/web/src/app/Icon.vue index 2712354..e5173d6 100644 --- a/web/src/app/Icon.vue +++ b/web/src/app/Icon.vue @@ -14,6 +14,7 @@ const paths = { restore: 'M3 12a9 9 0 1 0 3-6.7L3 8M3 3v5h5', edit: 'M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z', search: 'M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14zM21 21l-4.35-4.35', + lock: 'M5 11h14v10H5zM8 11V7a4 4 0 0 1 8 0v4', command: 'M18 3a3 3 0 0 0-3 3v12a3 3 0 1 0 3-3H6a3 3 0 1 0 3 3V6a3 3 0 1 0-3 3h12a3 3 0 0 0 0-6z', } defineProps<{ name: keyof typeof paths; size?: number }>() diff --git a/web/src/app/KeyDialog.vue b/web/src/app/KeyDialog.vue new file mode 100644 index 0000000..4b2f707 --- /dev/null +++ b/web/src/app/KeyDialog.vue @@ -0,0 +1,86 @@ + + + diff --git a/web/src/app/QuickSwitcher.vue b/web/src/app/QuickSwitcher.vue index da90c83..662c068 100644 --- a/web/src/app/QuickSwitcher.vue +++ b/web/src/app/QuickSwitcher.vue @@ -3,7 +3,7 @@ import { computed, onMounted, ref, watch } from 'vue' import { availableCommands, formatKeys } from './commands' import { fuzzyMatch } from './fuzzy' import Icon from './Icon.vue' -import { dirname, NOTE_EXT, noteTitle } from './paths' +import { dirname, ENCRYPTED_EXT, isEncrypted, NOTE_EXT, noteTitle } from './paths' import { createNoteAt, openAt, ws } from './workspace' const MAX_ITEMS = 50 @@ -12,7 +12,7 @@ const RECENT_COMMANDS_MAX = 10 interface Item { key: string - icon: 'note' | 'noteAdd' | 'command' + icon: 'note' | 'lock' | 'noteAdd' | 'command' title: string // 笔记为所在目录,命令为快捷键 detail: string @@ -59,7 +59,7 @@ function noteItems(q: string): Item[] { const offset = dir ? dir.length + 1 : 0 return { key: path, - icon: 'note', + icon: isEncrypted(path) ? 'lock' : 'note', title: noteTitle(path), detail: dir, titleHits: new Set(positions.filter((p) => p >= offset).map((p) => p - offset)), @@ -80,7 +80,7 @@ function noteItems(q: string): Item[] { } const scored: { path: string; score: number; positions: number[] }[] = [] for (const e of notes) { - const target = e.path.slice(0, -NOTE_EXT.length) + const target = e.path.replace(/\.md(\.enc)?$/, '') const m = fuzzyMatch(q, target, target.lastIndexOf('/') + 1) if (m) scored.push({ path: e.path, ...m }) } @@ -93,7 +93,7 @@ function noteItems(q: string): Item[] { const path = name + NOTE_EXT if ( name.split('/').every((seg) => seg.trim() && !seg.startsWith('.')) && - !ws.entries.some((e) => e.path.toLowerCase() === path.toLowerCase()) + !ws.entries.some((e) => [path, name + ENCRYPTED_EXT].some((p) => e.path.toLowerCase() === p.toLowerCase())) ) { items.push({ key: '\0create', diff --git a/web/src/app/TabBar.vue b/web/src/app/TabBar.vue index 3c6f1d0..ded70c1 100644 --- a/web/src/app/TabBar.vue +++ b/web/src/app/TabBar.vue @@ -1,6 +1,6 @@ @@ -17,6 +17,7 @@ import { closeTab, ws } from './workspace' @click="ws.active = tab.path" @auxclick.prevent="$event.button === 1 && closeTab(tab.path)" > + {{ noteTitle(tab.path) }} diff --git a/web/src/app/TreeNode.vue b/web/src/app/TreeNode.vue index 6eecde5..6566295 100644 --- a/web/src/app/TreeNode.vue +++ b/web/src/app/TreeNode.vue @@ -1,7 +1,7 @@