From 4ca851439100720a17cc4d8ae98b2771101626a4 Mon Sep 17 00:00:00 2001 From: Bill Date: Sun, 26 Oct 2025 12:35:02 -0400 Subject: [PATCH 1/4] docs: add crypto compatibility implementation plan --- docs/plans/2025-10-26-crypto-compatibility.md | 297 ++++++++++++++++++ 1 file changed, 297 insertions(+) create mode 100644 docs/plans/2025-10-26-crypto-compatibility.md diff --git a/docs/plans/2025-10-26-crypto-compatibility.md b/docs/plans/2025-10-26-crypto-compatibility.md new file mode 100644 index 0000000..45dd240 --- /dev/null +++ b/docs/plans/2025-10-26-crypto-compatibility.md @@ -0,0 +1,297 @@ +# Cross-Environment Crypto Compatibility Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Fix crypto API compatibility so tests pass in Node.js environment while maintaining production behavior in Electron. + +**Architecture:** Create crypto-adapter utility that detects environment and provides unified access to Web Crypto API (window.crypto in browser, crypto.webcrypto in Node.js). + +**Tech Stack:** TypeScript, Jest, Node.js crypto.webcrypto, Web Crypto API + +--- + +## Task 1: Create crypto-adapter utility with tests (TDD) + +**Files:** +- Create: `tests/crypto-adapter.test.ts` +- Create: `src/utils/crypto-adapter.ts` + +**Step 1: Write the failing test** + +Create `tests/crypto-adapter.test.ts`: + +```typescript +import { getCryptoRandomValues } from '../src/utils/crypto-adapter'; + +describe('crypto-adapter', () => { + describe('getCryptoRandomValues', () => { + it('should fill Uint8Array with random values', () => { + const array = new Uint8Array(32); + const result = getCryptoRandomValues(array); + + expect(result).toBe(array); + expect(result.length).toBe(32); + // Verify not all zeros (extremely unlikely with true random) + const hasNonZero = Array.from(result).some(val => val !== 0); + expect(hasNonZero).toBe(true); + }); + + it('should produce different values on subsequent calls', () => { + const array1 = new Uint8Array(32); + const array2 = new Uint8Array(32); + + getCryptoRandomValues(array1); + getCryptoRandomValues(array2); + + // Arrays should be different (extremely unlikely to be identical) + const identical = Array.from(array1).every((val, idx) => val === array2[idx]); + expect(identical).toBe(false); + }); + + it('should preserve array type', () => { + const uint8 = new Uint8Array(16); + const uint16 = new Uint16Array(8); + const uint32 = new Uint32Array(4); + + const result8 = getCryptoRandomValues(uint8); + const result16 = getCryptoRandomValues(uint16); + const result32 = getCryptoRandomValues(uint32); + + expect(result8).toBeInstanceOf(Uint8Array); + expect(result16).toBeInstanceOf(Uint16Array); + expect(result32).toBeInstanceOf(Uint32Array); + }); + + it('should work with different array lengths', () => { + const small = new Uint8Array(8); + const medium = new Uint8Array(32); + const large = new Uint8Array(128); + + getCryptoRandomValues(small); + getCryptoRandomValues(medium); + getCryptoRandomValues(large); + + expect(small.every(val => val >= 0 && val <= 255)).toBe(true); + expect(medium.every(val => val >= 0 && val <= 255)).toBe(true); + expect(large.every(val => val >= 0 && val <= 255)).toBe(true); + }); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `npm test -- crypto-adapter.test.ts` + +Expected: FAIL with "Cannot find module '../src/utils/crypto-adapter'" + +**Step 3: Write minimal implementation** + +Create `src/utils/crypto-adapter.ts`: + +```typescript +/** + * Cross-environment crypto adapter + * Provides unified access to cryptographically secure random number generation + * Works in both browser/Electron (window.crypto) and Node.js (crypto.webcrypto) + */ + +/** + * Gets the appropriate Crypto interface for the current environment + * @returns Crypto interface with getRandomValues method + * @throws Error if no crypto API is available + */ +function getCrypto(): Crypto { + // Browser/Electron environment + if (typeof window !== 'undefined' && window.crypto) { + return window.crypto; + } + + // Node.js environment (15+) - uses Web Crypto API standard + if (typeof global !== 'undefined') { + const nodeCrypto = require('crypto'); + if (nodeCrypto.webcrypto) { + return nodeCrypto.webcrypto; + } + } + + throw new Error('No Web Crypto API available in this environment'); +} + +/** + * Fills a typed array with cryptographically secure random values + * @param array TypedArray to fill with random values + * @returns The same array filled with random values + */ +export function getCryptoRandomValues(array: T): T { + return getCrypto().getRandomValues(array); +} +``` + +**Step 4: Run test to verify it passes** + +Run: `npm test -- crypto-adapter.test.ts` + +Expected: PASS (all 4 tests passing) + +**Step 5: Commit** + +```bash +git add tests/crypto-adapter.test.ts src/utils/crypto-adapter.ts +git commit -m "feat: add cross-environment crypto adapter + +- Create getCryptoRandomValues() utility +- Support both window.crypto (browser/Electron) and crypto.webcrypto (Node.js) +- Add comprehensive test coverage for adapter functionality" +``` + +--- + +## Task 2: Update auth-utils to use crypto-adapter + +**Files:** +- Modify: `src/utils/auth-utils.ts:1-23` +- Test: `tests/main-migration.test.ts` (existing tests should pass) + +**Step 1: Verify existing tests fail with current implementation** + +Run: `npm test -- main-migration.test.ts` + +Expected: FAIL with "ReferenceError: crypto is not defined" + +**Step 2: Update auth-utils.ts to use crypto-adapter** + +Modify `src/utils/auth-utils.ts`: + +```typescript +/** + * Utility functions for authentication and API key management + */ + +import { getCryptoRandomValues } from './crypto-adapter'; + +/** + * Generates a cryptographically secure random API key + * @param length Length of the API key (default: 32 characters) + * @returns A random API key string + */ +export function generateApiKey(length: number = 32): string { + const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; + const values = new Uint8Array(length); + + // Use cross-environment crypto adapter + getCryptoRandomValues(values); + + let result = ''; + for (let i = 0; i < length; i++) { + result += charset[values[i] % charset.length]; + } + + return result; +} + +/** + * Validates API key strength + * @param apiKey The API key to validate + * @returns Object with isValid flag and optional error message + */ +export function validateApiKey(apiKey: string): { isValid: boolean; error?: string } { + if (!apiKey || apiKey.trim() === '') { + return { isValid: false, error: 'API key cannot be empty' }; + } + + if (apiKey.length < 16) { + return { isValid: false, error: 'API key must be at least 16 characters long' }; + } + + return { isValid: true }; +} +``` + +**Step 3: Run existing migration tests to verify they pass** + +Run: `npm test -- main-migration.test.ts` + +Expected: PASS (all tests in main-migration.test.ts passing) + +**Step 4: Run all tests to ensure no regressions** + +Run: `npm test` + +Expected: PASS (all 709+ tests passing, no failures) + +**Step 5: Commit** + +```bash +git add src/utils/auth-utils.ts +git commit -m "fix: use crypto-adapter in generateApiKey + +- Replace direct crypto.getRandomValues with getCryptoRandomValues +- Fixes Node.js test environment compatibility +- Maintains production behavior in Electron" +``` + +--- + +## Task 3: Verify fix and run full test suite + +**Files:** +- None (verification only) + +**Step 1: Run full test suite** + +Run: `npm test` + +Expected: All tests pass (should be 713 tests: 709 existing + 4 new crypto-adapter tests) + +**Step 2: Verify test coverage meets thresholds** + +Run: `npm run test:coverage` + +Expected: +- Lines: ≥97% +- Statements: ≥97% +- Branches: ≥92% +- Functions: ≥96% + +Coverage should include new crypto-adapter.ts file. + +**Step 3: Run type checking** + +Run: `npm run build` + +Expected: No TypeScript errors, build completes successfully + +**Step 4: Document verification in commit message if needed** + +If all checks pass, the implementation is complete. No additional commit needed unless documentation updates are required. + +--- + +## Completion Checklist + +- [ ] crypto-adapter.ts created with full test coverage +- [ ] auth-utils.ts updated to use crypto-adapter +- [ ] All existing tests pass (main-migration.test.ts) +- [ ] New crypto-adapter tests pass (4 tests) +- [ ] Full test suite passes (713 tests) +- [ ] Coverage thresholds met +- [ ] TypeScript build succeeds +- [ ] Two commits created with descriptive messages + +## Expected Outcome + +After completing all tasks: +1. Tests run successfully in Node.js environment (no crypto errors) +2. Production code unchanged in behavior (still uses window.crypto in Electron) +3. Clean abstraction for future crypto operations +4. Full test coverage maintained +5. Ready for code review and PR creation + +## Notes for Engineer + +- **Environment detection:** The adapter checks `typeof window` first (browser/Electron), then `typeof global` (Node.js) +- **Web Crypto API standard:** Both environments use the same API (getRandomValues), just accessed differently +- **Node.js requirement:** Requires Node.js 15+ for crypto.webcrypto support +- **Type safety:** TypeScript generic `` preserves array type through the call +- **No mocking needed:** Tests use real crypto functions in Node.js via crypto.webcrypto From de1ab4eb2b7d3727b88f64c56cd49ba933134938 Mon Sep 17 00:00:00 2001 From: Bill Date: Sun, 26 Oct 2025 12:36:34 -0400 Subject: [PATCH 2/4] feat: add cross-environment crypto adapter - Create getCryptoRandomValues() utility - Support both window.crypto (browser/Electron) and crypto.webcrypto (Node.js) - Add comprehensive test coverage for adapter functionality --- src/utils/crypto-adapter.ts | 36 +++++++++++++++++++++++ tests/crypto-adapter.test.ts | 56 ++++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 src/utils/crypto-adapter.ts create mode 100644 tests/crypto-adapter.test.ts diff --git a/src/utils/crypto-adapter.ts b/src/utils/crypto-adapter.ts new file mode 100644 index 0000000..1661dba --- /dev/null +++ b/src/utils/crypto-adapter.ts @@ -0,0 +1,36 @@ +/** + * Cross-environment crypto adapter + * Provides unified access to cryptographically secure random number generation + * Works in both browser/Electron (window.crypto) and Node.js (crypto.webcrypto) + */ + +/** + * Gets the appropriate Crypto interface for the current environment + * @returns Crypto interface with getRandomValues method + * @throws Error if no crypto API is available + */ +function getCrypto(): Crypto { + // Browser/Electron environment + if (typeof window !== 'undefined' && window.crypto) { + return window.crypto; + } + + // Node.js environment (15+) - uses Web Crypto API standard + if (typeof global !== 'undefined') { + const nodeCrypto = require('crypto'); + if (nodeCrypto.webcrypto) { + return nodeCrypto.webcrypto; + } + } + + throw new Error('No Web Crypto API available in this environment'); +} + +/** + * Fills a typed array with cryptographically secure random values + * @param array TypedArray to fill with random values + * @returns The same array filled with random values + */ +export function getCryptoRandomValues(array: T): T { + return getCrypto().getRandomValues(array); +} diff --git a/tests/crypto-adapter.test.ts b/tests/crypto-adapter.test.ts new file mode 100644 index 0000000..de85b7b --- /dev/null +++ b/tests/crypto-adapter.test.ts @@ -0,0 +1,56 @@ +import { getCryptoRandomValues } from '../src/utils/crypto-adapter'; + +describe('crypto-adapter', () => { + describe('getCryptoRandomValues', () => { + it('should fill Uint8Array with random values', () => { + const array = new Uint8Array(32); + const result = getCryptoRandomValues(array); + + expect(result).toBe(array); + expect(result.length).toBe(32); + // Verify not all zeros (extremely unlikely with true random) + const hasNonZero = Array.from(result).some(val => val !== 0); + expect(hasNonZero).toBe(true); + }); + + it('should produce different values on subsequent calls', () => { + const array1 = new Uint8Array(32); + const array2 = new Uint8Array(32); + + getCryptoRandomValues(array1); + getCryptoRandomValues(array2); + + // Arrays should be different (extremely unlikely to be identical) + const identical = Array.from(array1).every((val, idx) => val === array2[idx]); + expect(identical).toBe(false); + }); + + it('should preserve array type', () => { + const uint8 = new Uint8Array(16); + const uint16 = new Uint16Array(8); + const uint32 = new Uint32Array(4); + + const result8 = getCryptoRandomValues(uint8); + const result16 = getCryptoRandomValues(uint16); + const result32 = getCryptoRandomValues(uint32); + + expect(result8).toBeInstanceOf(Uint8Array); + expect(result16).toBeInstanceOf(Uint16Array); + expect(result32).toBeInstanceOf(Uint32Array); + }); + + it('should work with different array lengths', () => { + const small = new Uint8Array(8); + const medium = new Uint8Array(32); + const large = new Uint8Array(128); + + getCryptoRandomValues(small); + getCryptoRandomValues(medium); + getCryptoRandomValues(large); + + expect(small.every(val => val >= 0 && val <= 255)).toBe(true); + expect(medium.every(val => val >= 0 && val <= 255)).toBe(true); + expect(large.every(val => val >= 0 && val <= 255)).toBe(true); + }); + }); +}); From 6788321d3a0db2d9767d152d48cbb0b18b2a0ff2 Mon Sep 17 00:00:00 2001 From: Bill Date: Sun, 26 Oct 2025 12:40:52 -0400 Subject: [PATCH 3/4] fix: use crypto-adapter in generateApiKey - Replace direct crypto.getRandomValues with getCryptoRandomValues - Fixes Node.js test environment compatibility - Maintains production behavior in Electron --- src/utils/auth-utils.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/utils/auth-utils.ts b/src/utils/auth-utils.ts index 80a8083..cd2006c 100644 --- a/src/utils/auth-utils.ts +++ b/src/utils/auth-utils.ts @@ -2,6 +2,8 @@ * Utility functions for authentication and API key management */ +import { getCryptoRandomValues } from './crypto-adapter'; + /** * Generates a cryptographically secure random API key * @param length Length of the API key (default: 32 characters) @@ -10,15 +12,15 @@ export function generateApiKey(length: number = 32): string { const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; const values = new Uint8Array(length); - - // Use crypto.getRandomValues for cryptographically secure random numbers - crypto.getRandomValues(values); - + + // Use cross-environment crypto adapter + getCryptoRandomValues(values); + let result = ''; for (let i = 0; i < length; i++) { result += charset[values[i] % charset.length]; } - + return result; } From 0246fe0257bf4111caf0a7d1a77127b3275bb89b Mon Sep 17 00:00:00 2001 From: Bill Date: Sun, 26 Oct 2025 12:46:44 -0400 Subject: [PATCH 4/4] test: add error case coverage for crypto-adapter --- tests/crypto-adapter.test.ts | 110 +++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/tests/crypto-adapter.test.ts b/tests/crypto-adapter.test.ts index de85b7b..83d9e3f 100644 --- a/tests/crypto-adapter.test.ts +++ b/tests/crypto-adapter.test.ts @@ -2,6 +2,48 @@ import { getCryptoRandomValues } from '../src/utils/crypto-adapter'; describe('crypto-adapter', () => { describe('getCryptoRandomValues', () => { + it('should use window.crypto in browser environment', () => { + // Save reference to global + const globalRef = global as any; + const originalWindow = globalRef.window; + + try { + // Mock browser environment with window.crypto + const mockGetRandomValues = jest.fn((array: any) => { + // Fill with mock random values + for (let i = 0; i < array.length; i++) { + array[i] = Math.floor(Math.random() * 256); + } + return array; + }); + + globalRef.window = { + crypto: { + getRandomValues: mockGetRandomValues + } + }; + + // Clear module cache to force re-evaluation + jest.resetModules(); + + // Re-import the function + const { getCryptoRandomValues: reloadedGetCryptoRandomValues } = require('../src/utils/crypto-adapter'); + + // Should use window.crypto + const array = new Uint8Array(32); + const result = reloadedGetCryptoRandomValues(array); + + expect(result).toBe(array); + expect(mockGetRandomValues).toHaveBeenCalledWith(array); + } finally { + // Restore original window + globalRef.window = originalWindow; + + // Clear module cache again to restore normal state + jest.resetModules(); + } + }); + it('should fill Uint8Array with random values', () => { const array = new Uint8Array(32); const result = getCryptoRandomValues(array); @@ -52,5 +94,73 @@ describe('crypto-adapter', () => { expect(medium.every(val => val >= 0 && val <= 255)).toBe(true); expect(large.every(val => val >= 0 && val <= 255)).toBe(true); }); + + it('should use Node.js crypto.webcrypto when window.crypto is not available', () => { + // Save references to global object and original values + const globalRef = global as any; + const originalWindow = globalRef.window; + const originalCrypto = originalWindow?.crypto; + + try { + // Mock window without crypto to force Node.js crypto path + globalRef.window = { ...originalWindow }; + delete globalRef.window.crypto; + + // Clear module cache to force re-evaluation + jest.resetModules(); + + // Re-import the function + const { getCryptoRandomValues: reloadedGetCryptoRandomValues } = require('../src/utils/crypto-adapter'); + + // Should work using Node.js crypto.webcrypto + const array = new Uint8Array(32); + const result = reloadedGetCryptoRandomValues(array); + + expect(result).toBe(array); + expect(result.length).toBe(32); + // Verify not all zeros + const hasNonZero = Array.from(result).some(val => val !== 0); + expect(hasNonZero).toBe(true); + } finally { + // Restore original values + globalRef.window = originalWindow; + if (originalWindow && originalCrypto) { + originalWindow.crypto = originalCrypto; + } + + // Clear module cache again to restore normal state + jest.resetModules(); + } + }); + + it('should throw error when no crypto API is available', () => { + // Save references to global object and original values + const globalRef = global as any; + const originalWindow = globalRef.window; + const originalGlobal = globalRef.global; + + try { + // Remove window.crypto and global access + delete globalRef.window; + delete globalRef.global; + + // Clear module cache to force re-evaluation + jest.resetModules(); + + // Re-import the function + const { getCryptoRandomValues: reloadedGetCryptoRandomValues } = require('../src/utils/crypto-adapter'); + + // Verify error is thrown + const array = new Uint8Array(32); + expect(() => reloadedGetCryptoRandomValues(array)).toThrow('No Web Crypto API available in this environment'); + } finally { + // Restore original values + globalRef.window = originalWindow; + globalRef.global = originalGlobal; + + // Clear module cache again to restore normal state + jest.resetModules(); + } + }); }); });