From c281b4dac44d9da3a3f1a25c79ad3340327e3000 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 15 May 2026 22:34:37 +0000 Subject: [PATCH] chore(encryption): remove legacy CP01 localStorage decryption path The CP01 per-device WebCrypto format stored its AES key in browser localStorage and was kept only to decrypt secrets written before the OS Keychain migration. With that migration complete, drop the localStorage read entirely so the plugin no longer touches browser storage. Vaults with stale CP01 secrets in data.json will fail to decrypt those values; users must re-enter affected API keys. --- src/encryptionService.test.ts | 80 ----------------------------------- src/encryptionService.ts | 46 +------------------- 2 files changed, 1 insertion(+), 125 deletions(-) diff --git a/src/encryptionService.test.ts b/src/encryptionService.test.ts index 80c3820e..df0c007a 100644 --- a/src/encryptionService.test.ts +++ b/src/encryptionService.test.ts @@ -31,34 +31,6 @@ window.atob = jest .fn() .mockImplementation((str: string) => Buffer.from(str, "base64").toString("latin1")); -/** - * Ensure `window.localStorage` is usable for tests. - */ -function ensureTestLocalStorage(): { clear: () => void } { - try { - const storage = window.localStorage; - storage.setItem("__copilot_test__", "1"); - storage.removeItem("__copilot_test__"); - return { clear: () => window.localStorage.clear() }; - } catch { - const data = new Map(); - const shim = { - getItem: jest.fn((key: string) => data.get(key) ?? null), - setItem: jest.fn((key: string, value: string) => { - data.set(key, value); - }), - removeItem: jest.fn((key: string) => { - data.delete(key); - }), - clear: jest.fn(() => data.clear()), - }; - Object.defineProperty(window, "localStorage", { value: shim, configurable: true }); - return { clear: () => data.clear() }; - } -} - -const testLocalStorage = ensureTestLocalStorage(); - let randomCounter = 1; Object.defineProperty(window.crypto, "getRandomValues", { value: jest.fn((arr: Uint8Array) => { @@ -94,7 +66,6 @@ describe("EncryptionService", () => { beforeEach(() => { jest.clearAllMocks(); randomCounter = 1; - testLocalStorage.clear(); mockElectron.remote.safeStorage.isEncryptionAvailable.mockReturnValue(true); }); @@ -149,57 +120,6 @@ describe("EncryptionService", () => { expect(decrypted).toBe(plaintext); }); - it("should decrypt CP01 per-device payloads when localStorage key exists", async () => { - mockElectron.remote.safeStorage.isEncryptionAvailable.mockReturnValue(false); - - // Reason: CP01 uses a per-device AES key stored in localStorage. - // Seed it so getExistingWebCryptoKey() returns a usable key. - const keyBytes = new Uint8Array(32); - window.localStorage.setItem( - "obsidian-copilot:webcrypto-key:v1", - Buffer.from(keyBytes).toString("base64") - ); - - const plaintext = "cp01DeviceSecret"; - const iv = new Uint8Array(12); - iv[0] = 2; - - const ciphertext = new Uint8Array( - await crypto.subtle.encrypt( - { name: "AES-GCM", iv }, - "mockCryptoKey" as unknown as CryptoKey, - new TextEncoder().encode(plaintext) - ) - ); - - const cp01 = new Uint8Array([0x43, 0x50, 0x30, 0x31]); // "CP01" - const payload = new Uint8Array(cp01.length + iv.length + ciphertext.length); - payload.set(cp01, 0); - payload.set(iv, cp01.length); - payload.set(ciphertext, cp01.length + iv.length); - - const base64 = Buffer.from(payload).toString("base64"); - const decrypted = await getDecryptedKey(`enc_web_${base64}`); - expect(decrypted).toBe(plaintext); - }); - - it("should return empty string for CP01 when localStorage key is missing", async () => { - mockElectron.remote.safeStorage.isEncryptionAvailable.mockReturnValue(false); - // Reason: no key in localStorage — CP01 decryption should fail gracefully. - - const cp01 = new Uint8Array([0x43, 0x50, 0x30, 0x31]); // "CP01" - const iv = new Uint8Array(12); - const fakeCiphertext = new Uint8Array([1, 2, 3, 4]); - const payload = new Uint8Array(cp01.length + iv.length + fakeCiphertext.length); - payload.set(cp01, 0); - payload.set(iv, cp01.length); - payload.set(fakeCiphertext, cp01.length + iv.length); - - const base64 = Buffer.from(payload).toString("base64"); - const result = await getDecryptedKey(`enc_web_${base64}`); - expect(result).toBe(""); - }); - it("should decrypt legacy enc_ prefix payloads via WebCrypto fallback", async () => { mockElectron.remote.safeStorage.isEncryptionAvailable.mockReturnValue(false); diff --git a/src/encryptionService.ts b/src/encryptionService.ts index bf10ba7a..b934ae40 100644 --- a/src/encryptionService.ts +++ b/src/encryptionService.ts @@ -110,11 +110,8 @@ function looksLikeBase64(data: string): boolean { // --------------------------------------------------------------------------- const WEBCRYPTO_IV_LENGTH = 12; -const WEBCRYPTO_KEY_STORAGE_KEY = "obsidian-copilot:webcrypto-key:v1"; /** Magic header for portable payloads (hardcoded key + random IV). */ const WEBCRYPTO_PORTABLE_MAGIC = new Uint8Array([0x43, 0x50, 0x30, 0x30]); // "CP00" -/** Magic header for legacy per-device payloads (kept for backward-compat decryption). */ -const WEBCRYPTO_DEVICE_MAGIC = new Uint8Array([0x43, 0x50, 0x30, 0x31]); // "CP01" /** * Portable AES-GCM key material shared across all devices. @@ -129,32 +126,6 @@ function assertWebCryptoAvailable(): void { } } -function getLocalStorageSafe(): Storage | null { - try { - return window.localStorage ?? null; - } catch { - return null; - } -} - -/** - * Read an existing per-device AES-256 key from localStorage. - * Reason: kept for backward-compat decryption of CP01 payloads. - */ -async function getExistingWebCryptoKey(): Promise { - try { - const storage = getLocalStorageSafe(); - if (!storage) return null; - const existing = storage.getItem(WEBCRYPTO_KEY_STORAGE_KEY); - if (!existing) return null; - const raw = base64ToArrayBuffer(existing); - return await crypto.subtle.importKey("raw", raw, "AES-GCM", false, ["encrypt", "decrypt"]); - } catch (error) { - console.warn("Failed to load WebCrypto key from localStorage.", error); - return null; - } -} - /** * Import the portable WebCrypto key for CP00 decryption * and legacy fixed-IV decryption. @@ -174,7 +145,7 @@ function hasWebCryptoMagic(bytes: Uint8Array, magic: Uint8Array): boolean { /** * Decrypt a WEBCRYPTO_PREFIX value. - * Supports portable CP00, legacy per-device CP01, and legacy fixed-IV formats. + * Supports portable CP00 and legacy fixed-IV formats. */ async function decryptWebCryptoValue(base64Data: string): Promise { assertWebCryptoAvailable(); @@ -193,21 +164,6 @@ async function decryptWebCryptoValue(base64Data: string): Promise { return new TextDecoder().decode(decrypted); } - // Legacy per-device format: [CP01][IV][CIPHERTEXT] - if (hasWebCryptoMagic(raw, WEBCRYPTO_DEVICE_MAGIC)) { - const ivStart = WEBCRYPTO_DEVICE_MAGIC.length; - const ivEnd = ivStart + WEBCRYPTO_IV_LENGTH; - if (raw.length <= ivEnd) throw new Error("Invalid WebCrypto CP01 payload: too short."); - - const iv = raw.slice(ivStart, ivEnd); - const ciphertext = raw.slice(ivEnd); - const key = await getExistingWebCryptoKey(); - if (!key) throw new Error("Per-device WebCrypto key unavailable for CP01 decryption."); - - const decrypted = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ciphertext); - return new TextDecoder().decode(decrypted); - } - // Legacy format: ciphertext only, fixed IV + hardcoded key const key = await getPortableWebCryptoKey(); const decrypted = await crypto.subtle.decrypt(