Merge branch 'fix/crypto-compatibility'

This commit is contained in:
2025-10-26 12:52:53 -04:00
4 changed files with 506 additions and 5 deletions

View File

@@ -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<T extends ArrayBufferView>(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 `<T extends ArrayBufferView>` preserves array type through the call
- **No mocking needed:** Tests use real crypto functions in Node.js via crypto.webcrypto

View File

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

View File

@@ -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<T extends ArrayBufferView>(array: T): T {
return getCrypto().getRandomValues(array);
}

View File

@@ -0,0 +1,166 @@
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);
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);
});
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();
}
});
});
});