mirror of
https://github.com/kccleoc/seedpgp-web.git
synced 2026-03-07 09:57:50 +08:00
feat: Add integration tests and memory encryption strategy
This commit is contained in:
406
src/integration.test.ts
Normal file
406
src/integration.test.ts
Normal file
@@ -0,0 +1,406 @@
|
||||
/**
|
||||
* @file Integration tests for security features
|
||||
* Tests CSP enforcement, network blocking, and clipboard behavior
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeEach } from 'bun:test';
|
||||
|
||||
// ============================================================================
|
||||
// CSP ENFORCEMENT TESTS
|
||||
// ============================================================================
|
||||
|
||||
describe('CSP Enforcement', () => {
|
||||
test('should have restrictive CSP headers in index.html', async () => {
|
||||
// Parse index.html to verify CSP policy
|
||||
const fs = await import('fs');
|
||||
const path = await import('path');
|
||||
const htmlPath = path.join(import.meta.dir, '../index.html');
|
||||
const htmlContent = fs.readFileSync(htmlPath, 'utf-8');
|
||||
|
||||
// Extract CSP meta tag
|
||||
const cspMatch = htmlContent.match(
|
||||
/Content-Security-Policy"\s+content="([^"]+)"/
|
||||
);
|
||||
expect(cspMatch).toBeDefined();
|
||||
|
||||
const cspPolicy = cspMatch![1];
|
||||
|
||||
// Verify critical directives
|
||||
expect(cspPolicy).toContain("default-src 'none'");
|
||||
expect(cspPolicy).toContain("connect-src 'none'"); // COMPLETE network lockdown
|
||||
expect(cspPolicy).toContain("form-action 'none'");
|
||||
expect(cspPolicy).toContain("frame-ancestors 'none'");
|
||||
expect(cspPolicy).toContain("block-all-mixed-content");
|
||||
expect(cspPolicy).toContain("upgrade-insecure-requests");
|
||||
});
|
||||
|
||||
test('should have restrictive script-src directive', async () => {
|
||||
const fs = await import('fs');
|
||||
const path = await import('path');
|
||||
const htmlPath = path.join(import.meta.dir, '../index.html');
|
||||
const htmlContent = fs.readFileSync(htmlPath, 'utf-8');
|
||||
|
||||
const cspMatch = htmlContent.match(
|
||||
/Content-Security-Policy"\s+content="([^"]+)"/
|
||||
);
|
||||
const cspPolicy = cspMatch![1];
|
||||
|
||||
// script-src should only allow 'self' and 'wasm-unsafe-eval'
|
||||
const scriptSrcMatch = cspPolicy.match(/script-src\s+([^;]+)/);
|
||||
expect(scriptSrcMatch).toBeDefined();
|
||||
|
||||
const scriptSrc = scriptSrcMatch![1];
|
||||
expect(scriptSrc).toContain("'self'");
|
||||
expect(scriptSrc).toContain("'wasm-unsafe-eval'");
|
||||
|
||||
// Should NOT allow unsafe-inline or external CDNs
|
||||
expect(scriptSrc).not.toContain('https://');
|
||||
expect(scriptSrc).not.toContain('http://');
|
||||
});
|
||||
|
||||
test('should have secure image-src directive', async () => {
|
||||
const fs = await import('fs');
|
||||
const path = await import('path');
|
||||
const htmlPath = path.join(import.meta.dir, '../index.html');
|
||||
const htmlContent = fs.readFileSync(htmlPath, 'utf-8');
|
||||
|
||||
const cspMatch = htmlContent.match(
|
||||
/Content-Security-Policy"\s+content="([^"]+)"/
|
||||
);
|
||||
const cspPolicy = cspMatch![1];
|
||||
|
||||
const imgSrcMatch = cspPolicy.match(/img-src\s+([^;]+)/);
|
||||
expect(imgSrcMatch).toBeDefined();
|
||||
|
||||
const imgSrc = imgSrcMatch![1];
|
||||
// Should allow self and data: URIs (for generated QR codes)
|
||||
expect(imgSrc).toContain("'self'");
|
||||
expect(imgSrc).toContain('data:');
|
||||
|
||||
// Should NOT allow external image sources
|
||||
expect(imgSrc).not.toContain('https://');
|
||||
});
|
||||
|
||||
test('should have additional security headers in HTML meta tags', async () => {
|
||||
const fs = await import('fs');
|
||||
const path = await import('path');
|
||||
const htmlPath = path.join(import.meta.dir, '../index.html');
|
||||
const htmlContent = fs.readFileSync(htmlPath, 'utf-8');
|
||||
|
||||
expect(htmlContent).toContain('X-Frame-Options');
|
||||
expect(htmlContent).toContain('DENY');
|
||||
expect(htmlContent).toContain('X-Content-Type-Options');
|
||||
expect(htmlContent).toContain('nosniff');
|
||||
expect(htmlContent).toContain('referrer');
|
||||
expect(htmlContent).toContain('no-referrer');
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// NETWORK BLOCKING TESTS
|
||||
// ============================================================================
|
||||
|
||||
describe('Network Blocking', () => {
|
||||
let originalFetch: typeof fetch;
|
||||
let originalXHR: typeof XMLHttpRequest;
|
||||
let originalWS: typeof WebSocket;
|
||||
|
||||
beforeEach(() => {
|
||||
// Save originals
|
||||
originalFetch = globalThis.fetch;
|
||||
originalXHR = globalThis.XMLHttpRequest;
|
||||
originalWS = globalThis.WebSocket;
|
||||
});
|
||||
|
||||
test('should block fetch API after blockAllNetworks call', async () => {
|
||||
// Simulate blockAllNetworks behavior
|
||||
const mockBlockFetch = () => {
|
||||
(globalThis as any).fetch = (async () =>
|
||||
Promise.reject(new Error('Network blocked by user'))
|
||||
) as any;
|
||||
};
|
||||
|
||||
mockBlockFetch();
|
||||
|
||||
// Attempt to fetch should reject
|
||||
try {
|
||||
await globalThis.fetch('https://example.com');
|
||||
expect.unreachable('Fetch should have been blocked');
|
||||
} catch (error) {
|
||||
expect(error instanceof Error).toBe(true);
|
||||
expect((error as Error).message).toContain('Network blocked');
|
||||
}
|
||||
});
|
||||
|
||||
test('should block XMLHttpRequest after blockAllNetworks call', () => {
|
||||
// Simulate blockAllNetworks behavior - replace with error function
|
||||
const mockBlockXHR = () => {
|
||||
(globalThis as any).XMLHttpRequest = function () {
|
||||
throw new Error('Network blocked: XMLHttpRequest not allowed');
|
||||
};
|
||||
};
|
||||
|
||||
mockBlockXHR();
|
||||
|
||||
// Attempt to create XMLHttpRequest should throw
|
||||
expect(() => {
|
||||
new (globalThis as any).XMLHttpRequest();
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
test('should allow network restoration after unblockAllNetworks', async () => {
|
||||
const mockBlockAndUnblock = () => {
|
||||
// Block
|
||||
(globalThis as any).__original_fetch = originalFetch;
|
||||
(globalThis as any).fetch = (async () =>
|
||||
Promise.reject(new Error('Network blocked by user'))
|
||||
) as any;
|
||||
|
||||
// Unblock
|
||||
if ((globalThis as any).__original_fetch) {
|
||||
globalThis.fetch = (globalThis as any).__original_fetch;
|
||||
}
|
||||
};
|
||||
|
||||
mockBlockAndUnblock();
|
||||
|
||||
// After unblocking, fetch function should be restored
|
||||
// (Note: actual network call might fail if no real network, but function should exist)
|
||||
expect(typeof globalThis.fetch).toBe('function');
|
||||
});
|
||||
|
||||
test('should maintain network blocking state across multiple checks', async () => {
|
||||
const mockBlockFetch = () => {
|
||||
(globalThis as any).fetch = (async () =>
|
||||
Promise.reject(new Error('Network blocked by user'))
|
||||
) as any;
|
||||
};
|
||||
|
||||
mockBlockFetch();
|
||||
|
||||
// First attempt blocked
|
||||
try {
|
||||
await globalThis.fetch('https://first-attempt.com');
|
||||
expect.unreachable();
|
||||
} catch (e) {
|
||||
expect((e as Error).message).toContain('Network blocked');
|
||||
}
|
||||
|
||||
// Second attempt also blocked (state persists)
|
||||
try {
|
||||
await globalThis.fetch('https://second-attempt.com');
|
||||
expect.unreachable();
|
||||
} catch (e) {
|
||||
expect((e as Error).message).toContain('Network blocked');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// CLIPBOARD BEHAVIOR TESTS
|
||||
// ============================================================================
|
||||
|
||||
describe('Clipboard Security', () => {
|
||||
test('should detect sensitive field names', () => {
|
||||
const sensitivePatterns = ['mnemonic', 'seed', 'password', 'private', 'key'];
|
||||
const fieldNames = [
|
||||
'mnemonic12Words',
|
||||
'seedValue',
|
||||
'backupPassword',
|
||||
'privateKeyInput',
|
||||
'encryptionKey'
|
||||
];
|
||||
|
||||
fieldNames.forEach((fieldName) => {
|
||||
const isSensitive = sensitivePatterns.some(pattern =>
|
||||
fieldName.toLowerCase().includes(pattern)
|
||||
);
|
||||
expect(isSensitive).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle non-sensitive fields without warnings', () => {
|
||||
const sensitivePatterns = ['mnemonic', 'seed', 'password', 'private', 'key'];
|
||||
const fieldNames = [
|
||||
'publicKeyInput',
|
||||
'notes',
|
||||
'qrpayload'
|
||||
];
|
||||
|
||||
fieldNames.forEach((fieldName) => {
|
||||
const isSensitive = sensitivePatterns.some(pattern =>
|
||||
fieldName.toLowerCase().includes(pattern)
|
||||
);
|
||||
// Some of these might match 'key', so only test the ones that definitely shouldn't
|
||||
if (fieldName === 'publicKeyInput' || fieldName === 'notes') {
|
||||
expect(isSensitive).toBe(true === fieldName.includes('Key'));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('should convert Uint8Array to hex for clipboard', () => {
|
||||
const testData = new Uint8Array([0xFF, 0x00, 0xAB, 0xCD]);
|
||||
const hexString = Array.from(testData)
|
||||
.map(b => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
|
||||
expect(hexString).toBe('ff00abcd');
|
||||
});
|
||||
|
||||
test('should generate random garbage for clipboard clearing', () => {
|
||||
const length = 64;
|
||||
const garbage = crypto.getRandomValues(new Uint8Array(length))
|
||||
.reduce((s, b) => s + String.fromCharCode(32 + (b % 95)), '');
|
||||
|
||||
expect(typeof garbage).toBe('string');
|
||||
expect(garbage.length).toBe(length);
|
||||
|
||||
// Should be printable ASCII (no null bytes)
|
||||
garbage.split('').forEach(char => {
|
||||
const code = char.charCodeAt(0);
|
||||
expect(code >= 32 && code < 127).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test('should track clipboard events with metadata', () => {
|
||||
interface ClipboardEvent {
|
||||
timestamp: Date;
|
||||
field: string;
|
||||
length: number;
|
||||
}
|
||||
|
||||
const events: ClipboardEvent[] = [];
|
||||
|
||||
// Simulate adding a clipboard event
|
||||
events.push({
|
||||
timestamp: new Date(),
|
||||
field: 'mnemonic (will clear in 10s)',
|
||||
length: 128
|
||||
});
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].field).toContain('mnemonic');
|
||||
expect(events[0].length).toBe(128);
|
||||
expect(events[0].timestamp).toBeDefined();
|
||||
});
|
||||
|
||||
test('should maintain clipboard event history (max 10 entries)', () => {
|
||||
interface ClipboardEvent {
|
||||
timestamp: Date;
|
||||
field: string;
|
||||
length: number;
|
||||
}
|
||||
|
||||
let events: ClipboardEvent[] = [];
|
||||
|
||||
// Add 15 events
|
||||
for (let i = 0; i < 15; i++) {
|
||||
events = [
|
||||
{
|
||||
timestamp: new Date(),
|
||||
field: `field${i}`,
|
||||
length: i * 10
|
||||
},
|
||||
...events.slice(0, 9) // Keep max 10
|
||||
];
|
||||
}
|
||||
|
||||
expect(events).toHaveLength(10);
|
||||
expect(events[0].field).toBe('field14'); // Most recent first
|
||||
expect(events[9].field).toBe('field5'); // Oldest retained
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// SESSION KEY ROTATION TESTS
|
||||
// ============================================================================
|
||||
|
||||
describe('Session Key Management', () => {
|
||||
test('should track key operation count for rotation', () => {
|
||||
let keyOperationCount = 0;
|
||||
const MAX_KEY_OPERATIONS = 1000;
|
||||
|
||||
// Simulate operations
|
||||
for (let i = 0; i < 500; i++) {
|
||||
keyOperationCount++;
|
||||
}
|
||||
|
||||
expect(keyOperationCount).toBe(500);
|
||||
expect(keyOperationCount < MAX_KEY_OPERATIONS).toBe(true);
|
||||
|
||||
// Simulate more operations to trigger rotation
|
||||
for (let i = 0; i < 600; i++) {
|
||||
keyOperationCount++;
|
||||
}
|
||||
|
||||
expect(keyOperationCount >= MAX_KEY_OPERATIONS).toBe(true);
|
||||
});
|
||||
|
||||
test('should track key age for rotation', () => {
|
||||
const KEY_ROTATION_INTERVAL = 5 * 60 * 1000; // 5 minutes
|
||||
const keyCreatedAt = Date.now();
|
||||
|
||||
// Simulate checking age
|
||||
const elapsed = Date.now() - keyCreatedAt;
|
||||
expect(elapsed < KEY_ROTATION_INTERVAL).toBe(true);
|
||||
});
|
||||
|
||||
test('should handle key destruction on module level', () => {
|
||||
let sessionKey: CryptoKey | null = null;
|
||||
|
||||
sessionKey = {} as CryptoKey; // Simulate key
|
||||
expect(sessionKey).toBeDefined();
|
||||
|
||||
// Simulate destruction (nullify reference)
|
||||
sessionKey = null;
|
||||
expect(sessionKey).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// ENCRYPTION/DECRYPTION TESTS
|
||||
// ============================================================================
|
||||
|
||||
describe('Session Crypto Blob Format', () => {
|
||||
interface EncryptedBlob {
|
||||
v: 1;
|
||||
alg: 'A256GCM';
|
||||
iv_b64: string;
|
||||
ct_b64: string;
|
||||
}
|
||||
|
||||
test('should have valid EncryptedBlob structure', () => {
|
||||
const blob: EncryptedBlob = {
|
||||
v: 1,
|
||||
alg: 'A256GCM',
|
||||
iv_b64: 'dGVzdGl2',
|
||||
ct_b64: 'dGVzdGNp'
|
||||
};
|
||||
|
||||
expect(blob.v).toBe(1);
|
||||
expect(blob.alg).toBe('A256GCM');
|
||||
expect(typeof blob.iv_b64).toBe('string');
|
||||
expect(typeof blob.ct_b64).toBe('string');
|
||||
});
|
||||
|
||||
test('should base64 encode/decode IV and ciphertext', () => {
|
||||
const originalText = 'test data';
|
||||
const encoded = btoa(originalText);
|
||||
const decoded = atob(encoded);
|
||||
|
||||
expect(decoded).toBe(originalText);
|
||||
});
|
||||
|
||||
test('should generate valid base64 for cryptographic values', () => {
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12)); // 96-bit IV for GCM
|
||||
const ivBase64 = btoa(String.fromCharCode(...Array.from(iv)));
|
||||
|
||||
// Base64 should be valid
|
||||
expect(typeof ivBase64).toBe('string');
|
||||
expect(ivBase64.length > 0).toBe(true);
|
||||
|
||||
// Should be reversible
|
||||
const decoded = atob(ivBase64);
|
||||
expect(decoded.length).toBe(12);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user