fix:
- AES GCM protocol mismatch - better, granular error handling - UUID now uses crypto.randomUUID() - added native mlock addon to improve security - ZeroBuffer uses explicit_bzero now - fixed imports feat: - added unit tests
This commit is contained in:
parent
0b09b9a9c3
commit
c7601b2270
17 changed files with 12600 additions and 164 deletions
142
tests/unit/crypto.test.ts
Normal file
142
tests/unit/crypto.test.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
/**
|
||||
* 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();
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
84
tests/unit/errors.test.ts
Normal file
84
tests/unit/errors.test.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
/**
|
||||
* Unit tests for error classes
|
||||
*/
|
||||
|
||||
import {
|
||||
APIError,
|
||||
AuthenticationError,
|
||||
InvalidRequestError,
|
||||
RateLimitError,
|
||||
ServerError,
|
||||
ForbiddenError,
|
||||
ServiceUnavailableError,
|
||||
APIConnectionError,
|
||||
SecurityError,
|
||||
} from '../../src/errors';
|
||||
|
||||
describe('Error classes', () => {
|
||||
test('APIError has correct properties', () => {
|
||||
const err = new APIError('test', 400, { detail: 'bad' });
|
||||
expect(err.message).toBe('test');
|
||||
expect(err.statusCode).toBe(400);
|
||||
expect(err.errorDetails).toEqual({ detail: 'bad' });
|
||||
expect(err.name).toBe('APIError');
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
});
|
||||
|
||||
test('AuthenticationError extends APIError', () => {
|
||||
const err = new AuthenticationError('unauthorized', 401);
|
||||
expect(err.name).toBe('AuthenticationError');
|
||||
expect(err.statusCode).toBe(401);
|
||||
expect(err).toBeInstanceOf(APIError);
|
||||
});
|
||||
|
||||
test('InvalidRequestError extends APIError', () => {
|
||||
const err = new InvalidRequestError('bad request', 400);
|
||||
expect(err.name).toBe('InvalidRequestError');
|
||||
expect(err.statusCode).toBe(400);
|
||||
expect(err).toBeInstanceOf(APIError);
|
||||
});
|
||||
|
||||
test('RateLimitError extends APIError', () => {
|
||||
const err = new RateLimitError('rate limit', 429);
|
||||
expect(err.name).toBe('RateLimitError');
|
||||
expect(err.statusCode).toBe(429);
|
||||
expect(err).toBeInstanceOf(APIError);
|
||||
});
|
||||
|
||||
test('ServerError extends APIError', () => {
|
||||
const err = new ServerError('server error', 500);
|
||||
expect(err.name).toBe('ServerError');
|
||||
expect(err.statusCode).toBe(500);
|
||||
expect(err).toBeInstanceOf(APIError);
|
||||
});
|
||||
|
||||
test('ForbiddenError extends APIError', () => {
|
||||
const err = new ForbiddenError('forbidden', 403);
|
||||
expect(err.name).toBe('ForbiddenError');
|
||||
expect(err.statusCode).toBe(403);
|
||||
expect(err).toBeInstanceOf(APIError);
|
||||
});
|
||||
|
||||
test('ServiceUnavailableError extends APIError', () => {
|
||||
const err = new ServiceUnavailableError('unavailable', 503);
|
||||
expect(err.name).toBe('ServiceUnavailableError');
|
||||
expect(err.statusCode).toBe(503);
|
||||
expect(err).toBeInstanceOf(APIError);
|
||||
});
|
||||
|
||||
test('APIConnectionError is standalone', () => {
|
||||
const err = new APIConnectionError('connection failed');
|
||||
expect(err.name).toBe('APIConnectionError');
|
||||
expect(err.message).toBe('connection failed');
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err).not.toBeInstanceOf(APIError);
|
||||
});
|
||||
|
||||
test('SecurityError is standalone', () => {
|
||||
const err = new SecurityError('security violation');
|
||||
expect(err.name).toBe('SecurityError');
|
||||
expect(err.message).toBe('security violation');
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err).not.toBeInstanceOf(APIError);
|
||||
});
|
||||
});
|
||||
221
tests/unit/secure_client.test.ts
Normal file
221
tests/unit/secure_client.test.ts
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
/**
|
||||
* Unit tests for SecureCompletionClient (with mocked HTTP)
|
||||
*/
|
||||
|
||||
import { SecureCompletionClient } from '../../src/core/SecureCompletionClient';
|
||||
import {
|
||||
SecurityError,
|
||||
APIConnectionError,
|
||||
AuthenticationError,
|
||||
ForbiddenError,
|
||||
ServiceUnavailableError,
|
||||
RateLimitError,
|
||||
} from '../../src/errors';
|
||||
import { stringToArrayBuffer } from '../../src/core/crypto/utils';
|
||||
|
||||
// ---- helpers ---------------------------------------------------------------
|
||||
|
||||
function makeJsonResponse(statusCode: number, body: object): { statusCode: number; headers: Record<string, string>; body: ArrayBuffer } {
|
||||
return {
|
||||
statusCode,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: stringToArrayBuffer(JSON.stringify(body)),
|
||||
};
|
||||
}
|
||||
|
||||
function mockHttpClient(handler: (url: string, opts: unknown) => Promise<{ statusCode: number; headers: Record<string, string>; body: ArrayBuffer }>) {
|
||||
return {
|
||||
post: jest.fn((url: string, opts: unknown) => handler(url, opts)),
|
||||
get: jest.fn((url: string, _opts?: unknown) => handler(url, _opts)),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('SecureCompletionClient constructor', () => {
|
||||
test('warns about HTTP if allowHttp is false', () => {
|
||||
const spy = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
new SecureCompletionClient({ routerUrl: 'http://localhost:1234' });
|
||||
expect(spy).toHaveBeenCalledWith(expect.stringContaining('INSECURE'));
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
test('does not warn when allowHttp is true', () => {
|
||||
const spy = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
new SecureCompletionClient({ routerUrl: 'http://localhost:1234', allowHttp: true });
|
||||
// Only the HTTP-mode log should appear, not a warning
|
||||
expect(spy).not.toHaveBeenCalledWith(expect.stringContaining('Man-in-the-middle'));
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
test('removes trailing slash from routerUrl', () => {
|
||||
const client = new SecureCompletionClient({ routerUrl: 'https://api.example.com:12434/' });
|
||||
// We can verify indirectly via fetchServerPublicKey URL construction
|
||||
expect((client as unknown as { routerUrl: string }).routerUrl).toBe('https://api.example.com:12434');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SecureCompletionClient.fetchServerPublicKey', () => {
|
||||
test('throws SecurityError over HTTP without allowHttp', async () => {
|
||||
const client = new SecureCompletionClient({
|
||||
routerUrl: 'http://localhost:1234',
|
||||
allowHttp: false,
|
||||
});
|
||||
// Suppress console.warn from constructor
|
||||
jest.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
await expect(client.fetchServerPublicKey()).rejects.toBeInstanceOf(SecurityError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SecureCompletionClient.sendSecureRequest — security tier validation', () => {
|
||||
test('throws for invalid security tier', async () => {
|
||||
const client = new SecureCompletionClient({
|
||||
routerUrl: 'https://api.example.com:12434',
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.sendSecureRequest({}, 'test-id', undefined, 'ultra')
|
||||
).rejects.toThrow("Invalid securityTier: 'ultra'");
|
||||
});
|
||||
|
||||
test('accepts valid security tiers', async () => {
|
||||
// We just need to verify no validation error is thrown at the tier check stage
|
||||
// (subsequent network call will fail, which is expected in unit tests)
|
||||
const client = new SecureCompletionClient({
|
||||
routerUrl: 'https://api.example.com:12434',
|
||||
});
|
||||
|
||||
for (const tier of ['standard', 'high', 'maximum']) {
|
||||
// Should not throw a tier validation error (will throw something else)
|
||||
await expect(
|
||||
client.sendSecureRequest({}, 'test-id', undefined, tier)
|
||||
).rejects.not.toThrow("Invalid securityTier");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('SecureCompletionClient.buildErrorFromResponse (via sendSecureRequest)', () => {
|
||||
// We can test error mapping by making the HTTP mock return specific status codes
|
||||
// and verifying the correct typed error is thrown.
|
||||
|
||||
async function clientWithMockedHttp(statusCode: number, body: object) {
|
||||
const client = new SecureCompletionClient({
|
||||
routerUrl: 'https://api.example.com:12434',
|
||||
});
|
||||
|
||||
// Inject mocked HTTP client
|
||||
const http = mockHttpClient(async (url: string) => {
|
||||
if (url.includes('/pki/public_key')) {
|
||||
// Should not be reached in error tests
|
||||
throw new Error('unexpected pki call');
|
||||
}
|
||||
return makeJsonResponse(statusCode, body);
|
||||
});
|
||||
(client as unknown as { httpClient: typeof http }).httpClient = http;
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
test('401 → AuthenticationError', async () => {
|
||||
const client = await clientWithMockedHttp(401, { detail: 'bad key' });
|
||||
// Keys must be generated first, so inject a pre-generated key set
|
||||
await (client as unknown as { generateKeys: () => Promise<void> }).generateKeys();
|
||||
|
||||
// Mock encryptPayload to skip actual encryption
|
||||
jest.spyOn(client as unknown as { encryptPayload: () => Promise<ArrayBuffer> }, 'encryptPayload')
|
||||
.mockResolvedValue(new ArrayBuffer(8));
|
||||
|
||||
await expect(
|
||||
client.sendSecureRequest({ model: 'test', messages: [] }, 'id-1')
|
||||
).rejects.toBeInstanceOf(AuthenticationError);
|
||||
}, 30000);
|
||||
|
||||
test('403 → ForbiddenError', async () => {
|
||||
const client = await clientWithMockedHttp(403, { detail: 'not allowed' });
|
||||
await (client as unknown as { generateKeys: () => Promise<void> }).generateKeys();
|
||||
jest.spyOn(client as unknown as { encryptPayload: () => Promise<ArrayBuffer> }, 'encryptPayload')
|
||||
.mockResolvedValue(new ArrayBuffer(8));
|
||||
|
||||
await expect(
|
||||
client.sendSecureRequest({ model: 'test', messages: [] }, 'id-1')
|
||||
).rejects.toBeInstanceOf(ForbiddenError);
|
||||
}, 30000);
|
||||
|
||||
test('429 → RateLimitError', async () => {
|
||||
const client = await clientWithMockedHttp(429, { detail: 'too many' });
|
||||
await (client as unknown as { generateKeys: () => Promise<void> }).generateKeys();
|
||||
jest.spyOn(client as unknown as { encryptPayload: () => Promise<ArrayBuffer> }, 'encryptPayload')
|
||||
.mockResolvedValue(new ArrayBuffer(8));
|
||||
|
||||
await expect(
|
||||
client.sendSecureRequest({ model: 'test', messages: [] }, 'id-1')
|
||||
).rejects.toBeInstanceOf(RateLimitError);
|
||||
}, 30000);
|
||||
|
||||
test('503 → ServiceUnavailableError', async () => {
|
||||
const client = await clientWithMockedHttp(503, { detail: 'down' });
|
||||
await (client as unknown as { generateKeys: () => Promise<void> }).generateKeys();
|
||||
jest.spyOn(client as unknown as { encryptPayload: () => Promise<ArrayBuffer> }, 'encryptPayload')
|
||||
.mockResolvedValue(new ArrayBuffer(8));
|
||||
|
||||
await expect(
|
||||
client.sendSecureRequest({ model: 'test', messages: [] }, 'id-1')
|
||||
).rejects.toBeInstanceOf(ServiceUnavailableError);
|
||||
}, 30000);
|
||||
|
||||
test('network error → APIConnectionError (not wrapping typed errors)', async () => {
|
||||
const client = new SecureCompletionClient({
|
||||
routerUrl: 'https://api.example.com:12434',
|
||||
});
|
||||
|
||||
const http = mockHttpClient(async () => { throw new Error('ECONNREFUSED'); });
|
||||
(client as unknown as { httpClient: typeof http }).httpClient = http;
|
||||
|
||||
await (client as unknown as { generateKeys: () => Promise<void> }).generateKeys();
|
||||
jest.spyOn(client as unknown as { encryptPayload: () => Promise<ArrayBuffer> }, 'encryptPayload')
|
||||
.mockResolvedValue(new ArrayBuffer(8));
|
||||
|
||||
await expect(
|
||||
client.sendSecureRequest({ model: 'test', messages: [] }, 'id-1')
|
||||
).rejects.toBeInstanceOf(APIConnectionError);
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
describe('SecureCompletionClient encrypt/decrypt roundtrip', () => {
|
||||
test('encryptPayload + decryptResponse roundtrip', async () => {
|
||||
// Use two clients: one for "client", one to simulate "server"
|
||||
const clientA = new SecureCompletionClient({ routerUrl: 'https://x', allowHttp: true });
|
||||
const clientB = new SecureCompletionClient({ routerUrl: 'https://x', allowHttp: true });
|
||||
|
||||
await (clientA as unknown as { generateKeys: () => Promise<void> }).generateKeys();
|
||||
await (clientB as unknown as { generateKeys: () => Promise<void> }).generateKeys();
|
||||
|
||||
const payload = { model: 'test', messages: [{ role: 'user', content: 'hi' }] };
|
||||
|
||||
// clientA encrypts, clientB decrypts (simulating server responding)
|
||||
// We can only test the client-side encrypt → client-side decrypt roundtrip
|
||||
// because the server uses its own key pair to encrypt the response.
|
||||
|
||||
// Directly test encryptPayload → decryptResponse using the SAME client's keys
|
||||
// (as the server would decrypt with its private key and re-encrypt with client's public key)
|
||||
// For a full roundtrip test we encrypt with clientB's public key and decrypt with clientB's private key.
|
||||
const serverPublicKeyPem = await (clientB as unknown as { keyManager: { getPublicKeyPEM: () => Promise<string> } }).keyManager.getPublicKeyPEM();
|
||||
|
||||
// Mock fetchServerPublicKey on clientA to return clientB's public key
|
||||
jest.spyOn(clientA as unknown as { fetchServerPublicKey: () => Promise<string> }, 'fetchServerPublicKey')
|
||||
.mockResolvedValue(serverPublicKeyPem);
|
||||
|
||||
const encrypted = await clientA.encryptPayload(payload);
|
||||
expect(encrypted.byteLength).toBeGreaterThan(0);
|
||||
|
||||
// Now simulate clientB decrypting (server decrypts the payload — we can only test
|
||||
// structure here since decryptResponse expects server-format encrypted response)
|
||||
const pkg = JSON.parse(new TextDecoder().decode(encrypted));
|
||||
expect(pkg.version).toBe('1.0');
|
||||
expect(pkg.algorithm).toBe('hybrid-aes256-rsa4096');
|
||||
expect(pkg.encrypted_payload.ciphertext).toBeTruthy();
|
||||
expect(pkg.encrypted_payload.nonce).toBeTruthy();
|
||||
expect(pkg.encrypted_payload.tag).toBeTruthy(); // tag must be present
|
||||
expect(pkg.encrypted_aes_key).toBeTruthy();
|
||||
}, 60000);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue