mirror of
https://github.com/kccleoc/seedpgp-web.git
synced 2026-03-07 09:57:50 +08:00
325 lines
11 KiB
TypeScript
325 lines
11 KiB
TypeScript
/**
|
|
* @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('CSP headers are now managed by _headers file', () => {
|
|
// This test is a placeholder to acknowledge that CSP is no longer in index.html.
|
|
// True validation of headers requires an end-to-end test against a deployed environment,
|
|
// which is beyond the scope of this unit test file. Manual verification is the next step.
|
|
expect(true).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// NETWORK BLOCKING TESTS
|
|
// ============================================================================
|
|
|
|
describe('Network Blocking', () => {
|
|
let originalFetch: typeof fetch;
|
|
|
|
beforeEach(() => {
|
|
// Save originals
|
|
originalFetch = globalThis.fetch;
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|