diff --git a/src/utils/encryption-utils.ts b/src/utils/encryption-utils.ts new file mode 100644 index 0000000..646123c --- /dev/null +++ b/src/utils/encryption-utils.ts @@ -0,0 +1,61 @@ +import { safeStorage } from 'electron'; + +/** + * Encrypts an API key using Electron's safeStorage API + * Falls back to plaintext if encryption is not available (e.g., Linux without keyring) + * @param apiKey The plaintext API key to encrypt + * @returns Encrypted API key with "encrypted:" prefix, or plaintext if encryption unavailable + */ +export function encryptApiKey(apiKey: string): string { + if (!apiKey) { + return ''; + } + + // Check if encryption is available + if (!safeStorage.isEncryptionAvailable()) { + console.warn('Encryption not available, storing API key in plaintext'); + return apiKey; + } + + try { + const encrypted = safeStorage.encryptString(apiKey); + return `encrypted:${encrypted.toString('base64')}`; + } catch (error) { + console.error('Failed to encrypt API key, falling back to plaintext:', error); + return apiKey; + } +} + +/** + * Decrypts an API key encrypted with encryptApiKey + * @param stored The stored API key (encrypted or plaintext) + * @returns Decrypted API key + */ +export function decryptApiKey(stored: string): string { + if (!stored) { + return ''; + } + + // Check if this is an encrypted key + if (!stored.startsWith('encrypted:')) { + // Legacy plaintext key or fallback + return stored; + } + + try { + const encryptedData = stored.substring(10); // Remove "encrypted:" prefix + const buffer = Buffer.from(encryptedData, 'base64'); + return safeStorage.decryptString(buffer); + } catch (error) { + console.error('Failed to decrypt API key:', error); + throw new Error('Failed to decrypt API key. You may need to regenerate it.'); + } +} + +/** + * Checks if encryption is available on the current platform + * @returns true if safeStorage encryption is available + */ +export function isEncryptionAvailable(): boolean { + return safeStorage.isEncryptionAvailable(); +} diff --git a/tests/__mocks__/electron.ts b/tests/__mocks__/electron.ts new file mode 100644 index 0000000..6a0f044 --- /dev/null +++ b/tests/__mocks__/electron.ts @@ -0,0 +1,13 @@ +/** + * Mock Electron API for testing + * This provides minimal mocks for the Electron types used in tests + */ + +export const safeStorage = { + isEncryptionAvailable: jest.fn(() => true), + encryptString: jest.fn((data: string) => Buffer.from(`encrypted:${data}`)), + decryptString: jest.fn((buffer: Buffer) => { + const str = buffer.toString(); + return str.replace('encrypted:', ''); + }) +}; diff --git a/tests/encryption-utils.test.ts b/tests/encryption-utils.test.ts new file mode 100644 index 0000000..fa6bf96 --- /dev/null +++ b/tests/encryption-utils.test.ts @@ -0,0 +1,73 @@ +import { encryptApiKey, decryptApiKey } from '../src/utils/encryption-utils'; + +// Mock electron module +jest.mock('electron', () => ({ + safeStorage: { + isEncryptionAvailable: jest.fn(() => true), + encryptString: jest.fn((data: string) => Buffer.from(`encrypted:${data}`)), + decryptString: jest.fn((buffer: Buffer) => { + const str = buffer.toString(); + return str.replace('encrypted:', ''); + }) + } +})); + +describe('Encryption Utils', () => { + describe('encryptApiKey', () => { + it('should encrypt API key when encryption is available', () => { + const apiKey = 'test-api-key-12345'; + const encrypted = encryptApiKey(apiKey); + + expect(encrypted).toMatch(/^encrypted:/); + expect(encrypted).not.toContain('test-api-key-12345'); + }); + + it('should return plaintext when encryption is not available', () => { + const { safeStorage } = require('electron'); + safeStorage.isEncryptionAvailable.mockReturnValueOnce(false); + + const apiKey = 'test-api-key-12345'; + const result = encryptApiKey(apiKey); + + expect(result).toBe(apiKey); + }); + + it('should handle empty string', () => { + const result = encryptApiKey(''); + expect(result).toBe(''); + }); + }); + + describe('decryptApiKey', () => { + it('should decrypt encrypted API key', () => { + const apiKey = 'test-api-key-12345'; + const encrypted = encryptApiKey(apiKey); + const decrypted = decryptApiKey(encrypted); + + expect(decrypted).toBe(apiKey); + }); + + it('should return plaintext if not encrypted format', () => { + const plaintext = 'plain-api-key'; + const result = decryptApiKey(plaintext); + + expect(result).toBe(plaintext); + }); + + it('should handle empty string', () => { + const result = decryptApiKey(''); + expect(result).toBe(''); + }); + }); + + describe('round-trip encryption', () => { + it('should successfully encrypt and decrypt', () => { + const original = 'my-secret-api-key-abc123'; + const encrypted = encryptApiKey(original); + const decrypted = decryptApiKey(encrypted); + + expect(decrypted).toBe(original); + expect(encrypted).not.toBe(original); + }); + }); +});