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:
eezd committed 2026-10-17 14:51:08 +08:00
1 parent 35d3138c7d
commit 5cf5a45682
20 files changed
+899 -39

No files matched your search

+35
View File
@@ -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}`),