diff --git a/src/encryptionService.test.ts b/src/encryptionService.test.ts index 17c336f6..5de6d3f0 100644 --- a/src/encryptionService.test.ts +++ b/src/encryptionService.test.ts @@ -69,21 +69,21 @@ describe("EncryptionService", () => { jest.resetModules(); }); - describe("getEncryptedKey", () => { - it("should encrypt an API key", async () => { + describe("getEncryptedKey (deprecated no-op)", () => { + it("should return plaintext key unchanged", async () => { const apiKey = "testApiKey"; - const encryptedKey = await getEncryptedKey(apiKey); - // The key is base64 encoded, so we should expect that format - expect(encryptedKey).toMatch(/^enc_(desk|web)_[A-Za-z0-9+/=]+$/); - // Verify we can decrypt it back - const decryptedKey = await getDecryptedKey(encryptedKey); - expect(decryptedKey).toBe(apiKey); + const result = await getEncryptedKey(apiKey); + expect(result).toBe(apiKey); }); - it("should return the original key if already encrypted", async () => { - const apiKey = "enc_testApiKey"; - const encryptedKey = await getEncryptedKey(apiKey); - expect(encryptedKey).toBe(apiKey); + it("should strip the dec_ prefix when present", async () => { + const result = await getEncryptedKey("dec_testApiKey"); + expect(result).toBe("testApiKey"); + }); + + it("should return empty string for empty input", async () => { + const result = await getEncryptedKey(""); + expect(result).toBe(""); }); }); @@ -102,8 +102,8 @@ describe("EncryptionService", () => { }); }); - describe("encryptAllKeys", () => { - it("should encrypt all keys containing 'apikey'", async () => { + describe("encryptAllKeys (deprecated no-op)", () => { + it("should return settings unchanged regardless of enableEncryption", async () => { const settings = { enableEncryption: true, openAIApiKey: "testApiKey", @@ -111,28 +111,8 @@ describe("EncryptionService", () => { userSystemPrompt: "shouldBeIgnored", } as unknown as CopilotSettings; - const newSettings = await encryptAllKeys(settings); - expect(newSettings.openAIApiKey).toMatch(/^enc_(desk|web)_[A-Za-z0-9+/=]+$/); - expect(newSettings.cohereApiKey).toMatch(/^enc_(desk|web)_[A-Za-z0-9+/=]+$/); - expect(newSettings.userSystemPrompt).toBe("shouldBeIgnored"); - - // Verify we can decrypt the keys back - const decryptedOpenAI = await getDecryptedKey(newSettings.openAIApiKey); - const decryptedCohere = await getDecryptedKey(newSettings.cohereApiKey); - expect(decryptedOpenAI).toBe("testApiKey"); - expect(decryptedCohere).toBe("anotherTestApiKey"); - }); - - it("should not encrypt keys when encryption is not enabled", async () => { - const newSettings = await encryptAllKeys({ - enableEncryption: false, - openAIApiKey: "testApiKey", - cohereApiKey: "anotherTestApiKey", - userSystemPrompt: "shouldBeIgnored", - } as unknown as CopilotSettings); - expect(newSettings.openAIApiKey).toBe("testApiKey"); - expect(newSettings.cohereApiKey).toBe("anotherTestApiKey"); - expect(newSettings.userSystemPrompt).toBe("shouldBeIgnored"); + const result = await encryptAllKeys(settings); + expect(result).toBe(settings); }); }); }); @@ -153,41 +133,23 @@ describe("Cross-platform compatibility", () => { console.error = originalConsoleError; }); - it("should encrypt and decrypt consistently on mobile", async () => { - // Mock as mobile by making safeStorage unavailable - mockElectron.remote.safeStorage.isEncryptionAvailable.mockReturnValue(false); + it("should decrypt legacy WebCrypto-encrypted keys on mobile", async () => { + // Simulate a key encrypted with the legacy WebCrypto path (the only path + // mobile ever used, and the path desktop fell back to when safeStorage + // wasn't available). The mocked subtle.decrypt strips the `_encrypted` + // suffix the encrypt mock appends. + const enc = (await mockSubtle.encrypt( + null, + null, + new TextEncoder().encode("testApiKey").buffer + )) as ArrayBuffer; + const encBytes = new Uint8Array(enc); + let binary = ""; + for (let i = 0; i < encBytes.length; i++) binary += String.fromCharCode(encBytes[i]); + const webEncryptedKey = "enc_web_" + window.btoa(binary); - const originalKey = "testApiKey"; - const encryptedKey = await getEncryptedKey(originalKey); - expect(encryptedKey).toMatch(/^enc_(desk|web)_[A-Za-z0-9+/=]+$/); - - // Reset the mock counts before decryption - mockSubtle.encrypt.mockClear(); - mockSubtle.decrypt.mockClear(); - - const decryptedKey = await getDecryptedKey(encryptedKey); - expect(decryptedKey).toBe(originalKey); - - // On mobile, we should use Web Crypto API for decryption + const decryptedKey = await getDecryptedKey(webEncryptedKey); + expect(decryptedKey).toBe("testApiKey"); expect(mockSubtle.decrypt).toHaveBeenCalled(); }); - - it("should be able to decrypt mobile-encrypted keys on desktop", async () => { - // First encrypt on mobile - mockElectron.remote.safeStorage.isEncryptionAvailable.mockReturnValue(false); - - const originalKey = "testApiKey"; - const mobileEncryptedKey = await getEncryptedKey(originalKey); - expect(mobileEncryptedKey).toMatch(/^enc_(desk|web)_[A-Za-z0-9+/=]+$/); - expect(mockSubtle.encrypt).toHaveBeenCalled(); - - // Reset the mock counts before desktop decryption - mockSubtle.encrypt.mockClear(); - mockSubtle.decrypt.mockClear(); - - // Then decrypt on desktop - mockElectron.remote.safeStorage.isEncryptionAvailable.mockReturnValue(true); - const decryptedKey = await getDecryptedKey(mobileEncryptedKey); - expect(decryptedKey).toBe(originalKey); - }); }); diff --git a/src/encryptionService.ts b/src/encryptionService.ts index 5399b69a..8462596e 100644 --- a/src/encryptionService.ts +++ b/src/encryptionService.ts @@ -1,3 +1,10 @@ +// Reason: `buffer` is the npm polyfill (browser-compatible), bundled by +// esbuild so the same Buffer code path works on desktop (Electron) and +// mobile (WebView). Without this import, bare `Buffer` is undefined on +// mobile and throws "Can't find variable: Buffer" — see PR #2443. +// eslint-disable-next-line import/no-nodejs-modules +import { Buffer } from "buffer"; + import { type CopilotSettings } from "@/settings/model"; import { Platform } from "obsidian"; @@ -19,7 +26,11 @@ function getSafeStorage(): SafeStorage | null { return safeStorageInternal; } -// Add new prefixes to distinguish encryption methods +// Encryption-at-rest is deprecated as of this commit. `getEncryptedKey` +// and `encryptAllKeys` are now no-ops, so new keys are saved plaintext. +// We still decrypt legacy `enc_*` blobs at read time via `getDecryptedKey` +// for backwards compatibility; existing keys migrate organically the next +// time the user re-saves them. const DESKTOP_PREFIX = "enc_desk_"; const WEBCRYPTO_PREFIX = "enc_web_"; // Keep old prefix for backward compatibility @@ -37,72 +48,25 @@ async function getEncryptionKey(): Promise { ]); } +/** + * No-op: encryption-at-rest is deprecated. Returns settings unchanged. + * Existing encrypted keys (`enc_*`) stay in data.json and are decrypted at + * read time via getDecryptedKey for backwards compatibility. New keys are + * saved plaintext. + */ export async function encryptAllKeys( settings: Readonly ): Promise> { - if (!settings.enableEncryption) { - return settings; - } - const newSettings = { ...settings }; - const keysToEncrypt = Object.keys(settings).filter( - (key) => - key.toLowerCase().includes("apikey") || - key === "plusLicenseKey" || - key === "githubCopilotAccessToken" || - key === "githubCopilotToken" - ); - - for (const key of keysToEncrypt) { - const apiKey = settings[key as keyof CopilotSettings] as string; - (newSettings[key as keyof CopilotSettings] as any) = await getEncryptedKey(apiKey); - } - - if (Array.isArray(settings.activeModels)) { - newSettings.activeModels = await Promise.all( - settings.activeModels.map(async (model) => ({ - ...model, - apiKey: await getEncryptedKey(model.apiKey || ""), - })) - ); - } - - if (Array.isArray(settings.activeEmbeddingModels)) { - newSettings.activeEmbeddingModels = await Promise.all( - settings.activeEmbeddingModels.map(async (model) => ({ - ...model, - apiKey: await getEncryptedKey(model.apiKey || ""), - })) - ); - } - - return newSettings; + return settings; } +/** + * No-op: encryption-at-rest is deprecated. Strips the `dec_` marker if + * present and returns the raw plaintext key. + */ export async function getEncryptedKey(apiKey: string): Promise { - if (!apiKey || apiKey.startsWith(ENCRYPTION_PREFIX)) { - return apiKey; - } - - if (isDecrypted(apiKey)) { - apiKey = apiKey.replace(DECRYPTION_PREFIX, ""); - } - - try { - // Try desktop encryption first - if (getSafeStorage()?.isEncryptionAvailable()) { - const encryptedBuffer = getSafeStorage()!.encryptString(apiKey); - return DESKTOP_PREFIX + encryptedBuffer.toString("base64"); - } - - // Fallback to Web Crypto API - const key = await getEncryptionKey(); - const encodedData = new TextEncoder().encode(apiKey); - const encryptedData = await crypto.subtle.encrypt(ALGORITHM, key, encodedData); - return WEBCRYPTO_PREFIX + arrayBufferToBase64(encryptedData); - } catch (error) { - console.error("Encryption failed:", error); - return apiKey; - } + if (!apiKey) return apiKey; + return isDecrypted(apiKey) ? apiKey.replace(DECRYPTION_PREFIX, "") : apiKey; } export async function getDecryptedKey(apiKey: string): Promise { @@ -113,8 +77,16 @@ export async function getDecryptedKey(apiKey: string): Promise { return apiKey.replace(DECRYPTION_PREFIX, ""); } - // Handle different encryption methods + // Handle different encryption methods (legacy data only — new keys are + // saved plaintext per the deprecation above). if (apiKey.startsWith(DESKTOP_PREFIX)) { + if (Platform.isMobile) { + throw new Error( + "An API key was encrypted on desktop with OS-level encryption that's " + + "unavailable on mobile. Please re-enter your API keys in Copilot " + + "settings on this device." + ); + } const base64Data = apiKey.replace(DESKTOP_PREFIX, ""); const buffer = Buffer.from(base64Data, "base64"); return getSafeStorage()!.decryptString(buffer); @@ -162,15 +134,6 @@ function isDecrypted(keyBuffer: string): boolean { return keyBuffer.startsWith(DECRYPTION_PREFIX); } -function arrayBufferToBase64(buffer: ArrayBuffer): string { - const bytes = new Uint8Array(buffer); - let binary = ""; - for (let i = 0; i < bytes.byteLength; i++) { - binary += String.fromCharCode(bytes[i]); - } - return window.btoa(binary); -} - function base64ToArrayBuffer(base64: string): ArrayBuffer { const binaryString = window.atob(base64); const bytes = new Uint8Array(binaryString.length); diff --git a/src/main.ts b/src/main.ts index 8417944e..4540ec5e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -21,7 +21,6 @@ import { ProjectRegister } from "@/projects/projectRegister"; import { ABORT_REASON, CHAT_VIEWTYPE, DEFAULT_OPEN_AREA, EVENT_NAMES } from "@/constants"; import { ChatManager } from "@/core/ChatManager"; import { MessageRepository } from "@/core/MessageRepository"; -import { encryptAllKeys } from "@/encryptionService"; import { logError, logInfo, logWarn } from "@/logger"; import { logFileManager } from "@/logFileManager"; import { UserMemoryManager } from "@/memory/UserMemoryManager"; @@ -106,11 +105,10 @@ export default class CopilotPlugin extends Plugin { await this.loadSettings(); this.settingsUnsubscriber = subscribeToSettingsChange((prev, next) => { void (async () => { - if (next.enableEncryption) { - await this.saveData(await encryptAllKeys(next)); - } else { - await this.saveData(next); - } + // Reason: encryption-at-rest is deprecated. encryptAllKeys is a no-op + // now; new keys are saved plaintext. Existing encrypted keys remain + // in data.json and are decrypted lazily by getDecryptedKey when read. + await this.saveData(next); registerCommands(this, prev, next); })(); }); diff --git a/src/settings/v2/components/AdvancedSettings.tsx b/src/settings/v2/components/AdvancedSettings.tsx index ca2f9415..070e150f 100644 --- a/src/settings/v2/components/AdvancedSettings.tsx +++ b/src/settings/v2/components/AdvancedSettings.tsx @@ -95,16 +95,6 @@ export const AdvancedSettings: React.FC = () => {

Others

- { - updateSetting("enableEncryption", checked); - }} - /> -