Merge branch 'fix/crypto-compatibility'
This commit is contained in:
297
docs/plans/2025-10-26-crypto-compatibility.md
Normal file
297
docs/plans/2025-10-26-crypto-compatibility.md
Normal 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
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
36
src/utils/crypto-adapter.ts
Normal file
36
src/utils/crypto-adapter.ts
Normal 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);
|
||||
}
|
||||
166
tests/crypto-adapter.test.ts
Normal file
166
tests/crypto-adapter.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user