feat(web-security): encrypt offline data at rest

This commit is contained in:
2026-04-06 16:07:37 +08:00
parent 1ea483016f
commit 1564d2dd30
8 changed files with 399 additions and 38 deletions
+21 -3
View File
@@ -1,5 +1,10 @@
import { localDb, type LocalAiChatSessionRecord } from "@/services/local-db"; import { localDb, type LocalAiChatSessionRecord } from "@/services/local-db";
import type { WebAiChannel } from "@/services/ai-api"; import type { WebAiChannel } from "@/services/ai-api";
import {
decryptAiChatSessionRecord,
encryptAiChatSessionRecord,
shouldEncryptAiChatSessionRecord
} from "@/services/local-sensitive-codec";
export type LocalAiChatMessageRecord = { export type LocalAiChatMessageRecord = {
id: string; id: string;
@@ -64,20 +69,33 @@ export async function listLocalAiChatSessions(
userId: string userId: string
): Promise<LocalAiChatSessionSnapshot[]> { ): Promise<LocalAiChatSessionSnapshot[]> {
const records = await localDb.aiChatSessions.where("userId").equals(userId).toArray(); 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( export async function saveLocalAiChatSession(
input: SaveLocalAiChatSessionInput input: SaveLocalAiChatSessionInput
): Promise<LocalAiChatSessionRecord> { ): Promise<LocalAiChatSessionRecord> {
const record: LocalAiChatSessionRecord = { const record = await encryptAiChatSessionRecord({
key: createSessionKey(input.userId, input.channel), key: createSessionKey(input.userId, input.channel),
userId: input.userId, userId: input.userId,
channel: input.channel, channel: input.channel,
sessionId: input.sessionId, sessionId: input.sessionId,
messagesJson: JSON.stringify(input.messages), messagesJson: JSON.stringify(input.messages),
updatedAt: Date.now() updatedAt: Date.now()
}; });
await localDb.aiChatSessions.put(record); await localDb.aiChatSessions.put(record);
return record; return record;
+126
View File
@@ -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<CryptoKey> | 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<CryptoKey> {
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<string | null | undefined> {
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<string | null | undefined> {
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;
}
}
@@ -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<LocalTaskRecord> {
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<LocalTaskRecord> {
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<LocalTaskDraftRecord> {
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<LocalTaskDraftRecord> {
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<LocalOpLogRecord> {
return {
...record,
payload: (await encryptLocalString(record.payload)) ?? record.payload
};
}
export async function decryptOpLogRecord(record: LocalOpLogRecord): Promise<LocalOpLogRecord> {
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<LocalSyncInboxRecord> {
return {
...record,
payload: (await encryptLocalString(record.payload)) ?? null
};
}
export async function decryptSyncInboxRecord(
record: LocalSyncInboxRecord
): Promise<LocalSyncInboxRecord> {
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<LocalAiChatSessionRecord> {
return {
...record,
sessionId: (await encryptLocalString(record.sessionId)) ?? null,
messagesJson: (await encryptLocalString(record.messagesJson)) ?? "[]"
};
}
export async function decryptAiChatSessionRecord(
record: LocalAiChatSessionRecord
): Promise<LocalAiChatSessionRecord> {
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 : "[]"
};
}
+35 -5
View File
@@ -1,19 +1,36 @@
import { import {
localDb, localDb,
type LocalOpLogRecord, type LocalOpLogRecord,
type LocalSyncInboxRecord, type LocalSyncInboxRecord,
type LocalSyncStateRecord type LocalSyncStateRecord
} from "@/services/local-db"; } from "@/services/local-db";
import {
decryptOpLogRecord,
decryptSyncInboxRecord,
encryptOpLogRecord,
encryptSyncInboxRecord,
shouldEncryptOpLogRecord,
shouldEncryptSyncInboxRecord
} from "@/services/local-sensitive-codec";
import type { SyncPullItem } from "@/services/sync-api"; import type { SyncPullItem } from "@/services/sync-api";
export const MAX_SYNC_RETRY_COUNT = 5; export const MAX_SYNC_RETRY_COUNT = 5;
export async function listPendingSyncOperations(limit = 20): Promise<LocalOpLogRecord[]> { export async function listPendingSyncOperations(limit = 20): Promise<LocalOpLogRecord[]> {
const records = await localDb.opLogs.orderBy("clientTs").toArray(); 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) .filter((record) => record.syncedAt === null && record.retryCount < MAX_SYNC_RETRY_COUNT)
.slice(0, limit); .slice(0, limit);
return Promise.all(pendingRecords.map((record) => decryptOpLogRecord(record)));
} }
export async function countPendingSyncOperations(): Promise<number> { export async function countPendingSyncOperations(): Promise<number> {
@@ -100,7 +117,9 @@ export async function enqueueRemoteSyncOperations(
} }
const receivedAt = Date.now(); const receivedAt = Date.now();
const records: LocalSyncInboxRecord[] = operations.map((operation) => ({ const records = await Promise.all(
operations.map(async (operation) =>
encryptSyncInboxRecord({
opId: operation.opId, opId: operation.opId,
userId, userId,
entityId: operation.entityId, entityId: operation.entityId,
@@ -112,7 +131,9 @@ export async function enqueueRemoteSyncOperations(
serverTs: new Date(operation.serverTs).getTime(), serverTs: new Date(operation.serverTs).getTime(),
receivedAt, receivedAt,
appliedAt: null appliedAt: null
})); })
)
);
await localDb.syncInbox.bulkPut(records); await localDb.syncInbox.bulkPut(records);
return records.length; return records.length;
@@ -123,8 +144,15 @@ export async function listPendingRemoteOperations(
limit = 100 limit = 100
): Promise<LocalSyncInboxRecord[]> { ): Promise<LocalSyncInboxRecord[]> {
const records = await localDb.syncInbox.where("userId").equals(userId).toArray(); 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) .filter((record) => record.appliedAt === null)
.sort((left, right) => { .sort((left, right) => {
if (left.serverTs !== right.serverTs) { if (left.serverTs !== right.serverTs) {
@@ -138,6 +166,8 @@ export async function listPendingRemoteOperations(
return left.opId.localeCompare(right.opId); return left.opId.localeCompare(right.opId);
}) })
.slice(0, limit); .slice(0, limit);
return Promise.all(pendingRecords.map((record) => decryptSyncInboxRecord(record)));
} }
export async function markRemoteOperationsApplied( export async function markRemoteOperationsApplied(
+16 -2
View File
@@ -1,4 +1,9 @@
import { localDb, type LocalTaskDraftRecord } from "@/services/local-db"; import { localDb, type LocalTaskDraftRecord } from "@/services/local-db";
import {
decryptTaskDraftRecord,
encryptTaskDraftRecord,
shouldEncryptTaskDraft
} from "@/services/local-sensitive-codec";
export type SaveLocalTaskDraftInput = { export type SaveLocalTaskDraftInput = {
taskId: string; taskId: string;
@@ -12,7 +17,16 @@ export type SaveLocalTaskDraftInput = {
}; };
export async function getLocalTaskDraft(taskId: string): Promise<LocalTaskDraftRecord | undefined> { export async function getLocalTaskDraft(taskId: string): Promise<LocalTaskDraftRecord | undefined> {
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( export async function saveLocalTaskDraft(
@@ -23,7 +37,7 @@ export async function saveLocalTaskDraft(
updatedAt: Date.now() updatedAt: Date.now()
}; };
await localDb.taskDrafts.put(draft); await localDb.taskDrafts.put(await encryptTaskDraftRecord(draft));
return draft; return draft;
} }
+27 -8
View File
@@ -6,6 +6,12 @@
type LocalTaskStatus, type LocalTaskStatus,
type SyncActionType type SyncActionType
} from "@/services/local-db"; } from "@/services/local-db";
import {
decryptTaskRecord,
encryptOpLogRecord,
encryptTaskRecord,
shouldEncryptTaskRecord
} from "@/services/local-sensitive-codec";
const DEVICE_ID_STORAGE_KEY = "todolist.web.device-id"; 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<LocalTaskRecord[]> { export async function listLocalTasksByUser(userId: string): Promise<LocalTaskRecord[]> {
const tasks = await localDb.tasks.where("userId").equals(userId).toArray(); 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) .filter((task) => task.deletedAt === null)
.sort((left, right) => right.updatedAt - left.updatedAt); .sort((left, right) => right.updatedAt - left.updatedAt);
} }
@@ -94,7 +109,11 @@ export async function getLocalTaskById(id: string): Promise<LocalTaskRecord | un
return undefined; return undefined;
} }
return task; if (shouldEncryptTaskRecord(task)) {
await localDb.tasks.put(await encryptTaskRecord(task));
}
return decryptTaskRecord(task);
} }
export async function createLocalTask(input: CreateLocalTaskInput): Promise<LocalTaskRecord> { export async function createLocalTask(input: CreateLocalTaskInput): Promise<LocalTaskRecord> {
@@ -134,8 +153,8 @@ export async function createLocalTask(input: CreateLocalTaskInput): Promise<Loca
); );
await localDb.transaction("rw", localDb.tasks, localDb.opLogs, async () => { await localDb.transaction("rw", localDb.tasks, localDb.opLogs, async () => {
await localDb.tasks.add(task); await localDb.tasks.add(await encryptTaskRecord(task));
await localDb.opLogs.add(opLog); await localDb.opLogs.add(await encryptOpLogRecord(opLog));
}); });
return task; return task;
@@ -178,8 +197,8 @@ export async function updateLocalTask(
); );
await localDb.transaction("rw", localDb.tasks, localDb.opLogs, async () => { await localDb.transaction("rw", localDb.tasks, localDb.opLogs, async () => {
await localDb.tasks.put(nextTask); await localDb.tasks.put(await encryptTaskRecord(nextTask));
await localDb.opLogs.add(opLog); await localDb.opLogs.add(await encryptOpLogRecord(opLog));
}); });
return nextTask; return nextTask;
@@ -211,8 +230,8 @@ export async function deleteLocalTask(id: string): Promise<boolean> {
); );
await localDb.transaction("rw", localDb.tasks, localDb.opLogs, async () => { await localDb.transaction("rw", localDb.tasks, localDb.opLogs, async () => {
await localDb.tasks.put(nextTask); await localDb.tasks.put(await encryptTaskRecord(nextTask));
await localDb.opLogs.add(opLog); await localDb.opLogs.add(await encryptOpLogRecord(opLog));
}); });
return true; return true;
+2 -6
View File
@@ -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; 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<StorageQuotaSnapshot> { export async function getStorageQuotaSnapshot(userId: string): Promise<StorageQuotaSnapshot> {
const tasks = await localDb.tasks.where("userId").equals(userId).toArray(); const tasks = await listLocalTasksByUser(userId);
const usedBytes = tasks.reduce((total, task) => { const usedBytes = tasks.reduce((total, task) => {
if (task.deletedAt !== null) {
return total;
}
return ( return (
total + total +
measureTextBytes(task.title) + measureTextBytes(task.title) +
+11 -3
View File
@@ -1,4 +1,4 @@
import { import {
localDb, localDb,
type LocalSyncInboxRecord, type LocalSyncInboxRecord,
type LocalTaskPriority, type LocalTaskPriority,
@@ -6,6 +6,11 @@ import {
type LocalTaskStatus type LocalTaskStatus
} from "@/services/local-db"; } from "@/services/local-db";
import { listPendingRemoteOperations } from "@/services/local-sync-repo"; 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_PRIORITY_VALUES: LocalTaskPriority[] = ["LOW", "MEDIUM", "HIGH", "URGENT"];
const TASK_STATUS_VALUES: LocalTaskStatus[] = ["TODO", "IN_PROGRESS", "DONE", "ARCHIVED"]; const TASK_STATUS_VALUES: LocalTaskStatus[] = ["TODO", "IN_PROGRESS", "DONE", "ARCHIVED"];
@@ -246,11 +251,14 @@ export async function applyPendingRemoteOperations(userId: string): Promise<numb
continue; continue;
} }
const currentTask = await localDb.tasks.get(operation.entityId); const storedTask = await localDb.tasks.get(operation.entityId);
const currentTask = storedTask ? await decryptTaskRecord(storedTask) : undefined;
const incomingTask = buildIncomingTaskRecord(operation, currentTask); const incomingTask = buildIncomingTaskRecord(operation, currentTask);
if (shouldApplyIncomingTask(currentTask, incomingTask, operation)) { if (shouldApplyIncomingTask(currentTask, incomingTask, operation)) {
await localDb.tasks.put(incomingTask); await localDb.tasks.put(await encryptTaskRecord(incomingTask));
} else if (storedTask && currentTask && shouldEncryptTaskRecord(storedTask)) {
await localDb.tasks.put(await encryptTaskRecord(currentTask));
} }
await localDb.syncInbox.update(operation.opId, { appliedAt }); await localDb.syncInbox.update(operation.opId, { appliedAt });