2026-03-04 11:30:44 +01:00
|
|
|
/**
|
|
|
|
|
* Unit tests for crypto primitives (AES-256-GCM, RSA-OAEP)
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import { AESEncryption } from '../../src/core/crypto/encryption';
|
|
|
|
|
import { RSAOperations } from '../../src/core/crypto/rsa';
|
|
|
|
|
import { arrayBufferToBase64, base64ToArrayBuffer, stringToArrayBuffer, arrayBufferToString } from '../../src/core/crypto/utils';
|
|
|
|
|
|
|
|
|
|
describe('AESEncryption', () => {
|
|
|
|
|
let aes: AESEncryption;
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
aes = new AESEncryption();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('generateKey produces 256-bit key', async () => {
|
|
|
|
|
const key = await aes.generateKey();
|
|
|
|
|
expect(key.type).toBe('secret');
|
|
|
|
|
expect((key.algorithm as AesKeyAlgorithm).length).toBe(256);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('encrypt/decrypt roundtrip', async () => {
|
|
|
|
|
const key = await aes.generateKey();
|
|
|
|
|
const plaintext = stringToArrayBuffer('hello secure world');
|
|
|
|
|
|
|
|
|
|
const { ciphertext, nonce } = await aes.encrypt(plaintext, key);
|
|
|
|
|
expect(ciphertext.byteLength).toBeGreaterThan(0);
|
|
|
|
|
expect(nonce.byteLength).toBe(12);
|
|
|
|
|
|
|
|
|
|
// Web Crypto appends 16-byte tag — decrypt should succeed
|
|
|
|
|
const decrypted = await aes.decrypt(ciphertext, nonce, key);
|
|
|
|
|
expect(arrayBufferToString(decrypted)).toBe('hello secure world');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('decrypt fails with wrong key', async () => {
|
|
|
|
|
const key1 = await aes.generateKey();
|
|
|
|
|
const key2 = await aes.generateKey();
|
|
|
|
|
const { ciphertext, nonce } = await aes.encrypt(stringToArrayBuffer('secret'), key1);
|
|
|
|
|
|
|
|
|
|
await expect(aes.decrypt(ciphertext, nonce, key2)).rejects.toThrow();
|
|
|
|
|
});
|
|
|
|
|
|
fix:
Added DisposedError
Wrapped zeroMemory() in its own try/catch in finally
Generic error message; fixed ArrayBufferLike TypeScript type issue
Generic error message; password/salt/IV wrapped in SecureByteContext
Password ≥8 chars enforced; zeroKeys(); rotateKeys(); debug-gated logs; TS type fix
Zero source ArrayBuffer after req.write()
Added timeout, debug, keyRotationInterval, keyRotationDir, keyRotationPassword
dispose(), assertNotDisposed(), startKeyRotationTimer(), rotateKeys(); Promise-mutex on ensureKeys(); new URL() validation; CR/LF API key check; server error detail truncation; response schema validation; all console.log behind debugMode
Propagates new config fields; dispose()
Tests for dispose, timer, header injection, URL validation, error sanitization, debug flag
Tests for generic error messages, password validation, zeroKeys()
2026-04-01 14:28:05 +02:00
|
|
|
test('decrypt with wrong key throws generic message (no internal details)', async () => {
|
|
|
|
|
const key1 = await aes.generateKey();
|
|
|
|
|
const key2 = await aes.generateKey();
|
|
|
|
|
const { ciphertext, nonce } = await aes.encrypt(stringToArrayBuffer('secret'), key1);
|
|
|
|
|
|
|
|
|
|
await expect(aes.decrypt(ciphertext, nonce, key2))
|
|
|
|
|
.rejects.toThrow('AES-GCM decryption failed');
|
|
|
|
|
try {
|
|
|
|
|
await aes.decrypt(ciphertext, nonce, key2);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
expect((e as Error).message).toBe('AES-GCM decryption failed');
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-04 11:30:44 +01:00
|
|
|
test('exportKey / importKey roundtrip', async () => {
|
|
|
|
|
const key = await aes.generateKey();
|
|
|
|
|
const exported = await aes.exportKey(key);
|
|
|
|
|
expect(exported.byteLength).toBe(32); // 256-bit
|
|
|
|
|
|
|
|
|
|
const imported = await aes.importKey(exported);
|
|
|
|
|
const plaintext = stringToArrayBuffer('roundtrip test');
|
|
|
|
|
const { ciphertext, nonce } = await aes.encrypt(plaintext, imported);
|
|
|
|
|
const decrypted = await aes.decrypt(ciphertext, nonce, imported);
|
|
|
|
|
expect(arrayBufferToString(decrypted)).toBe('roundtrip test');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('GCM tag split/join compatibility with Python format', async () => {
|
|
|
|
|
// Simulate what SecureCompletionClient.performEncryption does:
|
|
|
|
|
// split the 16-byte tag from Web Crypto output, then re-join for decrypt
|
|
|
|
|
const key = await aes.generateKey();
|
|
|
|
|
const plaintext = stringToArrayBuffer('tag split test');
|
|
|
|
|
|
|
|
|
|
const { ciphertext, nonce } = await aes.encrypt(plaintext, key);
|
|
|
|
|
|
|
|
|
|
const TAG_LENGTH = 16;
|
|
|
|
|
const ciphertextBytes = new Uint8Array(ciphertext);
|
|
|
|
|
const ciphertextOnly = ciphertextBytes.slice(0, ciphertextBytes.length - TAG_LENGTH);
|
|
|
|
|
const tag = ciphertextBytes.slice(ciphertextBytes.length - TAG_LENGTH);
|
|
|
|
|
|
|
|
|
|
// Re-join before decrypt (as in decryptResponse)
|
|
|
|
|
const combined = new Uint8Array(ciphertextOnly.length + tag.length);
|
|
|
|
|
combined.set(ciphertextOnly, 0);
|
|
|
|
|
combined.set(tag, ciphertextOnly.length);
|
|
|
|
|
|
|
|
|
|
const decrypted = await aes.decrypt(combined.buffer, nonce, key);
|
|
|
|
|
expect(arrayBufferToString(decrypted)).toBe('tag split test');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('RSAOperations', () => {
|
|
|
|
|
let rsa: RSAOperations;
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
rsa = new RSAOperations();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('generateKeyPair produces usable 2048-bit keys', async () => {
|
|
|
|
|
const kp = await rsa.generateKeyPair(2048);
|
|
|
|
|
expect(kp.publicKey.type).toBe('public');
|
|
|
|
|
expect(kp.privateKey.type).toBe('private');
|
|
|
|
|
expect((kp.publicKey.algorithm as RsaHashedKeyAlgorithm).modulusLength).toBe(2048);
|
|
|
|
|
}, 30000);
|
|
|
|
|
|
|
|
|
|
test('encrypt/decrypt AES key roundtrip', async () => {
|
|
|
|
|
const kp = await rsa.generateKeyPair(2048);
|
|
|
|
|
const aes = new AESEncryption();
|
|
|
|
|
const aesKey = await aes.generateKey();
|
|
|
|
|
const aesKeyBytes = await aes.exportKey(aesKey);
|
|
|
|
|
|
|
|
|
|
const encrypted = await rsa.encryptKey(aesKeyBytes, kp.publicKey);
|
|
|
|
|
const decrypted = await rsa.decryptKey(encrypted, kp.privateKey);
|
|
|
|
|
|
|
|
|
|
expect(arrayBufferToBase64(decrypted)).toBe(arrayBufferToBase64(aesKeyBytes));
|
|
|
|
|
}, 30000);
|
|
|
|
|
|
|
|
|
|
test('exportPublicKey / importPublicKey roundtrip', async () => {
|
|
|
|
|
const kp = await rsa.generateKeyPair(2048);
|
|
|
|
|
const pem = await rsa.exportPublicKey(kp.publicKey);
|
|
|
|
|
expect(pem).toContain('-----BEGIN PUBLIC KEY-----');
|
|
|
|
|
|
|
|
|
|
const imported = await rsa.importPublicKey(pem);
|
|
|
|
|
expect(imported.type).toBe('public');
|
|
|
|
|
}, 30000);
|
|
|
|
|
|
|
|
|
|
test('exportPrivateKey / importPrivateKey roundtrip (no password)', async () => {
|
|
|
|
|
const kp = await rsa.generateKeyPair(2048);
|
|
|
|
|
const pem = await rsa.exportPrivateKey(kp.privateKey);
|
|
|
|
|
expect(pem).toContain('-----BEGIN PRIVATE KEY-----');
|
|
|
|
|
|
|
|
|
|
const imported = await rsa.importPrivateKey(pem);
|
|
|
|
|
expect(imported.type).toBe('private');
|
|
|
|
|
}, 30000);
|
|
|
|
|
|
|
|
|
|
test('importPrivateKey fails with wrong password', async () => {
|
|
|
|
|
const kp = await rsa.generateKeyPair(2048);
|
|
|
|
|
const pem = await rsa.exportPrivateKey(kp.privateKey, 'correct-password');
|
|
|
|
|
await expect(rsa.importPrivateKey(pem, 'wrong-password')).rejects.toThrow();
|
|
|
|
|
}, 30000);
|
fix:
Added DisposedError
Wrapped zeroMemory() in its own try/catch in finally
Generic error message; fixed ArrayBufferLike TypeScript type issue
Generic error message; password/salt/IV wrapped in SecureByteContext
Password ≥8 chars enforced; zeroKeys(); rotateKeys(); debug-gated logs; TS type fix
Zero source ArrayBuffer after req.write()
Added timeout, debug, keyRotationInterval, keyRotationDir, keyRotationPassword
dispose(), assertNotDisposed(), startKeyRotationTimer(), rotateKeys(); Promise-mutex on ensureKeys(); new URL() validation; CR/LF API key check; server error detail truncation; response schema validation; all console.log behind debugMode
Propagates new config fields; dispose()
Tests for dispose, timer, header injection, URL validation, error sanitization, debug flag
Tests for generic error messages, password validation, zeroKeys()
2026-04-01 14:28:05 +02:00
|
|
|
|
|
|
|
|
test('decryptKey with wrong private key throws generic message', async () => {
|
|
|
|
|
const kp1 = await rsa.generateKeyPair(2048);
|
|
|
|
|
const kp2 = await rsa.generateKeyPair(2048);
|
|
|
|
|
const aes = new AESEncryption();
|
|
|
|
|
const aesKey = await aes.generateKey();
|
|
|
|
|
const aesKeyBytes = await aes.exportKey(aesKey);
|
|
|
|
|
|
|
|
|
|
const encrypted = await rsa.encryptKey(aesKeyBytes, kp1.publicKey);
|
|
|
|
|
|
|
|
|
|
await expect(rsa.decryptKey(encrypted, kp2.privateKey))
|
|
|
|
|
.rejects.toThrow('RSA key decryption failed');
|
|
|
|
|
try {
|
|
|
|
|
await rsa.decryptKey(encrypted, kp2.privateKey);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
// Must not contain internal engine error details
|
|
|
|
|
expect((e as Error).message).toBe('RSA key decryption failed');
|
|
|
|
|
}
|
|
|
|
|
}, 30000);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('KeyManager password validation', () => {
|
|
|
|
|
test('generateKeys rejects password shorter than 8 characters', async () => {
|
|
|
|
|
const { KeyManager } = await import('../../src/core/crypto/keys');
|
|
|
|
|
const km = new KeyManager();
|
|
|
|
|
await expect(km.generateKeys({ keySize: 2048, password: 'short' }))
|
|
|
|
|
.rejects.toThrow('at least 8 characters');
|
|
|
|
|
}, 30000);
|
|
|
|
|
|
|
|
|
|
test('generateKeys rejects empty password', async () => {
|
|
|
|
|
const { KeyManager } = await import('../../src/core/crypto/keys');
|
|
|
|
|
const km = new KeyManager();
|
|
|
|
|
await expect(km.generateKeys({ keySize: 2048, password: '' }))
|
|
|
|
|
.rejects.toThrow('at least 8 characters');
|
|
|
|
|
}, 30000);
|
|
|
|
|
|
|
|
|
|
test('generateKeys accepts password of exactly 8 characters', async () => {
|
|
|
|
|
const { KeyManager } = await import('../../src/core/crypto/keys');
|
|
|
|
|
const km = new KeyManager();
|
|
|
|
|
await expect(km.generateKeys({ keySize: 2048, password: '12345678' }))
|
|
|
|
|
.resolves.toBeUndefined();
|
|
|
|
|
}, 30000);
|
|
|
|
|
|
|
|
|
|
test('generateKeys accepts undefined password (no encryption)', async () => {
|
|
|
|
|
const { KeyManager } = await import('../../src/core/crypto/keys');
|
|
|
|
|
const km = new KeyManager();
|
|
|
|
|
await expect(km.generateKeys({ keySize: 2048 }))
|
|
|
|
|
.resolves.toBeUndefined();
|
|
|
|
|
}, 30000);
|
|
|
|
|
|
|
|
|
|
test('zeroKeys clears key references', async () => {
|
|
|
|
|
const { KeyManager } = await import('../../src/core/crypto/keys');
|
|
|
|
|
const km = new KeyManager();
|
|
|
|
|
await km.generateKeys({ keySize: 2048 });
|
|
|
|
|
expect(km.hasKeys()).toBe(true);
|
|
|
|
|
km.zeroKeys();
|
|
|
|
|
expect(km.hasKeys()).toBe(false);
|
|
|
|
|
}, 30000);
|
2026-03-04 11:30:44 +01:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('Base64 utilities', () => {
|
|
|
|
|
test('arrayBufferToBase64 / base64ToArrayBuffer roundtrip', () => {
|
|
|
|
|
const original = new Uint8Array([0, 1, 127, 128, 255]);
|
|
|
|
|
const b64 = arrayBufferToBase64(original.buffer);
|
|
|
|
|
const restored = new Uint8Array(base64ToArrayBuffer(b64));
|
|
|
|
|
expect(Array.from(restored)).toEqual([0, 1, 127, 128, 255]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('stringToArrayBuffer / arrayBufferToString roundtrip', () => {
|
|
|
|
|
const text = 'Hello, 世界! 🔐';
|
|
|
|
|
const buf = stringToArrayBuffer(text);
|
|
|
|
|
expect(arrayBufferToString(buf)).toBe(text);
|
|
|
|
|
});
|
|
|
|
|
});
|