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
This commit is contained in:
1 parent
35d3138c7d
commit
5cf5a45682
20 files changed
+899
-39
No files matched your search
@@ -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<HTMLElement>()
|
||||
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' })
|
||||
|
||||
<template>
|
||||
<div class="rm-editor-pane">
|
||||
<div v-show="tab && tab.status !== 'loading' && tab.status !== 'error'" ref="host" class="rm-editor-host" />
|
||||
<div v-show="tab && !['loading', 'error', 'locked'].includes(tab.status)" ref="host" class="rm-editor-host" />
|
||||
<div v-if="!tab" class="rm-empty">
|
||||
<p>选择或新建一篇笔记</p>
|
||||
</div>
|
||||
<div v-else-if="tab.status === 'error'" class="rm-empty"><p>笔记打开失败</p></div>
|
||||
<div v-else-if="tab.status === 'locked'" class="rm-empty">
|
||||
<template v-if="keyState.supported">
|
||||
<p>这是一篇加密笔记</p>
|
||||
<button class="rm-btn rm-btn-primary" @click="unlockNotes()">解锁</button>
|
||||
</template>
|
||||
<p v-else class="rm-locked-hint">这是一篇加密笔记。当前连接不是 HTTPS,浏览器不允许网页使用加密功能,无法查看。请通过 HTTPS 访问。</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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 }>()
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { ApiError } from './api'
|
||||
import { WrongPasswordError } from './crypto'
|
||||
import { changePassword, closeKeyDialog, keyState, setupKeyring, unlock } from './encryption'
|
||||
import Icon from './Icon.vue'
|
||||
import { toast } from './workspace'
|
||||
|
||||
// 与登录密码的最短长度一致(internal/auth MinPasswordLen)
|
||||
const MIN_LENGTH = 8
|
||||
|
||||
const mode = computed(() => keyState.dialog!)
|
||||
const title = computed(() => ({ unlock: '解锁加密笔记', setup: '设置加密密码', change: '修改加密密码' })[mode.value])
|
||||
const current = ref('')
|
||||
const password = ref('')
|
||||
const confirm = ref('')
|
||||
const error = ref('')
|
||||
const busy = ref(false)
|
||||
const form = ref<HTMLFormElement>()
|
||||
|
||||
// autofocus 对动态插入的元素不一定生效
|
||||
onMounted(() => form.value?.querySelector('input')?.focus())
|
||||
|
||||
async function submit() {
|
||||
error.value = ''
|
||||
if (mode.value !== 'unlock') {
|
||||
if ([...password.value].length < MIN_LENGTH) return void (error.value = `密码至少 ${MIN_LENGTH} 个字符`)
|
||||
if (password.value !== confirm.value) return void (error.value = '两次输入的密码不一致')
|
||||
}
|
||||
busy.value = true
|
||||
try {
|
||||
if (mode.value === 'unlock') await unlock(password.value)
|
||||
else if (mode.value === 'setup') await setupKeyring(password.value)
|
||||
else {
|
||||
await changePassword(current.value, password.value)
|
||||
toast('加密密码已修改')
|
||||
}
|
||||
closeKeyDialog(true)
|
||||
} catch (err) {
|
||||
if (err instanceof WrongPasswordError) error.value = mode.value === 'change' ? '当前密码错误' : '密码错误'
|
||||
else if (err instanceof ApiError && err.status === 409) error.value = '加密密码已在其他地方设置,请关闭后重新解锁'
|
||||
else if (err instanceof ApiError && err.status === 412) error.value = '加密密码刚在其他地方被修改,请关闭后重试'
|
||||
else error.value = err instanceof Error ? err.message : String(err)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rm-dialog-backdrop" @click.self="closeKeyDialog(false)">
|
||||
<div class="rm-dialog rm-key-dialog" role="dialog" :aria-label="title">
|
||||
<div class="rm-dialog-head">
|
||||
<h2>{{ title }}</h2>
|
||||
<button class="rm-icon-btn" title="关闭" @click="closeKeyDialog(false)"><Icon name="close" /></button>
|
||||
</div>
|
||||
<form ref="form" class="rm-auth-card rm-key-form" @submit.prevent="submit">
|
||||
<p v-if="mode === 'setup'" class="rm-auth-hint">
|
||||
加密密码与登录密码分开,所有加密笔记共用。<strong>忘记加密密码将无法恢复任何加密笔记</strong>,请妥善保管。
|
||||
</p>
|
||||
<p v-else-if="mode === 'change'" class="rm-auth-hint">修改后已加密的笔记无需重新加密,旧密码立即失效。</p>
|
||||
<label v-if="mode === 'change'">
|
||||
<span>当前密码</span>
|
||||
<input v-model="current" type="password" autocomplete="current-password" required />
|
||||
</label>
|
||||
<label>
|
||||
<span>{{ mode === 'change' ? '新密码' : '加密密码' }}</span>
|
||||
<input
|
||||
v-model="password"
|
||||
type="password"
|
||||
:autocomplete="mode === 'unlock' ? 'current-password' : 'new-password'"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label v-if="mode !== 'unlock'">
|
||||
<span>确认密码</span>
|
||||
<input v-model="confirm" type="password" autocomplete="new-password" required />
|
||||
</label>
|
||||
<p v-if="error" class="rm-auth-error">{{ error }}</p>
|
||||
<button class="rm-btn rm-btn-primary" type="submit" :disabled="busy">
|
||||
{{ busy ? '处理中…' : mode === 'unlock' ? '解锁' : '确定' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import Icon from './Icon.vue'
|
||||
import { noteTitle } from './paths'
|
||||
import { isEncrypted, noteTitle } from './paths'
|
||||
import { closeTab, ws } from './workspace'
|
||||
</script>
|
||||
|
||||
@@ -17,6 +17,7 @@ import { closeTab, ws } from './workspace'
|
||||
@click="ws.active = tab.path"
|
||||
@auxclick.prevent="$event.button === 1 && closeTab(tab.path)"
|
||||
>
|
||||
<Icon v-if="isEncrypted(tab.path)" name="lock" :size="12" class="rm-lock-icon" />
|
||||
<span class="rm-tab-title">{{ noteTitle(tab.path) }}</span>
|
||||
<button class="rm-tab-close" :class="{ dirty: tab.dirty }" title="关闭" @click.stop="closeTab(tab.path)">
|
||||
<span class="rm-dirty-dot" />
|
||||
|
||||
@@ -48,7 +48,7 @@ onMounted(load)
|
||||
<li v-for="item in items ?? []" :key="item.id">
|
||||
<Icon :name="item.dir ? 'folder' : 'note'" />
|
||||
<div class="rm-trash-info">
|
||||
<span class="rm-trash-path">{{ item.path.replace(/\.md$/, '') }}</span>
|
||||
<span class="rm-trash-path">{{ item.path.replace(/\.md(\.enc)?$/, '') }}</span>
|
||||
<span class="rm-trash-time">{{ fmt.format(item.deletedAt) }}</span>
|
||||
</div>
|
||||
<button class="rm-btn" @click="doRestore(item)">恢复</button>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import Icon from './Icon.vue'
|
||||
import { basename, joinPath } from './paths'
|
||||
import { basename, isEncrypted, joinPath, noteTitle } from './paths'
|
||||
import { expanded, type TreeNode } from './tree'
|
||||
import { createFolder, createNote, move, openNote, remove, renameTarget, ws } from './workspace'
|
||||
|
||||
@@ -26,7 +26,7 @@ watch(
|
||||
isRenaming,
|
||||
async (on) => {
|
||||
if (!on) return
|
||||
draft.value = props.node.dir ? props.node.name : props.node.name.replace(/\.md$/, '')
|
||||
draft.value = props.node.dir ? props.node.name : noteTitle(props.node.name)
|
||||
await nextTick()
|
||||
input.value?.focus()
|
||||
input.value?.select()
|
||||
@@ -104,7 +104,8 @@ function onDrop(e: DragEvent) {
|
||||
@keydown.esc.prevent="cancelRename"
|
||||
@blur="commitRename"
|
||||
/>
|
||||
<span v-else class="rm-tree-name">{{ node.dir ? node.name : node.name.replace(/\.md$/, '') }}</span>
|
||||
<span v-else class="rm-tree-name">{{ node.dir ? node.name : noteTitle(node.name) }}</span>
|
||||
<Icon v-if="!isRenaming && isEncrypted(node.path)" name="lock" :size="12" class="rm-lock-icon" />
|
||||
<button class="rm-icon-btn rm-tree-more" title="更多操作" @click.stop="menuOpen = !menuOpen">
|
||||
<Icon name="more" />
|
||||
</button>
|
||||
|
||||
@@ -3,6 +3,8 @@ import { computed, onBeforeUnmount, onMounted } from 'vue'
|
||||
import { api } from './api'
|
||||
import { registerCommand } from './commands'
|
||||
import EditorPane from './EditorPane.vue'
|
||||
import { keyState } from './encryption'
|
||||
import KeyDialog from './KeyDialog.vue'
|
||||
import Icon from './Icon.vue'
|
||||
import NoteBanner from './NoteBanner.vue'
|
||||
import QuickSwitcher from './QuickSwitcher.vue'
|
||||
@@ -67,5 +69,6 @@ onBeforeUnmount(() => {
|
||||
</main>
|
||||
<QuickSwitcher v-if="ws.switcher !== null" />
|
||||
<TrashDialog v-if="ws.trashOpen" @close="ws.trashOpen = false" />
|
||||
<KeyDialog v-if="keyState.dialog" :key="keyState.dialog" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -96,6 +96,15 @@ export const api = {
|
||||
return (await json<{ rev: string }>(res)).rev
|
||||
},
|
||||
|
||||
// 加密/取消加密:写入 path 并删除同名的另一种笔记 from,ifMatch 为 from 的版本
|
||||
async convert(from: string, path: string, content: string, ifMatch: string): Promise<string> {
|
||||
const res = request('PUT', `/api/v1/files/${encodePath(path)}?convertFrom=${encodeURIComponent(from)}`, {
|
||||
body: content,
|
||||
headers: { 'Content-Type': 'text/plain; charset=utf-8', 'If-Match': `"${ifMatch}"` },
|
||||
})
|
||||
return (await json<{ rev: string }>(res)).rev
|
||||
},
|
||||
|
||||
deleteFile: (path: string) => json<TrashItem>(request('DELETE', '/api/v1/files/' + encodePath(path))),
|
||||
move: (from: string, to: string) => request('POST', '/api/v1/move', { json: { from, to } }),
|
||||
mkdir: (path: string) => request('POST', '/api/v1/folders', { json: { path } }),
|
||||
@@ -106,6 +115,32 @@ export const api = {
|
||||
return (await json<{ link: string }>(request('POST', '/api/v1/assets?note=' + encodeURIComponent(note), { body: form }))).link
|
||||
},
|
||||
|
||||
// 读取 /api/v1/raw 地址的文件内容(加密图片需要取回后在浏览器解密)
|
||||
async raw(url: string): Promise<Blob> {
|
||||
return (await request('GET', url)).blob()
|
||||
},
|
||||
|
||||
deleteAsset: (path: string) => request('DELETE', '/api/v1/assets/' + encodePath(path)),
|
||||
|
||||
// 未设置过加密密码时返回 null
|
||||
async keyring(): Promise<{ text: string; rev: string } | null> {
|
||||
try {
|
||||
const res = await request('GET', '/api/v1/keyring')
|
||||
return { text: await res.text(), rev: (res.headers.get('ETag') ?? '').replace(/"/g, '') }
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 404) return null
|
||||
throw err
|
||||
}
|
||||
},
|
||||
|
||||
// ifMatch 为空字符串表示首次创建
|
||||
async putKeyring(text: string, ifMatch: string): Promise<string> {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||
if (ifMatch) headers['If-Match'] = `"${ifMatch}"`
|
||||
else headers['If-None-Match'] = '*'
|
||||
return (await json<{ rev: string }>(request('PUT', '/api/v1/keyring', { body: text, headers }))).rev
|
||||
},
|
||||
|
||||
trash: async () => (await json<{ items: TrashItem[] }>(request('GET', '/api/v1/trash'))).items,
|
||||
restore: (id: string) => json<TrashItem>(request('POST', `/api/v1/trash/${id}/restore`)),
|
||||
purge: (id: string) => request('DELETE', `/api/v1/trash/${id}`),
|
||||
|
||||
@@ -354,6 +354,10 @@ button {
|
||||
}
|
||||
.rm-dialog-head { display: flex; align-items: center; padding: 14px 16px 8px 20px; }
|
||||
.rm-dialog-head h2 { flex: 1; margin: 0; font-size: 16px; }
|
||||
.rm-key-dialog { max-width: 400px; }
|
||||
.rm-locked-hint { max-width: 360px; padding: 0 24px; text-align: center; line-height: 1.6; }
|
||||
.rm-key-form { max-width: none; box-sizing: border-box; padding: 4px 20px 20px; }
|
||||
.rm-lock-icon { flex: none; color: var(--rm-text-muted); }
|
||||
.rm-trash-list { list-style: none; margin: 0; padding: 0 12px 12px; overflow: auto; }
|
||||
.rm-trash-list li { display: flex; align-items: center; gap: 10px; padding: 8px; border-radius: 8px; color: var(--rm-text-muted); }
|
||||
.rm-trash-list li:hover { background: var(--rm-hover); }
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
createKeyring,
|
||||
decryptAsset,
|
||||
decryptNote,
|
||||
encryptAsset,
|
||||
encryptNote,
|
||||
newNoteKey,
|
||||
parseKeyring,
|
||||
rewrapKeyring,
|
||||
unlockKeyring,
|
||||
WrongPasswordError,
|
||||
} from './crypto'
|
||||
|
||||
// 测试中缩短 PBKDF2 迭代次数
|
||||
const ITER = 1000
|
||||
|
||||
describe('密钥环', () => {
|
||||
it('正确密码解锁后能解密之前加密的笔记,错误密码报 WrongPasswordError', async () => {
|
||||
const { keyring, master } = await createKeyring('正确的密码', ITER)
|
||||
const stored = parseKeyring(JSON.stringify(keyring))
|
||||
const file = await encryptNote(await newNoteKey(master), '# 秘密\n内容')
|
||||
|
||||
const unlocked = await unlockKeyring(stored, '正确的密码')
|
||||
expect((await decryptNote(unlocked, file)).text).toBe('# 秘密\n内容')
|
||||
await expect(unlockKeyring(stored, '错误的密码')).rejects.toBeInstanceOf(WrongPasswordError)
|
||||
})
|
||||
|
||||
it('主密钥不可导出', async () => {
|
||||
const { keyring, master } = await createKeyring('password', ITER)
|
||||
expect(master.extractable).toBe(false)
|
||||
expect((await unlockKeyring(keyring, 'password')).extractable).toBe(false)
|
||||
})
|
||||
|
||||
it('修改密码后旧笔记仍可解密,旧密码失效', async () => {
|
||||
const { keyring, master } = await createKeyring('old-password', ITER)
|
||||
const file = await encryptNote(await newNoteKey(master), '内容')
|
||||
await expect(rewrapKeyring(keyring, '不对', 'new-password', ITER)).rejects.toBeInstanceOf(WrongPasswordError)
|
||||
|
||||
const next = await rewrapKeyring(keyring, 'old-password', 'new-password', ITER)
|
||||
expect(next.kdf.salt).not.toBe(keyring.kdf.salt)
|
||||
expect((await decryptNote(await unlockKeyring(next, 'new-password'), file)).text).toBe('内容')
|
||||
await expect(unlockKeyring(next, 'old-password')).rejects.toBeInstanceOf(WrongPasswordError)
|
||||
})
|
||||
|
||||
it('拒绝无法识别的格式', () => {
|
||||
expect(() => parseKeyring('{"v":2}')).toThrow('密钥环格式无法识别')
|
||||
expect(() => parseKeyring('null')).toThrow('密钥环格式无法识别')
|
||||
})
|
||||
})
|
||||
|
||||
describe('笔记', () => {
|
||||
it('同一笔记密钥多次加密:每次 iv 不同,密钥信封不变', async () => {
|
||||
const { master } = await createKeyring('password', ITER)
|
||||
const nk = await newNoteKey(master)
|
||||
const a = JSON.parse(await encryptNote(nk, 'x'))
|
||||
const b = JSON.parse(await encryptNote(nk, 'x'))
|
||||
expect(a.iv).not.toBe(b.iv)
|
||||
expect(a.key).toEqual(b.key)
|
||||
|
||||
// 解密返回的密钥可继续用于保存
|
||||
const { key } = await decryptNote(master, JSON.stringify(a))
|
||||
expect((await decryptNote(master, await encryptNote(key, '新内容'))).text).toBe('新内容')
|
||||
})
|
||||
|
||||
it('其他密钥环或被篡改的文件解密失败', async () => {
|
||||
const { master } = await createKeyring('password', ITER)
|
||||
const other = (await createKeyring('password', ITER)).master
|
||||
const file = await encryptNote(await newNoteKey(master), '内容')
|
||||
await expect(decryptNote(other, file)).rejects.toThrow('解密失败')
|
||||
|
||||
const env = JSON.parse(file)
|
||||
env.data = env.data.slice(0, -4) + (env.data.endsWith('AAAA') ? 'BBBB' : 'AAAA')
|
||||
await expect(decryptNote(master, JSON.stringify(env))).rejects.toThrow('解密失败')
|
||||
await expect(decryptNote(master, '明文')).rejects.toThrow('加密笔记格式无效')
|
||||
await expect(decryptNote(master, '{}')).rejects.toThrow('加密笔记格式无效')
|
||||
})
|
||||
|
||||
it('空笔记与大笔记', async () => {
|
||||
const { master } = await createKeyring('password', ITER)
|
||||
const nk = await newNoteKey(master)
|
||||
const big = '中文内容 🙂\n'.repeat(100_000)
|
||||
for (const text of ['', big]) expect((await decryptNote(master, await encryptNote(nk, text))).text).toBe(text)
|
||||
})
|
||||
})
|
||||
|
||||
describe('图片', () => {
|
||||
it('往返保留内容与类型', async () => {
|
||||
const { master } = await createKeyring('password', ITER)
|
||||
const bytes = Uint8Array.from({ length: 200_000 }, (_, i) => (i * 7919) % 256)
|
||||
const file = await encryptAsset(master, bytes.buffer, 'image/png')
|
||||
expect([...file.subarray(0, 4)]).toEqual([0x52, 0x4d, 0x45, 0x31])
|
||||
|
||||
const blob = await decryptAsset(master, file.buffer)
|
||||
expect(blob.type).toBe('image/png')
|
||||
expect(new Uint8Array(await blob.arrayBuffer())).toEqual(bytes)
|
||||
})
|
||||
|
||||
it('错误密钥、篡改与非加密文件', async () => {
|
||||
const { master } = await createKeyring('password', ITER)
|
||||
const other = (await createKeyring('password', ITER)).master
|
||||
const file = await encryptAsset(master, new Uint8Array([1, 2, 3]).buffer, 'image/gif')
|
||||
await expect(decryptAsset(other, file.buffer)).rejects.toThrow('解密失败')
|
||||
file[file.length - 1] ^= 1
|
||||
await expect(decryptAsset(master, file.buffer)).rejects.toThrow('解密失败')
|
||||
await expect(decryptAsset(master, new TextEncoder().encode('\x89PNG plain image data, not encrypted at all......').buffer)).rejects.toThrow('格式无效')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,200 @@
|
||||
// 笔记加密的密码学部分(设计见 docs/architecture.md §6):
|
||||
// 加密密码 →PBKDF2→ KEK,包裹随机主密钥,存于 .rawmark/keyring.json;
|
||||
// 每篇笔记、每张图片各有一个随机 AES-256-GCM 数据密钥,由主密钥包裹后与密文存在一起。
|
||||
// 全部使用 AES-GCM(含包裹),密码错误、文件被篡改都会在解密时失败而不是得到乱码
|
||||
|
||||
const PBKDF2_ITERATIONS = 600_000
|
||||
const IV_BYTES = 12
|
||||
// AES-256 原始密钥 32 字节 + GCM 认证标签 16 字节
|
||||
const WRAPPED_KEY_BYTES = 48
|
||||
// 加密图片的文件头 "RME1"
|
||||
const ASSET_MAGIC = [0x52, 0x4d, 0x45, 0x31]
|
||||
|
||||
interface Wrapped {
|
||||
iv: string
|
||||
data: string
|
||||
}
|
||||
|
||||
export interface Keyring {
|
||||
v: 1
|
||||
kdf: { name: 'PBKDF2'; hash: 'SHA-256'; iterations: number; salt: string }
|
||||
// 被 KEK 包裹的主密钥
|
||||
key: Wrapped
|
||||
}
|
||||
|
||||
// 加密笔记(.md.enc)的文件内容
|
||||
interface NoteEnvelope {
|
||||
v: 1
|
||||
// 被主密钥包裹的笔记密钥
|
||||
key: Wrapped
|
||||
iv: string
|
||||
data: string
|
||||
}
|
||||
|
||||
/** 已解开的笔记密钥;保留包裹后的形式,每次保存只需重新加密正文 */
|
||||
export interface NoteKey {
|
||||
key: CryptoKey
|
||||
wrapped: Wrapped
|
||||
}
|
||||
|
||||
export class WrongPasswordError extends Error {
|
||||
constructor() {
|
||||
super('加密密码错误')
|
||||
}
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
const decoder = new TextDecoder()
|
||||
|
||||
function toBase64(bytes: Uint8Array): string {
|
||||
let s = ''
|
||||
for (let i = 0; i < bytes.length; i += 0x8000) s += String.fromCharCode(...bytes.subarray(i, i + 0x8000))
|
||||
return btoa(s)
|
||||
}
|
||||
|
||||
function fromBase64(s: string): Uint8Array<ArrayBuffer> {
|
||||
return Uint8Array.from(atob(s), (c) => c.charCodeAt(0))
|
||||
}
|
||||
|
||||
const randomBytes = (n: number) => crypto.getRandomValues(new Uint8Array(n))
|
||||
const gcm = (iv: Uint8Array<ArrayBuffer>): AesGcmParams => ({ name: 'AES-GCM', iv })
|
||||
const AES: AesKeyGenParams = { name: 'AES-GCM', length: 256 }
|
||||
const MASTER_USAGES: KeyUsage[] = ['wrapKey', 'unwrapKey']
|
||||
const DATA_USAGES: KeyUsage[] = ['encrypt', 'decrypt']
|
||||
|
||||
// GCM 认证失败在 WebCrypto 中表现为 OperationError
|
||||
const isAuthFailure = (err: unknown) => err instanceof DOMException && err.name === 'OperationError'
|
||||
|
||||
async function wrap(key: CryptoKey, wrappingKey: CryptoKey): Promise<Wrapped> {
|
||||
const iv = randomBytes(IV_BYTES)
|
||||
const data = await crypto.subtle.wrapKey('raw', key, wrappingKey, gcm(iv))
|
||||
return { iv: toBase64(iv), data: toBase64(new Uint8Array(data)) }
|
||||
}
|
||||
|
||||
function unwrap(w: Wrapped, wrappingKey: CryptoKey, usages: KeyUsage[], extractable = false): Promise<CryptoKey> {
|
||||
return crypto.subtle.unwrapKey('raw', fromBase64(w.data), wrappingKey, gcm(fromBase64(w.iv)), AES, extractable, usages)
|
||||
}
|
||||
|
||||
async function deriveKek(password: string, kdf: Keyring['kdf']): Promise<CryptoKey> {
|
||||
const base = await crypto.subtle.importKey('raw', encoder.encode(password), 'PBKDF2', false, ['deriveKey'])
|
||||
const params: Pbkdf2Params = { name: 'PBKDF2', hash: 'SHA-256', salt: fromBase64(kdf.salt), iterations: kdf.iterations }
|
||||
return crypto.subtle.deriveKey(params, base, AES, false, MASTER_USAGES)
|
||||
}
|
||||
|
||||
function newKdf(iterations: number): Keyring['kdf'] {
|
||||
return { name: 'PBKDF2', hash: 'SHA-256', iterations, salt: toBase64(randomBytes(16)) }
|
||||
}
|
||||
|
||||
/** 解析服务器返回的密钥环;格式不符时抛错(不猜测、不兼容未知版本) */
|
||||
export function parseKeyring(text: string): Keyring {
|
||||
const k = JSON.parse(text) as Keyring
|
||||
if (k?.v !== 1 || k.kdf?.name !== 'PBKDF2' || k.kdf.hash !== 'SHA-256' || !k.key?.iv || !k.key.data) {
|
||||
throw new Error('密钥环格式无法识别')
|
||||
}
|
||||
return k
|
||||
}
|
||||
|
||||
/** 生成新的密钥环,返回它和已解开的主密钥(iterations 仅供测试缩短耗时) */
|
||||
export async function createKeyring(password: string, iterations = PBKDF2_ITERATIONS): Promise<{ keyring: Keyring; master: CryptoKey }> {
|
||||
const kdf = newKdf(iterations)
|
||||
const kek = await deriveKek(password, kdf)
|
||||
const extractable = await crypto.subtle.generateKey(AES, true, MASTER_USAGES)
|
||||
const keyring: Keyring = { v: 1, kdf, key: await wrap(extractable, kek) }
|
||||
// 内存中只保留不可导出的副本
|
||||
return { keyring, master: await unwrap(keyring.key, kek, MASTER_USAGES) }
|
||||
}
|
||||
|
||||
/** 用加密密码解开主密钥;密码错误时抛出 WrongPasswordError */
|
||||
export async function unlockKeyring(keyring: Keyring, password: string): Promise<CryptoKey> {
|
||||
const kek = await deriveKek(password, keyring.kdf)
|
||||
try {
|
||||
return await unwrap(keyring.key, kek, MASTER_USAGES)
|
||||
} catch (err) {
|
||||
throw isAuthFailure(err) ? new WrongPasswordError() : err
|
||||
}
|
||||
}
|
||||
|
||||
/** 修改加密密码:主密钥不变,只用新密码重新包裹,已加密的笔记无需改动 */
|
||||
export async function rewrapKeyring(keyring: Keyring, oldPassword: string, newPassword: string, iterations = PBKDF2_ITERATIONS): Promise<Keyring> {
|
||||
const oldKek = await deriveKek(oldPassword, keyring.kdf)
|
||||
let master: CryptoKey
|
||||
try {
|
||||
master = await unwrap(keyring.key, oldKek, MASTER_USAGES, true)
|
||||
} catch (err) {
|
||||
throw isAuthFailure(err) ? new WrongPasswordError() : err
|
||||
}
|
||||
const kdf = newKdf(iterations)
|
||||
return { v: 1, kdf, key: await wrap(master, await deriveKek(newPassword, kdf)) }
|
||||
}
|
||||
|
||||
export async function newNoteKey(master: CryptoKey): Promise<NoteKey> {
|
||||
const key = await crypto.subtle.generateKey(AES, true, DATA_USAGES)
|
||||
return { key, wrapped: await wrap(key, master) }
|
||||
}
|
||||
|
||||
export async function encryptNote(nk: NoteKey, text: string): Promise<string> {
|
||||
const iv = randomBytes(IV_BYTES)
|
||||
const data = await crypto.subtle.encrypt(gcm(iv), nk.key, encoder.encode(text))
|
||||
const env: NoteEnvelope = { v: 1, key: nk.wrapped, iv: toBase64(iv), data: toBase64(new Uint8Array(data)) }
|
||||
return JSON.stringify(env)
|
||||
}
|
||||
|
||||
/** 解密笔记,同时返回笔记密钥供之后保存时使用 */
|
||||
export async function decryptNote(master: CryptoKey, file: string): Promise<{ text: string; key: NoteKey }> {
|
||||
let env: NoteEnvelope
|
||||
try {
|
||||
env = JSON.parse(file) as NoteEnvelope
|
||||
} catch {
|
||||
throw new Error('加密笔记格式无效')
|
||||
}
|
||||
if (env?.v !== 1 || !env.key || !env.iv || typeof env.data !== 'string') throw new Error('加密笔记格式无效')
|
||||
try {
|
||||
const key = await unwrap(env.key, master, DATA_USAGES)
|
||||
const text = decoder.decode(await crypto.subtle.decrypt(gcm(fromBase64(env.iv)), key, fromBase64(env.data)))
|
||||
return { text, key: { key, wrapped: env.key } }
|
||||
} catch (err) {
|
||||
throw isAuthFailure(err) ? new Error('解密失败:文件已损坏或不是用当前密钥加密的') : err
|
||||
}
|
||||
}
|
||||
|
||||
// 加密图片为二进制(避免 base64 膨胀):文件头 | 密钥 iv | 包裹的密钥 | 内容 iv | 密文。
|
||||
// 服务器只保存为 .enc,原始类型(如 image/png)放在明文开头:1 字节长度 + 类型
|
||||
export async function encryptAsset(master: CryptoKey, bytes: ArrayBuffer, type: string): Promise<Uint8Array<ArrayBuffer>> {
|
||||
const mime = encoder.encode(type)
|
||||
if (mime.length > 255) throw new Error('图片类型过长')
|
||||
const key = await crypto.subtle.generateKey(AES, true, DATA_USAGES)
|
||||
const keyIv = randomBytes(IV_BYTES)
|
||||
const wrapped = new Uint8Array(await crypto.subtle.wrapKey('raw', key, master, gcm(keyIv)))
|
||||
const plain = new Uint8Array(1 + mime.length + bytes.byteLength)
|
||||
plain[0] = mime.length
|
||||
plain.set(mime, 1)
|
||||
plain.set(new Uint8Array(bytes), 1 + mime.length)
|
||||
const iv = randomBytes(IV_BYTES)
|
||||
const cipher = new Uint8Array(await crypto.subtle.encrypt(gcm(iv), key, plain))
|
||||
const out = new Uint8Array(ASSET_MAGIC.length + IV_BYTES + WRAPPED_KEY_BYTES + IV_BYTES + cipher.length)
|
||||
let at = 0
|
||||
for (const part of [ASSET_MAGIC, keyIv, wrapped, iv, cipher]) {
|
||||
out.set(part, at)
|
||||
at += part.length
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export async function decryptAsset(master: CryptoKey, file: ArrayBuffer): Promise<Blob> {
|
||||
const bytes = new Uint8Array(file)
|
||||
const header = ASSET_MAGIC.length + IV_BYTES + WRAPPED_KEY_BYTES + IV_BYTES
|
||||
if (bytes.length < header || ASSET_MAGIC.some((b, i) => bytes[i] !== b)) throw new Error('加密图片格式无效')
|
||||
let at = ASSET_MAGIC.length
|
||||
const take = (n: number) => bytes.slice(at, (at += n))
|
||||
const keyIv = take(IV_BYTES)
|
||||
const wrapped = take(WRAPPED_KEY_BYTES)
|
||||
const iv = take(IV_BYTES)
|
||||
try {
|
||||
const key = await crypto.subtle.unwrapKey('raw', wrapped, master, gcm(keyIv), AES, false, DATA_USAGES)
|
||||
const plain = new Uint8Array(await crypto.subtle.decrypt(gcm(iv), key, bytes.subarray(at)))
|
||||
const type = decoder.decode(plain.subarray(1, 1 + plain[0]))
|
||||
return new Blob([plain.subarray(1 + plain[0])], { type })
|
||||
} catch (err) {
|
||||
throw isAuthFailure(err) ? new Error('解密失败:图片已损坏或不是用当前密钥加密的') : err
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// 加密会话:解锁后主密钥只在内存中,关闭页面即失效(用户选择不做空闲超时),另可手动锁定。
|
||||
// 笔记的读写与转换在 workspace.ts,这里只管主密钥、密码对话框与加密图片
|
||||
import { reactive } from 'vue'
|
||||
import { api } from './api'
|
||||
import { createKeyring, decryptAsset, encryptAsset, parseKeyring, rewrapKeyring, unlockKeyring } from './crypto'
|
||||
|
||||
export type KeyDialogMode = 'unlock' | 'setup' | 'change'
|
||||
|
||||
export const keyState = reactive({
|
||||
// 浏览器只在安全上下文(HTTPS 或 localhost)提供 WebCrypto
|
||||
supported: window.isSecureContext && globalThis.crypto?.subtle !== undefined,
|
||||
unlocked: false,
|
||||
dialog: null as KeyDialogMode | null,
|
||||
})
|
||||
|
||||
let master: CryptoKey | null = null
|
||||
let dialogDone: ((ok: boolean) => void) | null = null
|
||||
// 已解密图片:/api/v1/raw 地址 → blob 地址。图片文件名是内容哈希,内容不会变
|
||||
const images = new Map<string, Promise<string>>()
|
||||
|
||||
export function masterKey(): CryptoKey {
|
||||
if (!master) throw new Error('加密笔记已锁定')
|
||||
return master
|
||||
}
|
||||
|
||||
/** 确保已解锁:未解锁时弹出解锁对话框(从未设置过密码则为设置对话框),返回是否已解锁 */
|
||||
export async function ensureUnlocked(): Promise<boolean> {
|
||||
if (master) return true
|
||||
if (!keyState.supported) return false
|
||||
return openKeyDialog((await api.keyring()) ? 'unlock' : 'setup')
|
||||
}
|
||||
|
||||
/** 打开密码对话框,在对话框完成(true)或取消(false)时返回 */
|
||||
export function openKeyDialog(mode: KeyDialogMode): Promise<boolean> {
|
||||
dialogDone?.(false)
|
||||
keyState.dialog = mode
|
||||
return new Promise((resolve) => (dialogDone = resolve))
|
||||
}
|
||||
|
||||
export function closeKeyDialog(ok: boolean) {
|
||||
keyState.dialog = null
|
||||
const done = dialogDone
|
||||
dialogDone = null
|
||||
done?.(ok)
|
||||
}
|
||||
|
||||
export async function unlock(password: string) {
|
||||
const k = await api.keyring()
|
||||
if (!k) throw new Error('尚未设置加密密码')
|
||||
master = await unlockKeyring(parseKeyring(k.text), password)
|
||||
keyState.unlocked = true
|
||||
}
|
||||
|
||||
/** 首次设置加密密码;其他设备抢先设置时服务器返回 409 */
|
||||
export async function setupKeyring(password: string) {
|
||||
const created = await createKeyring(password)
|
||||
await api.putKeyring(JSON.stringify(created.keyring), '')
|
||||
master = created.master
|
||||
keyState.unlocked = true
|
||||
}
|
||||
|
||||
export async function changePassword(oldPassword: string, newPassword: string) {
|
||||
const k = await api.keyring()
|
||||
if (!k) throw new Error('尚未设置加密密码')
|
||||
const next = await rewrapKeyring(parseKeyring(k.text), oldPassword, newPassword)
|
||||
await api.putKeyring(JSON.stringify(next), k.rev)
|
||||
}
|
||||
|
||||
/** 丢弃主密钥与已解密的图片;打开的加密笔记由调用方处理 */
|
||||
export function lockKey() {
|
||||
master = null
|
||||
keyState.unlocked = false
|
||||
for (const url of images.values()) void url.then(URL.revokeObjectURL, () => {})
|
||||
images.clear()
|
||||
}
|
||||
|
||||
export function loadEncryptedImage(url: string): Promise<string> {
|
||||
let loading = images.get(url)
|
||||
if (!loading) {
|
||||
loading = api
|
||||
.raw(url)
|
||||
.then(async (blob) => URL.createObjectURL(await decryptAsset(masterKey(), await blob.arrayBuffer())))
|
||||
// 失败的不缓存,下次显示时重试
|
||||
loading.catch(() => images.delete(url))
|
||||
images.set(url, loading)
|
||||
}
|
||||
return loading
|
||||
}
|
||||
|
||||
/** 加密图片供上传;服务器对加密笔记的图片一律存为 .enc,类型记录在密文中 */
|
||||
export async function encryptImage(image: Blob): Promise<File> {
|
||||
return new File([await encryptAsset(masterKey(), await image.arrayBuffer(), image.type)], 'image.enc')
|
||||
}
|
||||
|
||||
/** 解密图片供上传为普通图片;扩展名取自原始类型(如 image/svg+xml → .svg),服务器据此命名 */
|
||||
export async function decryptImage(file: Blob): Promise<File> {
|
||||
const image = await decryptAsset(masterKey(), await file.arrayBuffer())
|
||||
const ext = /^image\/([a-z0-9]{1,8})/.exec(image.type)?.[1] ?? 'bin'
|
||||
return new File([image], 'image.' + ext, { type: image.type })
|
||||
}
|
||||
+28
-5
@@ -1,6 +1,11 @@
|
||||
import { encodePath } from './api'
|
||||
|
||||
export const NOTE_EXT = '.md'
|
||||
export const ENCRYPTED_EXT = '.md.enc'
|
||||
|
||||
export function isEncrypted(path: string): boolean {
|
||||
return path.endsWith(ENCRYPTED_EXT)
|
||||
}
|
||||
|
||||
export function dirname(path: string): string {
|
||||
const i = path.lastIndexOf('/')
|
||||
@@ -16,12 +21,22 @@ export function joinPath(dir: string, name: string): string {
|
||||
}
|
||||
|
||||
export function noteTitle(path: string): string {
|
||||
return basename(path).replace(/\.md$/, '')
|
||||
return basename(path).replace(/\.md(\.enc)?$/, '')
|
||||
}
|
||||
|
||||
// 把笔记中的图片地址解析为可加载的 URL:绝对地址原样返回,相对地址基于笔记所在目录走 /api/v1/raw
|
||||
export function resolveAssetUrl(notePath: string, src: string): string {
|
||||
if (/^[a-z][a-z\d+.-]*:/i.test(src) || src.startsWith('/') || src.startsWith('#')) return src
|
||||
/** 笔记的图片目录(Typora 风格,加密与未加密的同名笔记共用) */
|
||||
export function assetsDirOf(notePath: string): string {
|
||||
return joinPath(dirname(notePath), noteTitle(notePath) + '.assets')
|
||||
}
|
||||
|
||||
// 与服务端 vault.EscapeLinkPath 一致:只转义会破坏 Markdown 链接语法的字符
|
||||
export function escapeLinkPath(s: string): string {
|
||||
return s.replace(/[ ()<>]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase())
|
||||
}
|
||||
|
||||
/** 笔记中相对地址的图片在仓库中的路径;绝对地址、站内绝对路径与锚点返回 null */
|
||||
export function assetPathOf(notePath: string, src: string): string | null {
|
||||
if (/^[a-z][a-z\d+.-]*:/i.test(src) || src.startsWith('/') || src.startsWith('#')) return null
|
||||
const parts = dirname(notePath) ? dirname(notePath).split('/') : []
|
||||
for (const seg of src.split(/[?#]/)[0].split('/')) {
|
||||
if (seg === '' || seg === '.') continue
|
||||
@@ -36,5 +51,13 @@ export function resolveAssetUrl(notePath: string, src: string): string {
|
||||
parts.push(decoded)
|
||||
}
|
||||
}
|
||||
return '/api/v1/raw/' + encodePath(parts.join('/'))
|
||||
return parts.join('/')
|
||||
}
|
||||
|
||||
// 把笔记中的图片地址解析为可加载的 URL:绝对地址原样返回,相对地址基于笔记所在目录走 /api/v1/raw
|
||||
export function resolveAssetUrl(notePath: string, src: string): string {
|
||||
const p = assetPathOf(notePath, src)
|
||||
return p === null ? src : RAW_PREFIX + encodePath(p)
|
||||
}
|
||||
|
||||
export const RAW_PREFIX = '/api/v1/raw/'
|
||||
+237
-19
@@ -5,12 +5,38 @@ import { EditorView } from '@codemirror/view'
|
||||
import { nextTick, reactive, watch } from 'vue'
|
||||
import { createEditorState, pasteImages } from '../editor'
|
||||
import { handleShortcut, registerCommand } from './commands'
|
||||
import { api, ApiError, type Entry } from './api'
|
||||
import { imageLinks, rewriteImageLinks } from '../markdown/links'
|
||||
import { api, ApiError, encodePath, type Entry } from './api'
|
||||
import { decryptNote, encryptNote, newNoteKey, type NoteKey } from './crypto'
|
||||
import { drafts } from './drafts'
|
||||
import {
|
||||
decryptImage,
|
||||
encryptImage,
|
||||
ensureUnlocked,
|
||||
keyState,
|
||||
loadEncryptedImage,
|
||||
lockKey,
|
||||
masterKey,
|
||||
openKeyDialog,
|
||||
} from './encryption'
|
||||
import { connectEvents, type VaultEvent } from './events'
|
||||
import { basename, dirname, joinPath, NOTE_EXT, resolveAssetUrl } from './paths'
|
||||
import {
|
||||
assetPathOf,
|
||||
assetsDirOf,
|
||||
basename,
|
||||
dirname,
|
||||
ENCRYPTED_EXT,
|
||||
escapeLinkPath,
|
||||
isEncrypted,
|
||||
joinPath,
|
||||
NOTE_EXT,
|
||||
noteTitle,
|
||||
RAW_PREFIX,
|
||||
resolveAssetUrl,
|
||||
} from './paths'
|
||||
|
||||
export type TabStatus = 'loading' | 'ready' | 'conflict' | 'deleted' | 'error'
|
||||
// locked:加密笔记未解锁(或浏览器不支持加密),内容未载入
|
||||
export type TabStatus = 'loading' | 'ready' | 'conflict' | 'deleted' | 'error' | 'locked'
|
||||
|
||||
export interface Tab {
|
||||
path: string
|
||||
@@ -59,6 +85,8 @@ const MAX_RETRIES = 12
|
||||
const editorStates = new Map<string, EditorState>()
|
||||
// 每个打开的笔记一个可变引用,编辑器扩展通过它取得笔记的当前路径(重命名后随之更新)
|
||||
const noteRefs = new Map<string, { path: string }>()
|
||||
// 打开的加密笔记的笔记密钥,保存时用它加密
|
||||
const noteKeys = new Map<string, NoteKey>()
|
||||
|
||||
interface SaveSlot {
|
||||
timer?: ReturnType<typeof setTimeout>
|
||||
@@ -101,7 +129,10 @@ export function dismissToast(id: number) {
|
||||
}
|
||||
|
||||
function errorText(err: unknown): string {
|
||||
return err instanceof ApiError ? err.message : '网络错误,请检查连接'
|
||||
if (err instanceof ApiError) return err.message
|
||||
// fetch 的网络错误是 TypeError;其余(如解密失败)给出原因
|
||||
if (err instanceof TypeError || !(err instanceof Error)) return '网络错误,请检查连接'
|
||||
return err.message
|
||||
}
|
||||
|
||||
// ---- 编辑器状态 ----
|
||||
@@ -130,13 +161,14 @@ function buildState(path: string, doc: string): EditorState {
|
||||
return createEditorState({
|
||||
doc,
|
||||
resolveImage: (src) => resolveAssetUrl(ref.path, src),
|
||||
loadImage: (src) => (isEncrypted(ref.path) && src.startsWith(RAW_PREFIX) ? loadEncryptedImage(src) : null),
|
||||
onChange: (u) => {
|
||||
editorStates.set(ref.path, u.state)
|
||||
if (!u.transactions.some((tr) => tr.annotation(externalChange))) scheduleSave(ref.path)
|
||||
},
|
||||
extensions: [
|
||||
pasteImages({
|
||||
upload: (file) => api.upload(ref.path, file),
|
||||
upload: async (file) => api.upload(ref.path, isEncrypted(ref.path) ? await encryptImage(file) : file),
|
||||
onError: (err) => toast('图片上传失败:' + errorText(err)),
|
||||
onPending: (d) => {
|
||||
slot(ref.path).uploads += d
|
||||
@@ -168,11 +200,45 @@ function replaceDoc(path: string, content: string) {
|
||||
else editorStates.set(path, state.update(spec).state)
|
||||
}
|
||||
|
||||
// 按地址映射改写打开的笔记中的图片链接。external 为 true 时不进入撤销历史、不触发保存
|
||||
function editImageLinks(path: string, map: (url: string) => string | undefined, external: boolean) {
|
||||
const active = path === ws.active && view !== null
|
||||
const state = active ? view!.state : editorStates.get(path)
|
||||
if (!state) return
|
||||
const changes = imageLinks(state.doc.toString()).flatMap((l) => {
|
||||
const insert = map(l.url)
|
||||
return insert === undefined ? [] : [{ from: l.from, to: l.to, insert }]
|
||||
})
|
||||
if (!changes.length) return
|
||||
const spec = { changes, annotations: external ? [externalChange.of(true), Transaction.addToHistory.of(false)] : [] }
|
||||
if (active) view!.dispatch(spec)
|
||||
else {
|
||||
editorStates.set(path, state.update(spec).state)
|
||||
if (!external) scheduleSave(path)
|
||||
}
|
||||
}
|
||||
|
||||
function docOf(path: string): string | undefined {
|
||||
if (path === ws.active && view && editorStates.has(path)) return view.state.doc.toString()
|
||||
return editorStates.get(path)?.doc.toString()
|
||||
}
|
||||
|
||||
// ---- 读写(加密笔记在浏览器端加解密) ----
|
||||
|
||||
/** 读取笔记;加密笔记返回解密后的正文及其笔记密钥(须已解锁) */
|
||||
async function readNote(path: string): Promise<{ content: string; rev: string; key?: NoteKey }> {
|
||||
const file = await api.readFile(path)
|
||||
if (!isEncrypted(path)) return file
|
||||
const { text, key } = await decryptNote(masterKey(), file.content)
|
||||
return { content: text, rev: file.rev, key }
|
||||
}
|
||||
|
||||
function encryptFor(path: string, content: string): Promise<string> {
|
||||
const key = noteKeys.get(path)
|
||||
if (!key) throw new Error('缺少笔记密钥:' + path)
|
||||
return encryptNote(key, content)
|
||||
}
|
||||
|
||||
// ---- 保存队列 ----
|
||||
|
||||
function slot(path: string): SaveSlot {
|
||||
@@ -207,12 +273,16 @@ export async function flush(path: string, keepalive = false): Promise<void> {
|
||||
if (content === undefined) return
|
||||
const seq = s.seq
|
||||
const baseRev = tab.rev
|
||||
const encrypted = isEncrypted(path)
|
||||
s.inflight = (async () => {
|
||||
// 草稿与请求并行:页面关闭时等待 IndexedDB 写完再发请求,请求往往来不及发出
|
||||
// 草稿只是兜底,写入失败不影响保存本身
|
||||
const draftSaved = drafts.put({ path, content, baseRev, updatedAt: Date.now() }).catch((err) => console.warn('写入本地草稿失败', err))
|
||||
// 草稿只是兜底,写入失败不影响保存本身。加密笔记不写草稿,避免明文落在本地
|
||||
const draftSaved = encrypted
|
||||
? undefined
|
||||
: drafts.put({ path, content, baseRev, updatedAt: Date.now() }).catch((err) => console.warn('写入本地草稿失败', err))
|
||||
try {
|
||||
const rev = await api.writeFile(path, content, baseRev, keepalive)
|
||||
// 未加密的笔记不能在发请求前 await:页面关闭时异步之后的请求可能发不出去
|
||||
const rev = await api.writeFile(path, encrypted ? await encryptFor(path, content) : content, baseRev, keepalive)
|
||||
await draftSaved
|
||||
tab.rev = rev
|
||||
tab.saveError = undefined
|
||||
@@ -220,7 +290,7 @@ export async function flush(path: string, keepalive = false): Promise<void> {
|
||||
s.retries = 0
|
||||
if (s.seq === seq) {
|
||||
tab.dirty = false
|
||||
await drafts.delete(path)
|
||||
if (!encrypted) await drafts.delete(path)
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 412) {
|
||||
@@ -257,7 +327,8 @@ export async function useServerVersion(path: string) {
|
||||
const tab = tabOf(path)
|
||||
if (!tab) return
|
||||
try {
|
||||
const { content, rev } = await api.readFile(path)
|
||||
const { content, rev, key } = await readNote(path)
|
||||
if (key) noteKeys.set(path, key)
|
||||
replaceDoc(path, content)
|
||||
const s = slot(path)
|
||||
s.savedSeq = s.seq
|
||||
@@ -274,7 +345,8 @@ export async function keepLocalVersion(path: string) {
|
||||
const content = docOf(path)
|
||||
if (!tab || content === undefined) return
|
||||
try {
|
||||
const rev = await api.writeFile(path, content, tab.conflictRev ?? '')
|
||||
const body = isEncrypted(path) ? await encryptFor(path, content) : content
|
||||
const rev = await api.writeFile(path, body, tab.conflictRev ?? '')
|
||||
const s = slot(path)
|
||||
s.savedSeq = s.seq
|
||||
Object.assign(tab, { rev, status: 'ready', dirty: false, conflictRev: undefined, saveError: undefined })
|
||||
@@ -336,11 +408,16 @@ export async function openNote(path: string) {
|
||||
ws.active = path
|
||||
return
|
||||
}
|
||||
const tab: Tab = { path, rev: '', status: 'loading', dirty: false }
|
||||
ws.tabs.push(tab)
|
||||
const locked = isEncrypted(path) && !keyState.unlocked
|
||||
ws.tabs.push({ path, rev: '', status: locked ? 'locked' : 'loading', dirty: false })
|
||||
ws.active = path
|
||||
if (!locked) await loadTab(path)
|
||||
}
|
||||
|
||||
async function loadTab(path: string) {
|
||||
try {
|
||||
const { content, rev } = await api.readFile(path)
|
||||
const { content, rev, key } = await readNote(path)
|
||||
if (key) noteKeys.set(path, key)
|
||||
editorStates.set(path, buildState(path, content))
|
||||
const t = tabOf(path)
|
||||
if (t) Object.assign(t, { rev, status: 'ready' })
|
||||
@@ -368,6 +445,7 @@ export async function closeTab(path: string) {
|
||||
function forget(path: string) {
|
||||
editorStates.delete(path)
|
||||
noteRefs.delete(path)
|
||||
noteKeys.delete(path)
|
||||
const s = slots.get(path)
|
||||
if (s) clearTimeout(s.timer)
|
||||
slots.delete(path)
|
||||
@@ -387,7 +465,7 @@ function remapOpenNotes(from: string, to: string) {
|
||||
const old = tab.path
|
||||
tab.path = next
|
||||
if (ws.active === old) ws.active = next
|
||||
for (const map of [editorStates, noteRefs, slots] as Map<string, unknown>[]) {
|
||||
for (const map of [editorStates, noteRefs, noteKeys, slots] as Map<string, unknown>[]) {
|
||||
if (map.has(old)) {
|
||||
map.set(next, map.get(old))
|
||||
map.delete(old)
|
||||
@@ -470,7 +548,8 @@ function uniqueName(dir: string, base: string, ext: string): string {
|
||||
const taken = new Set(ws.entries.map((e) => e.path))
|
||||
for (let i = 0; ; i++) {
|
||||
const name = base + (i ? ` ${i}` : '') + ext
|
||||
if (!taken.has(joinPath(dir, name))) return joinPath(dir, name)
|
||||
// 同名的加密笔记与未加密笔记不能共存(共用图片目录)
|
||||
if (!taken.has(joinPath(dir, name)) && !(ext === NOTE_EXT && taken.has(joinPath(dir, name + '.enc')))) return joinPath(dir, name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -545,7 +624,8 @@ export function renameTarget(path: string, name: string): string | null {
|
||||
name = name.trim().replace(/\//g, '')
|
||||
if (!name || name.startsWith('.')) return null
|
||||
const entry = ws.entries.find((e) => e.path === path)
|
||||
if (entry && !entry.dir && !name.endsWith(NOTE_EXT)) name += NOTE_EXT
|
||||
const ext = isEncrypted(path) ? ENCRYPTED_EXT : NOTE_EXT
|
||||
if (entry && !entry.dir && !name.endsWith(ext)) name += ext
|
||||
return joinPath(dirname(path), name)
|
||||
}
|
||||
|
||||
@@ -556,6 +636,9 @@ export async function move(from: string, to: string) {
|
||||
toast(`“${basename(to)}”已存在`)
|
||||
return
|
||||
}
|
||||
// 服务器读不到加密笔记的正文,无法随图片目录改名改写其中的链接,须解锁后由浏览器改写
|
||||
const relink = isEncrypted(from) && noteTitle(from) !== noteTitle(to)
|
||||
if (relink && !(await unlockNotes())) return
|
||||
await withOp(async () => {
|
||||
// 先保存待保存的修改,确保移动的是最新内容;移动期间暂停保存,避免写到旧路径
|
||||
await Promise.all(affected(from).map((p) => flush(p)))
|
||||
@@ -576,6 +659,7 @@ export async function move(from: string, to: string) {
|
||||
} finally {
|
||||
paused.forEach((s) => s.paused--)
|
||||
}
|
||||
affected(to).forEach(resumeSaving)
|
||||
// 笔记改名时服务器会改写正文中的图片链接,版本随之变化,需要重新同步
|
||||
await Promise.all(affected(to).map(async (p) => {
|
||||
const tab = tabOf(p)
|
||||
@@ -586,10 +670,39 @@ export async function move(from: string, to: string) {
|
||||
if (rev !== tab.rev) Object.assign(tab, { status: 'conflict', conflictRev: rev })
|
||||
} else await syncOpenTab(tab)
|
||||
}))
|
||||
if (relink) await relinkRenamedNote(from, to)
|
||||
})
|
||||
void refreshTree()
|
||||
}
|
||||
|
||||
// 暂停期间到期的保存计时器绑定的是旧路径,移动/转换后按新路径重新安排
|
||||
function resumeSaving(path: string) {
|
||||
const s = slots.get(path)
|
||||
if (!s || s.seq === s.savedSeq) return
|
||||
clearTimeout(s.timer)
|
||||
s.timer = setTimeout(() => void flush(path), SAVE_DELAY)
|
||||
}
|
||||
|
||||
async function relinkRenamedNote(from: string, to: string) {
|
||||
const oldDir = assetsDirOf(from)
|
||||
const newDir = escapeLinkPath(basename(assetsDirOf(to)))
|
||||
const map = (url: string) => {
|
||||
const p = assetPathOf(from, url)
|
||||
return p !== null && dirname(p) === oldDir ? newDir + '/' + escapeLinkPath(basename(p)) : undefined
|
||||
}
|
||||
if (tabOf(to)?.status === 'ready') {
|
||||
editImageLinks(to, map, false)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const { content, rev, key } = await readNote(to)
|
||||
const next = rewriteImageLinks(content, map)
|
||||
if (next !== content) await api.writeFile(to, await encryptNote(key!, next), rev)
|
||||
} catch (err) {
|
||||
toast('改写图片链接失败:' + errorText(err))
|
||||
}
|
||||
}
|
||||
|
||||
export async function remove(path: string) {
|
||||
const entry = ws.entries.find((e) => e.path === path)
|
||||
if (!entry) return
|
||||
@@ -601,7 +714,7 @@ export async function remove(path: string) {
|
||||
for (const p of paths) await closeTab(p)
|
||||
try {
|
||||
const item = await api.deleteFile(path)
|
||||
toast(`已删除“${basename(path).replace(/\.md$/, '')}”`, {
|
||||
toast(`已删除“${noteTitle(path)}”`, {
|
||||
label: '撤销',
|
||||
run: () => void restore(item.id),
|
||||
})
|
||||
@@ -628,8 +741,9 @@ async function syncOpenTab(tab: Tab) {
|
||||
const s = slots.get(tab.path)
|
||||
if (tab.status !== 'ready' || tab.dirty || s?.inflight) return
|
||||
try {
|
||||
const { content, rev } = await api.readFile(tab.path)
|
||||
const { content, rev, key } = await readNote(tab.path)
|
||||
if (rev === tab.rev || tab.dirty) return
|
||||
if (key) noteKeys.set(tab.path, key)
|
||||
replaceDoc(tab.path, content)
|
||||
tab.rev = rev
|
||||
} catch (err) {
|
||||
@@ -665,6 +779,100 @@ async function resync() {
|
||||
await Promise.all(ws.tabs.map(syncOpenTab))
|
||||
}
|
||||
|
||||
// ---- 加密 ----
|
||||
|
||||
/** 解锁并载入处于锁定状态的标签页,返回是否已解锁 */
|
||||
export async function unlockNotes(): Promise<boolean> {
|
||||
try {
|
||||
if (!(await ensureUnlocked())) return false
|
||||
} catch (err) {
|
||||
toast('读取加密密钥失败:' + errorText(err))
|
||||
return false
|
||||
}
|
||||
await Promise.all(ws.tabs.filter((t) => t.status === 'locked').map((t) => ((t.status = 'loading'), loadTab(t.path))))
|
||||
return true
|
||||
}
|
||||
|
||||
/** 锁定:丢弃主密钥和已打开的加密笔记内容。有保存失败的修改时不锁定,否则修改会丢失 */
|
||||
export async function lockNotes() {
|
||||
const tabs = ws.tabs.filter((t) => isEncrypted(t.path))
|
||||
await Promise.all(tabs.map((t) => flush(t.path)))
|
||||
if (tabs.some((t) => t.dirty)) {
|
||||
toast('有加密笔记的修改尚未保存成功,暂不能锁定')
|
||||
return
|
||||
}
|
||||
lockKey()
|
||||
for (const t of tabs) {
|
||||
editorStates.delete(t.path)
|
||||
noteKeys.delete(t.path)
|
||||
Object.assign(t, { status: 'locked', conflictRev: undefined, saveError: undefined })
|
||||
}
|
||||
toast('已锁定加密笔记')
|
||||
}
|
||||
|
||||
/**
|
||||
* 加密或取消加密笔记(a.md ↔ a.md.enc)。本篇图片目录中被引用的图片一并加密/解密为新文件并改写链接,
|
||||
* 转换成功后永久删除原图片(加密前的明文图片不应留在回收站)
|
||||
*/
|
||||
export async function toggleEncryption(path: string) {
|
||||
if (!(await unlockNotes())) return
|
||||
const tab = tabOf(path)
|
||||
if (!tab || tab.status !== 'ready') return
|
||||
const encrypting = !isEncrypted(path)
|
||||
const to = encrypting ? path.slice(0, -NOTE_EXT.length) + ENCRYPTED_EXT : path.slice(0, -ENCRYPTED_EXT.length) + NOTE_EXT
|
||||
const action = encrypting ? '加密' : '取消加密'
|
||||
await withOp(async () => {
|
||||
await flush(path)
|
||||
if (tab.dirty || tab.status !== 'ready') {
|
||||
toast(`笔记尚未保存成功,暂不能${action}`)
|
||||
return
|
||||
}
|
||||
// 转换期间暂停保存;期间的输入在转换后保存到新路径
|
||||
const s = slot(path)
|
||||
s.paused++
|
||||
const replaced = new Map<string, string>()
|
||||
const converted: string[] = []
|
||||
try {
|
||||
const text = docOf(path)!
|
||||
for (const { url } of imageLinks(text)) {
|
||||
const p = assetPathOf(path, url)
|
||||
if (replaced.has(url) || p === null || dirname(p) !== assetsDirOf(path) || p.endsWith('.enc') === encrypting) continue
|
||||
let file: Blob
|
||||
try {
|
||||
file = await api.raw(RAW_PREFIX + encodePath(p))
|
||||
} catch (err) {
|
||||
// 链接指向的图片已不存在,没有可转换的
|
||||
if (err instanceof ApiError && err.status === 404) continue
|
||||
throw err
|
||||
}
|
||||
replaced.set(url, await api.upload(to, encrypting ? await encryptImage(file) : await decryptImage(file)))
|
||||
converted.push(p)
|
||||
}
|
||||
const content = rewriteImageLinks(text, (u) => replaced.get(u))
|
||||
const key = encrypting ? await newNoteKey(masterKey()) : undefined
|
||||
tab.rev = await api.convert(path, to, key ? await encryptNote(key, content) : content, tab.rev)
|
||||
if (key) noteKeys.set(path, key)
|
||||
else noteKeys.delete(path)
|
||||
ws.entries = ws.entries.map((e) => (e.path === path ? { ...e, path: to } : e))
|
||||
remapOpenNotes(path, to)
|
||||
remapRecent(path, to)
|
||||
// 已写入的内容含新链接;转换期间若有输入,其中的旧链接一并改写,随后续保存写入
|
||||
editImageLinks(to, (u) => replaced.get(u), true)
|
||||
} catch (err) {
|
||||
toast(`${action}失败:` + errorText(err))
|
||||
return
|
||||
} finally {
|
||||
s.paused--
|
||||
}
|
||||
resumeSaving(to)
|
||||
toast(`已${action}“${noteTitle(to)}”`)
|
||||
for (const p of converted) {
|
||||
api.deleteAsset(p).catch((err) => toast('删除原图片失败:' + errorText(err)))
|
||||
}
|
||||
})
|
||||
void refreshTree()
|
||||
}
|
||||
|
||||
// ---- 启动 ----
|
||||
|
||||
const TABS_KEY = 'rawmark.tabs'
|
||||
@@ -734,6 +942,10 @@ export async function startWorkspace() {
|
||||
if (document.visibilityState === 'hidden') void flushAll(true)
|
||||
})
|
||||
window.addEventListener('pagehide', () => void flushAll(true))
|
||||
// 加密笔记要先异步加密才能发出请求,页面关闭时往往来不及,且没有本地草稿兜底:有未保存的修改时请浏览器确认
|
||||
window.addEventListener('beforeunload', (e) => {
|
||||
if (ws.tabs.some((t) => t.dirty && isEncrypted(t.path))) e.preventDefault()
|
||||
})
|
||||
window.addEventListener('keydown', handleShortcut)
|
||||
}
|
||||
|
||||
@@ -742,6 +954,7 @@ registerBuiltinCommands()
|
||||
|
||||
function registerBuiltinCommands() {
|
||||
const hasNote = () => ws.active !== null
|
||||
const activeEncrypted = () => ws.active !== null && isEncrypted(ws.active)
|
||||
const toggleSwitcher = (initial: string) => {
|
||||
ws.switcher = ws.switcher === null ? initial : null
|
||||
}
|
||||
@@ -758,6 +971,11 @@ function registerBuiltinCommands() {
|
||||
// 浏览器不允许网页覆盖 Ctrl+W
|
||||
{ id: 'tab.close', title: '关闭当前标签页', when: hasNote, run: () => void closeTab(ws.active!) },
|
||||
{ id: 'app.trash', title: '打开回收站', run: () => (ws.trashOpen = true) },
|
||||
{ id: 'note.encrypt', title: '加密当前笔记', when: () => keyState.supported && hasNote() && !activeEncrypted(), run: () => void toggleEncryption(ws.active!) },
|
||||
{ id: 'note.decrypt', title: '取消加密当前笔记', when: () => keyState.supported && activeEncrypted(), run: () => void toggleEncryption(ws.active!) },
|
||||
{ id: 'vault.unlock', title: '解锁加密笔记', when: () => keyState.supported && !keyState.unlocked, run: () => void unlockNotes() },
|
||||
{ id: 'vault.lock', title: '锁定加密笔记', when: () => keyState.unlocked, run: () => void lockNotes() },
|
||||
{ id: 'vault.password', title: '修改加密密码', when: () => keyState.supported, run: () => void openKeyDialog('change') },
|
||||
]) {
|
||||
registerCommand(cmd)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user