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
+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;
}
}