diff --git a/apps/web/src/services/local-ai-chat-repo.ts b/apps/web/src/services/local-ai-chat-repo.ts index e37c26f..1425a50 100644 --- a/apps/web/src/services/local-ai-chat-repo.ts +++ b/apps/web/src/services/local-ai-chat-repo.ts @@ -1,5 +1,10 @@ import { localDb, type LocalAiChatSessionRecord } from "@/services/local-db"; import type { WebAiChannel } from "@/services/ai-api"; +import { + decryptAiChatSessionRecord, + encryptAiChatSessionRecord, + shouldEncryptAiChatSessionRecord +} from "@/services/local-sensitive-codec"; export type LocalAiChatMessageRecord = { id: string; @@ -64,20 +69,33 @@ export async function listLocalAiChatSessions( userId: string ): Promise { const records = await localDb.aiChatSessions.where("userId").equals(userId).toArray(); - return records.map(toSnapshot); + const encryptedRecords = await Promise.all( + records + .filter(shouldEncryptAiChatSessionRecord) + .map((record) => encryptAiChatSessionRecord(record)) + ); + + if (encryptedRecords.length > 0) { + await localDb.aiChatSessions.bulkPut(encryptedRecords); + } + + const decryptedRecords = await Promise.all( + records.map((record) => decryptAiChatSessionRecord(record)) + ); + return decryptedRecords.map(toSnapshot); } export async function saveLocalAiChatSession( input: SaveLocalAiChatSessionInput ): Promise { - const record: LocalAiChatSessionRecord = { + const record = await encryptAiChatSessionRecord({ key: createSessionKey(input.userId, input.channel), userId: input.userId, channel: input.channel, sessionId: input.sessionId, messagesJson: JSON.stringify(input.messages), updatedAt: Date.now() - }; + }); await localDb.aiChatSessions.put(record); return record; diff --git a/apps/web/src/services/local-crypto.ts b/apps/web/src/services/local-crypto.ts new file mode 100644 index 0000000..6d8f5ff --- /dev/null +++ b/apps/web/src/services/local-crypto.ts @@ -0,0 +1,126 @@ +const LOCAL_CRYPTO_KEY_STORAGE_KEY = "todolist.web.local-crypto-key"; +const LOCAL_CRYPTO_PREFIX = "locv1"; +const LOCAL_CRYPTO_IV_LENGTH = 12; +const LOCAL_CRYPTO_KEY_LENGTH = 32; + +let cachedLocalCryptoKeyPromise: Promise | null = null; + +function toArrayBuffer(bytes: Uint8Array): ArrayBuffer { + return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; +} + +function bytesToBase64Url(bytes: Uint8Array): string { + let binary = ""; + const chunkSize = 0x8000; + + for (let index = 0; index < bytes.length; index += chunkSize) { + const chunk = bytes.subarray(index, index + chunkSize); + binary += String.fromCharCode(...chunk); + } + + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/u, ""); +} + +function base64UrlToBytes(value: string): Uint8Array { + const normalizedValue = value.replace(/-/g, "+").replace(/_/g, "/"); + const paddedValue = normalizedValue + "=".repeat((4 - (normalizedValue.length % 4 || 4)) % 4); + const binary = atob(paddedValue); + const bytes = new Uint8Array(binary.length); + + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + + return bytes; +} + +function createRandomKeyBytes(): Uint8Array { + const bytes = new Uint8Array(LOCAL_CRYPTO_KEY_LENGTH); + crypto.getRandomValues(bytes); + return bytes; +} + +async function resolveLocalCryptoKey(): Promise { + if (cachedLocalCryptoKeyPromise) { + return cachedLocalCryptoKeyPromise; + } + + cachedLocalCryptoKeyPromise = (async () => { + const savedKey = window.localStorage.getItem(LOCAL_CRYPTO_KEY_STORAGE_KEY); + const keyBytes = savedKey ? base64UrlToBytes(savedKey) : createRandomKeyBytes(); + + if (!savedKey) { + window.localStorage.setItem(LOCAL_CRYPTO_KEY_STORAGE_KEY, bytesToBase64Url(keyBytes)); + } + + return crypto.subtle.importKey("raw", toArrayBuffer(keyBytes), "AES-GCM", false, [ + "encrypt", + "decrypt" + ]); + })(); + + return cachedLocalCryptoKeyPromise; +} + +export function isLocalEncryptedString(value: string): boolean { + return value.startsWith(`${LOCAL_CRYPTO_PREFIX}:`); +} + +export async function encryptLocalString( + value: string | null | undefined +): Promise { + if (value === undefined || value === null) { + return value; + } + + if (isLocalEncryptedString(value)) { + return value; + } + + const key = await resolveLocalCryptoKey(); + const iv = crypto.getRandomValues(new Uint8Array(LOCAL_CRYPTO_IV_LENGTH)); + const plaintext = new TextEncoder().encode(value); + const encryptedBuffer = await crypto.subtle.encrypt( + { + name: "AES-GCM", + iv + }, + key, + plaintext + ); + + return `${LOCAL_CRYPTO_PREFIX}:${bytesToBase64Url(iv)}:${bytesToBase64Url(new Uint8Array(encryptedBuffer))}`; +} + +export async function decryptLocalString( + value: string | null | undefined +): Promise { + if (value === undefined || value === null) { + return value; + } + + if (!isLocalEncryptedString(value)) { + return value; + } + + const [prefix, ivText, encryptedText] = value.split(":"); + if (prefix !== LOCAL_CRYPTO_PREFIX || !ivText || !encryptedText) { + return null; + } + + try { + const key = await resolveLocalCryptoKey(); + const decryptedBuffer = await crypto.subtle.decrypt( + { + name: "AES-GCM", + iv: toArrayBuffer(base64UrlToBytes(ivText)) + }, + key, + toArrayBuffer(base64UrlToBytes(encryptedText)) + ); + + return new TextDecoder().decode(decryptedBuffer); + } catch { + return null; + } +} diff --git a/apps/web/src/services/local-sensitive-codec.ts b/apps/web/src/services/local-sensitive-codec.ts new file mode 100644 index 0000000..54c4c94 --- /dev/null +++ b/apps/web/src/services/local-sensitive-codec.ts @@ -0,0 +1,150 @@ +import type { + LocalAiChatSessionRecord, + LocalOpLogRecord, + LocalSyncInboxRecord, + LocalTaskDraftRecord, + LocalTaskRecord +} from "@/services/local-db"; +import { + decryptLocalString, + encryptLocalString, + isLocalEncryptedString +} from "@/services/local-crypto"; + +export function shouldEncryptTaskRecord(record: LocalTaskRecord): boolean { + return ( + !isLocalEncryptedString(record.title) || + (typeof record.contentJson === "string" && !isLocalEncryptedString(record.contentJson)) || + (typeof record.contentText === "string" && !isLocalEncryptedString(record.contentText)) + ); +} + +export async function encryptTaskRecord(record: LocalTaskRecord): Promise { + return { + ...record, + title: (await encryptLocalString(record.title)) ?? record.title, + contentJson: (await encryptLocalString(record.contentJson)) ?? null, + contentText: (await encryptLocalString(record.contentText)) ?? null + }; +} + +export async function decryptTaskRecord(record: LocalTaskRecord): Promise { + const title = await decryptLocalString(record.title); + const contentJson = await decryptLocalString(record.contentJson); + const contentText = await decryptLocalString(record.contentText); + + return { + ...record, + title: typeof title === "string" && title.trim().length > 0 ? title : "未命名任务", + contentJson: typeof contentJson === "string" ? contentJson : null, + contentText: typeof contentText === "string" ? contentText : null + }; +} + +export function shouldEncryptTaskDraft(record: LocalTaskDraftRecord): boolean { + return ( + !isLocalEncryptedString(record.title) || + (typeof record.contentJson === "string" && !isLocalEncryptedString(record.contentJson)) || + !isLocalEncryptedString(record.contentText) + ); +} + +export async function encryptTaskDraftRecord( + record: LocalTaskDraftRecord +): Promise { + return { + ...record, + title: (await encryptLocalString(record.title)) ?? record.title, + contentJson: (await encryptLocalString(record.contentJson)) ?? null, + contentText: (await encryptLocalString(record.contentText)) ?? "" + }; +} + +export async function decryptTaskDraftRecord( + record: LocalTaskDraftRecord +): Promise { + const title = await decryptLocalString(record.title); + const contentJson = await decryptLocalString(record.contentJson); + const contentText = await decryptLocalString(record.contentText); + + return { + ...record, + title: typeof title === "string" ? title : "", + contentJson: typeof contentJson === "string" ? contentJson : null, + contentText: typeof contentText === "string" ? contentText : "" + }; +} + +export function shouldEncryptOpLogRecord(record: LocalOpLogRecord): boolean { + return !isLocalEncryptedString(record.payload); +} + +export async function encryptOpLogRecord(record: LocalOpLogRecord): Promise { + return { + ...record, + payload: (await encryptLocalString(record.payload)) ?? record.payload + }; +} + +export async function decryptOpLogRecord(record: LocalOpLogRecord): Promise { + const payload = await decryptLocalString(record.payload); + + return { + ...record, + payload: typeof payload === "string" ? payload : record.payload + }; +} + +export function shouldEncryptSyncInboxRecord(record: LocalSyncInboxRecord): boolean { + return typeof record.payload === "string" && !isLocalEncryptedString(record.payload); +} + +export async function encryptSyncInboxRecord( + record: LocalSyncInboxRecord +): Promise { + return { + ...record, + payload: (await encryptLocalString(record.payload)) ?? null + }; +} + +export async function decryptSyncInboxRecord( + record: LocalSyncInboxRecord +): Promise { + const payload = await decryptLocalString(record.payload); + + return { + ...record, + payload: typeof payload === "string" ? payload : null + }; +} + +export function shouldEncryptAiChatSessionRecord(record: LocalAiChatSessionRecord): boolean { + return ( + !isLocalEncryptedString(record.messagesJson) || + (typeof record.sessionId === "string" && !isLocalEncryptedString(record.sessionId)) + ); +} + +export async function encryptAiChatSessionRecord( + record: LocalAiChatSessionRecord +): Promise { + return { + ...record, + sessionId: (await encryptLocalString(record.sessionId)) ?? null, + messagesJson: (await encryptLocalString(record.messagesJson)) ?? "[]" + }; +} + +export async function decryptAiChatSessionRecord( + record: LocalAiChatSessionRecord +): Promise { + const sessionId = await decryptLocalString(record.sessionId); + const messagesJson = await decryptLocalString(record.messagesJson); + + return { + ...record, + sessionId: typeof sessionId === "string" ? sessionId : null, + messagesJson: typeof messagesJson === "string" ? messagesJson : "[]" + }; +} diff --git a/apps/web/src/services/local-sync-repo.ts b/apps/web/src/services/local-sync-repo.ts index 87d13e9..267eca3 100644 --- a/apps/web/src/services/local-sync-repo.ts +++ b/apps/web/src/services/local-sync-repo.ts @@ -1,19 +1,36 @@ -import { +import { localDb, type LocalOpLogRecord, type LocalSyncInboxRecord, type LocalSyncStateRecord } from "@/services/local-db"; +import { + decryptOpLogRecord, + decryptSyncInboxRecord, + encryptOpLogRecord, + encryptSyncInboxRecord, + shouldEncryptOpLogRecord, + shouldEncryptSyncInboxRecord +} from "@/services/local-sensitive-codec"; import type { SyncPullItem } from "@/services/sync-api"; export const MAX_SYNC_RETRY_COUNT = 5; export async function listPendingSyncOperations(limit = 20): Promise { const records = await localDb.opLogs.orderBy("clientTs").toArray(); + const encryptedRecords = await Promise.all( + records.filter(shouldEncryptOpLogRecord).map((record) => encryptOpLogRecord(record)) + ); - return records + if (encryptedRecords.length > 0) { + await localDb.opLogs.bulkPut(encryptedRecords); + } + + const pendingRecords = records .filter((record) => record.syncedAt === null && record.retryCount < MAX_SYNC_RETRY_COUNT) .slice(0, limit); + + return Promise.all(pendingRecords.map((record) => decryptOpLogRecord(record))); } export async function countPendingSyncOperations(): Promise { @@ -100,19 +117,23 @@ export async function enqueueRemoteSyncOperations( } const receivedAt = Date.now(); - const records: LocalSyncInboxRecord[] = operations.map((operation) => ({ - opId: operation.opId, - userId, - entityId: operation.entityId, - entityType: operation.entityType, - action: operation.action, - payload: operation.payload, - clientTs: operation.clientTs, - deviceId: operation.deviceId, - serverTs: new Date(operation.serverTs).getTime(), - receivedAt, - appliedAt: null - })); + const records = await Promise.all( + operations.map(async (operation) => + encryptSyncInboxRecord({ + opId: operation.opId, + userId, + entityId: operation.entityId, + entityType: operation.entityType, + action: operation.action, + payload: operation.payload, + clientTs: operation.clientTs, + deviceId: operation.deviceId, + serverTs: new Date(operation.serverTs).getTime(), + receivedAt, + appliedAt: null + }) + ) + ); await localDb.syncInbox.bulkPut(records); return records.length; @@ -123,8 +144,15 @@ export async function listPendingRemoteOperations( limit = 100 ): Promise { const records = await localDb.syncInbox.where("userId").equals(userId).toArray(); + const encryptedRecords = await Promise.all( + records.filter(shouldEncryptSyncInboxRecord).map((record) => encryptSyncInboxRecord(record)) + ); - return records + if (encryptedRecords.length > 0) { + await localDb.syncInbox.bulkPut(encryptedRecords); + } + + const pendingRecords = records .filter((record) => record.appliedAt === null) .sort((left, right) => { if (left.serverTs !== right.serverTs) { @@ -138,6 +166,8 @@ export async function listPendingRemoteOperations( return left.opId.localeCompare(right.opId); }) .slice(0, limit); + + return Promise.all(pendingRecords.map((record) => decryptSyncInboxRecord(record))); } export async function markRemoteOperationsApplied( diff --git a/apps/web/src/services/local-task-draft-repo.ts b/apps/web/src/services/local-task-draft-repo.ts index 67220da..e34446e 100644 --- a/apps/web/src/services/local-task-draft-repo.ts +++ b/apps/web/src/services/local-task-draft-repo.ts @@ -1,4 +1,9 @@ import { localDb, type LocalTaskDraftRecord } from "@/services/local-db"; +import { + decryptTaskDraftRecord, + encryptTaskDraftRecord, + shouldEncryptTaskDraft +} from "@/services/local-sensitive-codec"; export type SaveLocalTaskDraftInput = { taskId: string; @@ -12,7 +17,16 @@ export type SaveLocalTaskDraftInput = { }; export async function getLocalTaskDraft(taskId: string): Promise { - return localDb.taskDrafts.get(taskId); + const draft = await localDb.taskDrafts.get(taskId); + if (!draft) { + return undefined; + } + + if (shouldEncryptTaskDraft(draft)) { + await localDb.taskDrafts.put(await encryptTaskDraftRecord(draft)); + } + + return decryptTaskDraftRecord(draft); } export async function saveLocalTaskDraft( @@ -23,7 +37,7 @@ export async function saveLocalTaskDraft( updatedAt: Date.now() }; - await localDb.taskDrafts.put(draft); + await localDb.taskDrafts.put(await encryptTaskDraftRecord(draft)); return draft; } diff --git a/apps/web/src/services/local-task-repo.ts b/apps/web/src/services/local-task-repo.ts index b611047..a9af409 100644 --- a/apps/web/src/services/local-task-repo.ts +++ b/apps/web/src/services/local-task-repo.ts @@ -6,6 +6,12 @@ type LocalTaskStatus, type SyncActionType } from "@/services/local-db"; +import { + decryptTaskRecord, + encryptOpLogRecord, + encryptTaskRecord, + shouldEncryptTaskRecord +} from "@/services/local-sensitive-codec"; const DEVICE_ID_STORAGE_KEY = "todolist.web.device-id"; @@ -83,7 +89,16 @@ function createSyncTaskPayload(payload: SyncTaskPayload): string { export async function listLocalTasksByUser(userId: string): Promise { const tasks = await localDb.tasks.where("userId").equals(userId).toArray(); - return tasks + const encryptedTasks = await Promise.all( + tasks.filter(shouldEncryptTaskRecord).map((task) => encryptTaskRecord(task)) + ); + + if (encryptedTasks.length > 0) { + await localDb.tasks.bulkPut(encryptedTasks); + } + + const decryptedTasks = await Promise.all(tasks.map((task) => decryptTaskRecord(task))); + return decryptedTasks .filter((task) => task.deletedAt === null) .sort((left, right) => right.updatedAt - left.updatedAt); } @@ -94,7 +109,11 @@ export async function getLocalTaskById(id: string): Promise { @@ -134,8 +153,8 @@ export async function createLocalTask(input: CreateLocalTaskInput): Promise { - await localDb.tasks.add(task); - await localDb.opLogs.add(opLog); + await localDb.tasks.add(await encryptTaskRecord(task)); + await localDb.opLogs.add(await encryptOpLogRecord(opLog)); }); return task; @@ -178,8 +197,8 @@ export async function updateLocalTask( ); await localDb.transaction("rw", localDb.tasks, localDb.opLogs, async () => { - await localDb.tasks.put(nextTask); - await localDb.opLogs.add(opLog); + await localDb.tasks.put(await encryptTaskRecord(nextTask)); + await localDb.opLogs.add(await encryptOpLogRecord(opLog)); }); return nextTask; @@ -211,8 +230,8 @@ export async function deleteLocalTask(id: string): Promise { ); await localDb.transaction("rw", localDb.tasks, localDb.opLogs, async () => { - await localDb.tasks.put(nextTask); - await localDb.opLogs.add(opLog); + await localDb.tasks.put(await encryptTaskRecord(nextTask)); + await localDb.opLogs.add(await encryptOpLogRecord(opLog)); }); return true; diff --git a/apps/web/src/services/storage-quota.ts b/apps/web/src/services/storage-quota.ts index 27445ed..50f76eb 100644 --- a/apps/web/src/services/storage-quota.ts +++ b/apps/web/src/services/storage-quota.ts @@ -1,4 +1,4 @@ -import { localDb } from "@/services/local-db"; +import { listLocalTasksByUser } from "@/services/local-task-repo"; export const DEFAULT_CLOUD_QUOTA_BYTES = 100 * 1024 * 1024; @@ -18,13 +18,9 @@ function measureTextBytes(value: string | null): number { } export async function getStorageQuotaSnapshot(userId: string): Promise { - const tasks = await localDb.tasks.where("userId").equals(userId).toArray(); + const tasks = await listLocalTasksByUser(userId); const usedBytes = tasks.reduce((total, task) => { - if (task.deletedAt !== null) { - return total; - } - return ( total + measureTextBytes(task.title) + diff --git a/apps/web/src/services/sync-merge.ts b/apps/web/src/services/sync-merge.ts index 666d919..22a299d 100644 --- a/apps/web/src/services/sync-merge.ts +++ b/apps/web/src/services/sync-merge.ts @@ -1,4 +1,4 @@ -import { +import { localDb, type LocalSyncInboxRecord, type LocalTaskPriority, @@ -6,6 +6,11 @@ import { type LocalTaskStatus } from "@/services/local-db"; import { listPendingRemoteOperations } from "@/services/local-sync-repo"; +import { + decryptTaskRecord, + encryptTaskRecord, + shouldEncryptTaskRecord +} from "@/services/local-sensitive-codec"; const TASK_PRIORITY_VALUES: LocalTaskPriority[] = ["LOW", "MEDIUM", "HIGH", "URGENT"]; const TASK_STATUS_VALUES: LocalTaskStatus[] = ["TODO", "IN_PROGRESS", "DONE", "ARCHIVED"]; @@ -246,11 +251,14 @@ export async function applyPendingRemoteOperations(userId: string): Promise