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()
This commit is contained in:
Alpha Nerd 2026-04-01 14:28:05 +02:00
parent 76703e2e3e
commit d9d2ec98db
11 changed files with 582 additions and 129 deletions

View file

@ -17,6 +17,11 @@ export class SecureChatCompletion {
allowHttp = false, allowHttp = false,
apiKey, apiKey,
secureMemory = true, secureMemory = true,
timeout,
debug,
keyRotationInterval,
keyRotationDir,
keyRotationPassword,
} = config; } = config;
this.apiKey = apiKey; this.apiKey = apiKey;
@ -24,6 +29,11 @@ export class SecureChatCompletion {
routerUrl: baseUrl, routerUrl: baseUrl,
allowHttp, allowHttp,
secureMemory, secureMemory,
...(timeout !== undefined && { timeout }),
...(debug !== undefined && { debug }),
...(keyRotationInterval !== undefined && { keyRotationInterval }),
...(keyRotationDir !== undefined && { keyRotationDir }),
...(keyRotationPassword !== undefined && { keyRotationPassword }),
}); });
} }
@ -68,4 +78,11 @@ export class SecureChatCompletion {
async acreate(request: ChatCompletionRequest): Promise<ChatCompletionResponse> { async acreate(request: ChatCompletionRequest): Promise<ChatCompletionResponse> {
return this.create(request); return this.create(request);
} }
/**
* Release resources: stop key rotation timer and zero in-memory key material.
*/
dispose(): void {
this.client.dispose();
}
} }

View file

@ -21,6 +21,7 @@ import {
ServerError, ServerError,
ForbiddenError, ForbiddenError,
ServiceUnavailableError, ServiceUnavailableError,
DisposedError,
} from '../errors'; } from '../errors';
import { import {
arrayBufferToBase64, arrayBufferToBase64,
@ -64,41 +65,126 @@ export class SecureCompletionClient {
private secureMemoryImpl = createSecureMemory(); private secureMemoryImpl = createSecureMemory();
private readonly keySize: 2048 | 4096; private readonly keySize: 2048 | 4096;
private disposed = false;
private readonly debugMode: boolean;
private readonly requestTimeout: number;
private readonly keyRotationInterval: number;
private keyRotationTimer?: ReturnType<typeof setInterval>;
private readonly keyRotationDir?: string;
private readonly keyRotationPassword?: string;
private _isHttps: boolean = true;
// Promise-based mutex: serialises concurrent ensureKeys() calls
private ensureKeysLock: Promise<void> = Promise.resolve();
constructor(config: ClientConfig = { routerUrl: 'https://api.nomyo.ai:12435' }) { constructor(config: ClientConfig = { routerUrl: 'https://api.nomyo.ai:12435' }) {
const { const {
routerUrl = 'https://api.nomyo.ai:12435', routerUrl = 'https://api.nomyo.ai:12435',
allowHttp = false, allowHttp = false,
secureMemory = true, secureMemory = true,
keySize = 4096, keySize = 4096,
timeout = 60000,
debug = false,
keyRotationInterval = 86400000, // 24 hours
keyRotationDir,
keyRotationPassword,
} = config; } = config;
this.debugMode = debug;
this.requestTimeout = timeout;
this.keyRotationInterval = keyRotationInterval;
this.keyRotationDir = keyRotationDir;
this.keyRotationPassword = keyRotationPassword;
this.keySize = keySize; this.keySize = keySize;
this.routerUrl = routerUrl.replace(/\/$/, '');
this.allowHttp = allowHttp; this.allowHttp = allowHttp;
this.secureMemory = secureMemory; this.secureMemory = secureMemory;
// Validate HTTPS for security // Validate and parse URL
if (!this.routerUrl.startsWith('https://')) { let parsedUrl: URL;
try {
parsedUrl = new URL(routerUrl);
} catch {
throw new Error(`Invalid routerUrl: "${routerUrl}" is not a valid URL`);
}
this._isHttps = parsedUrl.protocol === 'https:';
this.routerUrl = routerUrl.replace(/\/$/, '');
if (!this._isHttps) {
if (!allowHttp) { if (!allowHttp) {
console.warn( console.warn(
'⚠️ WARNING: Using HTTP instead of HTTPS. ' + 'WARNING: Using HTTP instead of HTTPS. ' +
'This is INSECURE and should only be used for local development. ' + 'This is INSECURE and should only be used for local development. ' +
'Man-in-the-middle attacks are possible!' 'Man-in-the-middle attacks are possible!'
); );
} else { } else {
console.log('HTTP mode enabled for local development (INSECURE)'); if (this.debugMode) console.log('HTTP mode enabled for local development (INSECURE)');
} }
} }
// Initialize components // Initialize components
this.keyManager = new KeyManager(); this.keyManager = new KeyManager(this.debugMode);
this.aes = new AESEncryption(); this.aes = new AESEncryption();
this.rsa = new RSAOperations(); this.rsa = new RSAOperations();
this.httpClient = createHttpClient(); this.httpClient = createHttpClient();
// Log memory protection info // Log memory protection info
const protectionInfo = this.secureMemoryImpl.getProtectionInfo(); const protectionInfo = this.secureMemoryImpl.getProtectionInfo();
console.log(`Memory protection: ${protectionInfo.method} (${protectionInfo.details})`); if (this.debugMode) console.log(`Memory protection: ${protectionInfo.method} (${protectionInfo.details})`);
// Start key rotation timer
if (this.keyRotationInterval > 0) {
this.startKeyRotationTimer();
}
}
/**
* Release resources: cancel key rotation timer and zero in-memory key material.
* After calling dispose(), all methods throw DisposedError.
*/
dispose(): void {
if (this.disposed) return;
this.disposed = true;
if (this.keyRotationTimer !== undefined) {
clearInterval(this.keyRotationTimer);
this.keyRotationTimer = undefined;
}
this.keyManager.zeroKeys();
}
private assertNotDisposed(): void {
if (this.disposed) {
throw new DisposedError();
}
}
private startKeyRotationTimer(): void {
this.keyRotationTimer = setInterval(
() => { void this.rotateKeys(); },
this.keyRotationInterval
);
// Allow the process to exit without waiting for the next rotation tick
const timer = this.keyRotationTimer as unknown as { unref?: () => void };
if (typeof timer.unref === 'function') {
timer.unref();
}
}
private async rotateKeys(): Promise<void> {
if (this.disposed) return;
if (this.debugMode) console.log('Key rotation: generating new key pair...');
try {
await this.keyManager.rotateKeys({
keySize: this.keySize,
saveToFile: typeof window === 'undefined',
keyDir: this.keyRotationDir ?? 'client_keys',
password: this.keyRotationPassword,
});
if (this.debugMode) console.log('Key rotation: complete');
} catch (err) {
console.error('Key rotation failed:', err instanceof Error ? err.message : 'unknown error');
}
} }
/** /**
@ -109,6 +195,7 @@ export class SecureCompletionClient {
keyDir?: string; keyDir?: string;
password?: string; password?: string;
} = {}): Promise<void> { } = {}): Promise<void> {
this.assertNotDisposed();
await this.keyManager.generateKeys({ await this.keyManager.generateKeys({
keySize: this.keySize, keySize: this.keySize,
...options, ...options,
@ -123,6 +210,7 @@ export class SecureCompletionClient {
publicKeyPath?: string, publicKeyPath?: string,
password?: string password?: string
): Promise<void> { ): Promise<void> {
this.assertNotDisposed();
await this.keyManager.loadKeys( await this.keyManager.loadKeys(
{ privateKeyPath, publicKeyPath }, { privateKeyPath, publicKeyPath },
password password
@ -130,12 +218,30 @@ export class SecureCompletionClient {
} }
/** /**
* Ensure keys are loaded, generate if necessary * Ensure keys are loaded, generate if necessary.
* Uses a Promise-chain mutex to prevent concurrent key generation races.
*/ */
private async ensureKeys(): Promise<void> { private ensureKeys(): Promise<void> {
if (this.keyManager.hasKeys()) { let resolve!: () => void;
return; let reject!: (e: unknown) => void;
const callerPromise = new Promise<void>((res, rej) => {
resolve = res;
reject = rej;
});
// Append to the shared chain so callers queue up
this.ensureKeysLock = this.ensureKeysLock.then(async () => {
try {
await this._doEnsureKeys();
resolve();
} catch (e) {
reject(e);
} }
});
return callerPromise;
}
private async _doEnsureKeys(): Promise<void> {
if (this.keyManager.hasKeys()) return;
// Try to load keys from default location (Node.js only) // Try to load keys from default location (Node.js only)
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
@ -150,10 +256,10 @@ export class SecureCompletionClient {
await fs.access(publicKeyPath); await fs.access(publicKeyPath);
await this.loadKeys(privateKeyPath, publicKeyPath); await this.loadKeys(privateKeyPath, publicKeyPath);
console.log('Loaded existing keys from client_keys/'); if (this.debugMode) console.log('Loaded existing keys from client_keys/');
return; return;
} catch (_error) { } catch (_error) {
console.log('No existing keys found, generating new keys...'); if (this.debugMode) console.log('No existing keys found, generating new keys...');
} }
} }
@ -167,9 +273,10 @@ export class SecureCompletionClient {
* Fetch server's public key from /pki/public_key endpoint * Fetch server's public key from /pki/public_key endpoint
*/ */
async fetchServerPublicKey(): Promise<string> { async fetchServerPublicKey(): Promise<string> {
console.log("Fetching server's public key..."); this.assertNotDisposed();
if (this.debugMode) console.log("Fetching server's public key...");
if (!this.routerUrl.startsWith('https://')) { if (!this._isHttps) {
if (!this.allowHttp) { if (!this.allowHttp) {
throw new SecurityError( throw new SecurityError(
'Server public key must be fetched over HTTPS to prevent MITM attacks. ' + 'Server public key must be fetched over HTTPS to prevent MITM attacks. ' +
@ -184,7 +291,7 @@ export class SecureCompletionClient {
const url = `${this.routerUrl}/pki/public_key`; const url = `${this.routerUrl}/pki/public_key`;
try { try {
const response = await this.httpClient.get(url, { timeout: 60000 }); const response = await this.httpClient.get(url, { timeout: this.requestTimeout });
if (response.statusCode === 200) { if (response.statusCode === 200) {
const serverPublicKey = arrayBufferToString(response.body); const serverPublicKey = arrayBufferToString(response.body);
@ -196,8 +303,8 @@ export class SecureCompletionClient {
throw new Error('Server returned invalid public key format'); throw new Error('Server returned invalid public key format');
} }
if (this.routerUrl.startsWith('https://')) { if (this._isHttps) {
console.log("Server's public key fetched securely over HTTPS"); if (this.debugMode) console.log("Server's public key fetched securely over HTTPS");
} else { } else {
console.warn("Server's public key fetched over HTTP (INSECURE)"); console.warn("Server's public key fetched over HTTP (INSECURE)");
} }
@ -227,7 +334,7 @@ export class SecureCompletionClient {
* - encrypted_aes_key: AES key encrypted with server's RSA public key * - encrypted_aes_key: AES key encrypted with server's RSA public key
*/ */
async encryptPayload(payload: object): Promise<ArrayBuffer> { async encryptPayload(payload: object): Promise<ArrayBuffer> {
console.log('Encrypting payload...'); this.assertNotDisposed();
if (!payload || typeof payload !== 'object') { if (!payload || typeof payload !== 'object') {
throw new Error('Payload must be an object'); throw new Error('Payload must be an object');
@ -243,7 +350,7 @@ export class SecureCompletionClient {
throw new Error(`Payload too large: ${payloadBytes.byteLength} bytes (max: ${MAX_PAYLOAD_SIZE})`); throw new Error(`Payload too large: ${payloadBytes.byteLength} bytes (max: ${MAX_PAYLOAD_SIZE})`);
} }
console.log(`Payload size: ${payloadBytes.byteLength} bytes`); if (this.debugMode) console.log(`Payload size: ${payloadBytes.byteLength} bytes`);
if (this.secureMemory) { if (this.secureMemory) {
const context = new SecureByteContext(payloadBytes, true); const context = new SecureByteContext(payloadBytes, true);
@ -297,7 +404,7 @@ export class SecureCompletionClient {
const packageJson = JSON.stringify(encryptedPackage); const packageJson = JSON.stringify(encryptedPackage);
const packageBytes = stringToArrayBuffer(packageJson); const packageBytes = stringToArrayBuffer(packageJson);
console.log(`Encrypted package size: ${packageBytes.byteLength} bytes`); if (this.debugMode) console.log(`Encrypted package size: ${packageBytes.byteLength} bytes`);
return packageBytes; return packageBytes;
}); });
@ -310,7 +417,7 @@ export class SecureCompletionClient {
* Web Crypto AES-GCM decrypt expects ciphertext || tag concatenated. * Web Crypto AES-GCM decrypt expects ciphertext || tag concatenated.
*/ */
async decryptResponse(encryptedResponse: ArrayBuffer, payloadId: string): Promise<Record<string, unknown>> { async decryptResponse(encryptedResponse: ArrayBuffer, payloadId: string): Promise<Record<string, unknown>> {
console.log('Decrypting response...'); this.assertNotDisposed();
if (!encryptedResponse || encryptedResponse.byteLength === 0) { if (!encryptedResponse || encryptedResponse.byteLength === 0) {
throw new Error('Empty encrypted response'); throw new Error('Empty encrypted response');
@ -368,7 +475,22 @@ export class SecureCompletionClient {
const plaintextContext = new SecureByteContext(plaintext, this.secureMemory); const plaintextContext = new SecureByteContext(plaintext, this.secureMemory);
return await plaintextContext.use(async (protectedPlaintext) => { return await plaintextContext.use(async (protectedPlaintext) => {
const responseJson = arrayBufferToString(protectedPlaintext); const responseJson = arrayBufferToString(protectedPlaintext);
return JSON.parse(responseJson) as Record<string, unknown>; const decoded = JSON.parse(responseJson) as Record<string, unknown>;
// Validate required ChatCompletionResponse fields
if (
typeof decoded.id !== 'string' ||
typeof decoded.object !== 'string' ||
typeof decoded.created !== 'number' ||
typeof decoded.model !== 'string' ||
!Array.isArray(decoded.choices)
) {
throw new SecurityError(
'Decrypted response does not conform to expected schema'
);
}
return decoded;
}); });
}); });
@ -382,7 +504,7 @@ export class SecureCompletionClient {
encryption_algorithm: packageData.algorithm, encryption_algorithm: packageData.algorithm,
}; };
console.log('Response decrypted successfully'); if (this.debugMode) console.log('Response decrypted successfully');
return response; return response;
} catch (error) { } catch (error) {
// Don't leak specific decryption errors (timing attacks) // Don't leak specific decryption errors (timing attacks)
@ -401,7 +523,8 @@ export class SecureCompletionClient {
apiKey?: string, apiKey?: string,
securityTier?: string securityTier?: string
): Promise<Record<string, unknown>> { ): Promise<Record<string, unknown>> {
console.log('Sending secure chat completion request...'); this.assertNotDisposed();
if (this.debugMode) console.log('Sending secure chat completion request...');
// Validate security tier // Validate security tier
if (securityTier !== undefined) { if (securityTier !== undefined) {
@ -413,6 +536,13 @@ export class SecureCompletionClient {
} }
} }
// Validate API key does not contain header injection characters
if (apiKey !== undefined) {
if (/[\r\n]/.test(apiKey)) {
throw new SecurityError('Invalid API key: must not contain line separator characters');
}
}
await this.ensureKeys(); await this.ensureKeys();
const encryptedPayload = await this.encryptPayload(payload); const encryptedPayload = await this.encryptPayload(payload);
@ -433,14 +563,14 @@ export class SecureCompletionClient {
} }
const url = `${this.routerUrl}/v1/chat/secure_completion`; const url = `${this.routerUrl}/v1/chat/secure_completion`;
console.log(`Target URL: ${url}`); if (this.debugMode) console.log(`Target URL: ${url}`);
let response: { statusCode: number; body: ArrayBuffer }; let response: { statusCode: number; body: ArrayBuffer };
try { try {
response = await this.httpClient.post(url, { response = await this.httpClient.post(url, {
headers, headers,
body: encryptedPayload, body: encryptedPayload,
timeout: 60000, timeout: this.requestTimeout,
}); });
} catch (error) { } catch (error) {
if (error instanceof Error) { if (error instanceof Error) {
@ -452,7 +582,7 @@ export class SecureCompletionClient {
throw error; throw error;
} }
console.log(`HTTP Status: ${response.statusCode}`); if (this.debugMode) console.log(`HTTP Status: ${response.statusCode}`);
if (response.statusCode === 200) { if (response.statusCode === 200) {
return await this.decryptResponse(response.body, payloadId); return await this.decryptResponse(response.body, payloadId);
@ -474,7 +604,9 @@ export class SecureCompletionClient {
// Ignore JSON parse errors // Ignore JSON parse errors
} }
const detail = (errorData.detail as string | undefined) ?? 'Unknown error'; // Truncate and strip non-printable chars to prevent log injection
const rawDetail = (errorData.detail as string | undefined) ?? 'Unknown error';
const detail = rawDetail.slice(0, 100).replace(/[^\x20-\x7E]/g, '');
switch (response.statusCode) { switch (response.statusCode) {
case 400: case 400:
@ -526,7 +658,7 @@ export class SecureCompletionClient {
); );
} }
console.log(`Valid ${algorithm.modulusLength}-bit RSA ${keyType} key`); if (this.debugMode) console.log(`Valid ${algorithm.modulusLength}-bit RSA ${keyType} key`);
} }
} }

View file

@ -36,14 +36,20 @@ export class AESEncryption {
data: ArrayBuffer, data: ArrayBuffer,
key: CryptoKey key: CryptoKey
): Promise<{ ciphertext: ArrayBuffer; nonce: ArrayBuffer }> { ): Promise<{ ciphertext: ArrayBuffer; nonce: ArrayBuffer }> {
// Generate random 96-bit (12-byte) nonce // Generate random 96-bit (12-byte) nonce — copy into a plain ArrayBuffer
const nonce = generateRandomBytes(12); // so the buffer type is strictly ArrayBuffer (not ArrayBufferLike)
const nonceRaw = generateRandomBytes(12);
const nonce = nonceRaw.buffer.slice(
nonceRaw.byteOffset,
nonceRaw.byteOffset + nonceRaw.byteLength
) as ArrayBuffer;
const nonceView = new Uint8Array(nonce);
// Encrypt with AES-GCM // Encrypt with AES-GCM
const ciphertext = await this.subtle.encrypt( const ciphertext = await this.subtle.encrypt(
{ {
name: 'AES-GCM', name: 'AES-GCM',
iv: nonce, iv: nonceView,
tagLength: 128, // 128-bit authentication tag tagLength: 128, // 128-bit authentication tag
}, },
key, key,
@ -52,7 +58,7 @@ export class AESEncryption {
return { return {
ciphertext, ciphertext,
nonce: nonce.buffer, nonce,
}; };
} }
@ -80,8 +86,8 @@ export class AESEncryption {
); );
return plaintext; return plaintext;
} catch (error) { } catch (_error) {
throw new Error(`AES-GCM decryption failed: ${error instanceof Error ? error.message : 'Unknown error'}`); throw new Error('AES-GCM decryption failed');
} }
} }

View file

@ -15,9 +15,11 @@ export class KeyManager {
private publicKey?: CryptoKey; private publicKey?: CryptoKey;
private privateKey?: CryptoKey; private privateKey?: CryptoKey;
private publicKeyPem?: string; private publicKeyPem?: string;
private debug: boolean;
constructor() { constructor(debug = false) {
this.rsa = new RSAOperations(); this.rsa = new RSAOperations();
this.debug = debug;
} }
/** /**
@ -32,7 +34,11 @@ export class KeyManager {
password, password,
} = options; } = options;
console.log(`Generating ${keySize}-bit RSA key pair...`); if (password !== undefined && password.length < 8) {
throw new Error('Password must be at least 8 characters');
}
if (this.debug) console.log(`Generating ${keySize}-bit RSA key pair...`);
// Generate key pair // Generate key pair
const keyPair = await this.rsa.generateKeyPair(keySize); const keyPair = await this.rsa.generateKeyPair(keySize);
@ -42,7 +48,7 @@ export class KeyManager {
// Export public key to PEM // Export public key to PEM
this.publicKeyPem = await this.rsa.exportPublicKey(this.publicKey); this.publicKeyPem = await this.rsa.exportPublicKey(this.publicKey);
console.log(`Generated ${keySize}-bit RSA key pair`); if (this.debug) console.log(`Generated ${keySize}-bit RSA key pair`);
// Save to file if requested (Node.js only) // Save to file if requested (Node.js only)
if (saveToFile) { if (saveToFile) {
@ -61,7 +67,11 @@ export class KeyManager {
throw new Error('File-based key loading is not supported in browsers. Use in-memory keys only.'); throw new Error('File-based key loading is not supported in browsers. Use in-memory keys only.');
} }
console.log('Loading keys from files...'); if (password !== undefined && password.length < 8) {
throw new Error('Password must be at least 8 characters');
}
if (this.debug) console.log('Loading keys from files...');
const fs = require('fs').promises; const fs = require('fs').promises;
const path = require('path'); const path = require('path');
@ -74,10 +84,15 @@ export class KeyManager {
const { createPrivateKey } = require('crypto') as typeof import('crypto'); const { createPrivateKey } = require('crypto') as typeof import('crypto');
const keyObject = createPrivateKey({ key: privateKeyPem, format: 'pem', passphrase: password }); const keyObject = createPrivateKey({ key: privateKeyPem, format: 'pem', passphrase: password });
const pkcs8Der = keyObject.export({ type: 'pkcs8', format: 'der' }) as Buffer; const pkcs8Der = keyObject.export({ type: 'pkcs8', format: 'der' }) as Buffer;
// Copy into a plain ArrayBuffer to satisfy strict Web Crypto typings
const pkcs8Buf = pkcs8Der.buffer.slice(
pkcs8Der.byteOffset,
pkcs8Der.byteOffset + pkcs8Der.byteLength
) as ArrayBuffer;
const subtle = getCrypto(); const subtle = getCrypto();
this.privateKey = await subtle.importKey( this.privateKey = await subtle.importKey(
'pkcs8', 'pkcs8',
pkcs8Der, pkcs8Buf,
{ name: 'RSA-OAEP', hash: 'SHA-256' }, { name: 'RSA-OAEP', hash: 'SHA-256' },
true, true,
['decrypt'] ['decrypt']
@ -97,22 +112,26 @@ export class KeyManager {
); );
} }
// Load or derive public key // Load or derive public key — use local variables to satisfy strict null checks
if (paths.publicKeyPath) { if (paths.publicKeyPath) {
this.publicKeyPem = await fs.readFile(paths.publicKeyPath, 'utf-8'); const pem = await fs.readFile(paths.publicKeyPath, 'utf-8') as string;
this.publicKey = await this.rsa.importPublicKey(this.publicKeyPem); this.publicKeyPem = pem;
this.publicKey = await this.rsa.importPublicKey(pem);
} else { } else {
const publicKeyPath = path.join( const publicKeyPath = path.join(
path.dirname(paths.privateKeyPath), path.dirname(paths.privateKeyPath),
'public_key.pem' 'public_key.pem'
); );
this.publicKeyPem = await fs.readFile(publicKeyPath, 'utf-8'); const pem = await fs.readFile(publicKeyPath, 'utf-8') as string;
this.publicKey = await this.rsa.importPublicKey(this.publicKeyPem); this.publicKeyPem = pem;
this.publicKey = await this.rsa.importPublicKey(pem);
} }
if (this.debug) {
console.log(`Valid ${privAlgorithm.modulusLength}-bit RSA private key`); console.log(`Valid ${privAlgorithm.modulusLength}-bit RSA private key`);
console.log('Keys loaded successfully'); console.log('Keys loaded successfully');
} }
}
/** /**
* Save keys to files (Node.js only) * Save keys to files (Node.js only)
@ -132,7 +151,7 @@ export class KeyManager {
const fs = require('fs').promises; const fs = require('fs').promises;
const path = require('path'); const path = require('path');
console.log(`Saving keys to ${directory}/...`); if (this.debug) console.log(`Saving keys to ${directory}/...`);
// Create directory if it doesn't exist // Create directory if it doesn't exist
await fs.mkdir(directory, { recursive: true }); await fs.mkdir(directory, { recursive: true });
@ -155,7 +174,7 @@ export class KeyManager {
// Set restrictive permissions on private key (Unix-like systems) // Set restrictive permissions on private key (Unix-like systems)
try { try {
await fs.chmod(privateKeyPath, 0o600); // Owner read/write only await fs.chmod(privateKeyPath, 0o600); // Owner read/write only
console.log('Private key permissions set to 600 (owner-only access)'); if (this.debug) console.log('Private key permissions set to 600 (owner-only access)');
} catch (error) { } catch (error) {
console.warn('Could not set private key permissions:', error); console.warn('Could not set private key permissions:', error);
} }
@ -170,18 +189,18 @@ export class KeyManager {
// Set permissions on public key // Set permissions on public key
try { try {
await fs.chmod(publicKeyPath, 0o644); // Owner read/write, others read await fs.chmod(publicKeyPath, 0o644); // Owner read/write, others read
console.log('Public key permissions set to 644'); if (this.debug) console.log('Public key permissions set to 644');
} catch (error) { } catch (error) {
console.warn('Could not set public key permissions:', error); console.warn('Could not set public key permissions:', error);
} }
if (password) { if (password) {
console.log('Private key encrypted with password'); if (this.debug) console.log('Private key encrypted with password');
} else { } else {
console.warn('Private key saved UNENCRYPTED (not recommended for production)'); console.warn('Private key saved UNENCRYPTED (not recommended for production)');
} }
console.log(`Keys saved to ${directory}/`); if (this.debug) console.log(`Keys saved to ${directory}/`);
} }
/** /**
@ -223,4 +242,26 @@ export class KeyManager {
hasKeys(): boolean { hasKeys(): boolean {
return !!(this.privateKey && this.publicKey); return !!(this.privateKey && this.publicKey);
} }
/**
* Zero in-memory key references.
* CryptoKey objects are opaque handles their backing memory is owned by the
* Web Crypto engine and cannot be zeroed from JavaScript. We sever the
* references so the GC can collect them as soon as possible.
*/
zeroKeys(): void {
this.privateKey = undefined;
this.publicKey = undefined;
// Strings are immutable; we can only null the reference.
this.publicKeyPem = undefined;
}
/**
* Rotate keys: zero the existing pair then generate a fresh one.
* @param options Key generation options (same as generateKeys)
*/
async rotateKeys(options: KeyGenOptions = {}): Promise<void> {
this.zeroKeys();
await this.generateKeys(options);
}
} }

View file

@ -4,6 +4,7 @@
*/ */
import { getCrypto, pemToArrayBuffer, arrayBufferToPem, stringToArrayBuffer, arrayBufferToString } from './utils'; import { getCrypto, pemToArrayBuffer, arrayBufferToPem, stringToArrayBuffer, arrayBufferToString } from './utils';
import { SecureByteContext } from '../memory/secure';
export class RSAOperations { export class RSAOperations {
private subtle: SubtleCrypto; private subtle: SubtleCrypto;
@ -60,8 +61,8 @@ export class RSAOperations {
privateKey, privateKey,
encryptedKey encryptedKey
); );
} catch (error) { } catch (_error) {
throw new Error(`RSA-OAEP decryption failed: ${error instanceof Error ? error.message : 'Unknown error'}`); throw new Error('RSA key decryption failed');
} }
} }
@ -148,47 +149,52 @@ export class RSAOperations {
* @returns PEM-encoded encrypted private key * @returns PEM-encoded encrypted private key
*/ */
private async encryptPrivateKeyWithPassword(keyData: ArrayBuffer, password: string): Promise<string> { private async encryptPrivateKeyWithPassword(keyData: ArrayBuffer, password: string): Promise<string> {
// Derive encryption key from password using PBKDF2 // Wrap password bytes so they are zeroed after key derivation
const passwordBytes = stringToArrayBuffer(password);
const pwContext = new SecureByteContext(passwordBytes, true);
return pwContext.use(async (pwData) => {
const passwordKey = await this.subtle.importKey( const passwordKey = await this.subtle.importKey(
'raw', 'raw',
stringToArrayBuffer(password), pwData,
'PBKDF2', 'PBKDF2',
false, false,
['deriveKey'] ['deriveKey']
); );
const salt = crypto.getRandomValues(new Uint8Array(16)); // Wrap salt so it is zeroed after use
const saltBytes = crypto.getRandomValues(new Uint8Array(16));
const saltContext = new SecureByteContext(saltBytes.buffer, true);
return saltContext.use(async (saltBuf) => {
const saltView = new Uint8Array(saltBuf);
const derivedKey = await this.subtle.deriveKey( const derivedKey = await this.subtle.deriveKey(
{ { name: 'PBKDF2', salt: saltView, iterations: 100000, hash: 'SHA-256' },
name: 'PBKDF2',
salt: salt,
iterations: 100000,
hash: 'SHA-256',
},
passwordKey, passwordKey,
{ name: 'AES-CBC', length: 256 }, { name: 'AES-CBC', length: 256 },
false, false,
['encrypt'] ['encrypt']
); );
// Encrypt private key with AES-256-CBC // Wrap IV so it is zeroed after use
const iv = crypto.getRandomValues(new Uint8Array(16)); const ivBytes = crypto.getRandomValues(new Uint8Array(16));
const ivContext = new SecureByteContext(ivBytes.buffer, true);
return ivContext.use(async (ivBuf) => {
const ivView = new Uint8Array(ivBuf);
const encrypted = await this.subtle.encrypt( const encrypted = await this.subtle.encrypt(
{ { name: 'AES-CBC', iv: ivView },
name: 'AES-CBC',
iv: iv,
},
derivedKey, derivedKey,
keyData keyData
); );
// Combine salt + iv + encrypted data // Combine salt + iv + encrypted data
const combined = new Uint8Array(salt.length + iv.length + encrypted.byteLength); const combined = new Uint8Array(saltView.length + ivView.length + encrypted.byteLength);
combined.set(salt, 0); combined.set(saltView, 0);
combined.set(iv, salt.length); combined.set(ivView, saltView.length);
combined.set(new Uint8Array(encrypted), salt.length + iv.length); combined.set(new Uint8Array(encrypted), saltView.length + ivView.length);
return arrayBufferToPem(combined.buffer, 'PRIVATE'); return arrayBufferToPem(combined.buffer, 'PRIVATE');
});
});
});
} }
/** /**

View file

@ -75,6 +75,10 @@ export class NodeHttpClient implements HttpClient {
}); });
req.write(bodyBuffer); req.write(bodyBuffer);
// Zero the source ArrayBuffer after data has been handed to the socket
if (body instanceof ArrayBuffer) {
new Uint8Array(body).fill(0);
}
req.end(); req.end();
}); });
} }

View file

@ -48,7 +48,11 @@ export class SecureByteContext {
} finally { } finally {
// Always zero memory, even if exception occurred // Always zero memory, even if exception occurred
if (this.useSecure) { if (this.useSecure) {
try {
this.secureMemory.zeroMemory(this.data); this.secureMemory.zeroMemory(this.data);
} catch (_zeroErr) {
// zeroMemory failure must not mask the original error
}
} }
} }
} }

View file

@ -84,3 +84,14 @@ export class SecurityError extends Error {
} }
} }
} }
export class DisposedError extends Error {
constructor(message = 'This client instance has been disposed and can no longer be used') {
super(message);
this.name = 'DisposedError';
if (captureStackTrace) {
captureStackTrace(this, this.constructor);
}
}
}

View file

@ -17,6 +17,21 @@ export interface ClientConfig {
/** Optional API key for authentication */ /** Optional API key for authentication */
apiKey?: string; apiKey?: string;
/** Request timeout in milliseconds (default: 60000) */
timeout?: number;
/** Enable debug logging (default: false) */
debug?: boolean;
/** Key rotation interval in milliseconds. Set to 0 to disable. (default: 86400000 = 24h) */
keyRotationInterval?: number;
/** Directory for rotated key files (Node.js only, default: 'client_keys') */
keyRotationDir?: string;
/** Password to encrypt rotated private key files */
keyRotationPassword?: string;
} }
export interface KeyGenOptions { export interface KeyGenOptions {
@ -53,4 +68,19 @@ export interface ChatCompletionConfig {
/** Enable secure memory protection */ /** Enable secure memory protection */
secureMemory?: boolean; secureMemory?: boolean;
/** Request timeout in milliseconds (default: 60000) */
timeout?: number;
/** Enable debug logging (default: false) */
debug?: boolean;
/** Key rotation interval in milliseconds. Set to 0 to disable. (default: 86400000 = 24h) */
keyRotationInterval?: number;
/** Directory for rotated key files (Node.js only, default: 'client_keys') */
keyRotationDir?: string;
/** Password to encrypt rotated private key files */
keyRotationPassword?: string;
} }

View file

@ -40,6 +40,20 @@ describe('AESEncryption', () => {
await expect(aes.decrypt(ciphertext, nonce, key2)).rejects.toThrow(); await expect(aes.decrypt(ciphertext, nonce, key2)).rejects.toThrow();
}); });
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');
}
});
test('exportKey / importKey roundtrip', async () => { test('exportKey / importKey roundtrip', async () => {
const key = await aes.generateKey(); const key = await aes.generateKey();
const exported = await aes.exportKey(key); const exported = await aes.exportKey(key);
@ -124,6 +138,64 @@ describe('RSAOperations', () => {
const pem = await rsa.exportPrivateKey(kp.privateKey, 'correct-password'); const pem = await rsa.exportPrivateKey(kp.privateKey, 'correct-password');
await expect(rsa.importPrivateKey(pem, 'wrong-password')).rejects.toThrow(); await expect(rsa.importPrivateKey(pem, 'wrong-password')).rejects.toThrow();
}, 30000); }, 30000);
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);
}); });
describe('Base64 utilities', () => { describe('Base64 utilities', () => {

View file

@ -10,6 +10,7 @@ import {
ForbiddenError, ForbiddenError,
ServiceUnavailableError, ServiceUnavailableError,
RateLimitError, RateLimitError,
DisposedError,
} from '../../src/errors'; } from '../../src/errors';
import { stringToArrayBuffer } from '../../src/core/crypto/utils'; import { stringToArrayBuffer } from '../../src/core/crypto/utils';
@ -50,20 +51,74 @@ describe('SecureCompletionClient constructor', () => {
test('removes trailing slash from routerUrl', () => { test('removes trailing slash from routerUrl', () => {
const client = new SecureCompletionClient({ routerUrl: 'https://api.example.com:12434/' }); 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'); expect((client as unknown as { routerUrl: string }).routerUrl).toBe('https://api.example.com:12434');
client.dispose();
});
test('throws on invalid URL', () => {
expect(() => new SecureCompletionClient({ routerUrl: 'not-a-url' }))
.toThrow('Invalid routerUrl');
});
test('http:// URL with allowHttp=true does not throw', () => {
expect(() => new SecureCompletionClient({
routerUrl: 'http://localhost:1234',
allowHttp: true,
})).not.toThrow();
});
});
describe('SecureCompletionClient.dispose()', () => {
test('calling dispose() twice does not throw', () => {
const client = new SecureCompletionClient({ routerUrl: 'https://api.example.com', keyRotationInterval: 0 });
client.dispose();
expect(() => client.dispose()).not.toThrow();
});
test('methods throw DisposedError after dispose()', async () => {
const client = new SecureCompletionClient({ routerUrl: 'https://api.example.com', keyRotationInterval: 0 });
client.dispose();
await expect(client.fetchServerPublicKey()).rejects.toBeInstanceOf(DisposedError);
await expect(client.encryptPayload({})).rejects.toBeInstanceOf(DisposedError);
await expect(client.sendSecureRequest({}, 'id')).rejects.toBeInstanceOf(DisposedError);
});
test('dispose() clears key rotation timer', () => {
jest.useFakeTimers();
const client = new SecureCompletionClient({
routerUrl: 'https://api.example.com',
keyRotationInterval: 1000,
});
const timerBefore = (client as unknown as { keyRotationTimer: unknown }).keyRotationTimer;
expect(timerBefore).toBeDefined();
client.dispose();
const timerAfter = (client as unknown as { keyRotationTimer: unknown }).keyRotationTimer;
expect(timerAfter).toBeUndefined();
jest.useRealTimers();
});
test('keyRotationInterval=0 does not start timer', () => {
const client = new SecureCompletionClient({
routerUrl: 'https://api.example.com',
keyRotationInterval: 0,
});
const timer = (client as unknown as { keyRotationTimer: unknown }).keyRotationTimer;
expect(timer).toBeUndefined();
client.dispose();
}); });
}); });
describe('SecureCompletionClient.fetchServerPublicKey', () => { describe('SecureCompletionClient.fetchServerPublicKey', () => {
test('throws SecurityError over HTTP without allowHttp', async () => { test('throws SecurityError over HTTP without allowHttp', async () => {
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
const client = new SecureCompletionClient({ const client = new SecureCompletionClient({
routerUrl: 'http://localhost:1234', routerUrl: 'http://localhost:1234',
allowHttp: false, allowHttp: false,
keyRotationInterval: 0,
}); });
// Suppress console.warn from constructor
jest.spyOn(console, 'warn').mockImplementation(() => undefined);
await expect(client.fetchServerPublicKey()).rejects.toBeInstanceOf(SecurityError); await expect(client.fetchServerPublicKey()).rejects.toBeInstanceOf(SecurityError);
client.dispose();
warnSpy.mockRestore();
}); });
}); });
@ -71,42 +126,122 @@ describe('SecureCompletionClient.sendSecureRequest — security tier validation'
test('throws for invalid security tier', async () => { test('throws for invalid security tier', async () => {
const client = new SecureCompletionClient({ const client = new SecureCompletionClient({
routerUrl: 'https://api.example.com:12434', routerUrl: 'https://api.example.com:12434',
keyRotationInterval: 0,
}); });
await expect( await expect(
client.sendSecureRequest({}, 'test-id', undefined, 'ultra') client.sendSecureRequest({}, 'test-id', undefined, 'ultra')
).rejects.toThrow("Invalid securityTier: 'ultra'"); ).rejects.toThrow("Invalid securityTier: 'ultra'");
client.dispose();
}); });
test('accepts valid security tiers', async () => { 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({ const client = new SecureCompletionClient({
routerUrl: 'https://api.example.com:12434', routerUrl: 'https://api.example.com:12434',
keyRotationInterval: 0,
}); });
for (const tier of ['standard', 'high', 'maximum']) { for (const tier of ['standard', 'high', 'maximum']) {
// Should not throw a tier validation error (will throw something else)
await expect( await expect(
client.sendSecureRequest({}, 'test-id', undefined, tier) client.sendSecureRequest({}, 'test-id', undefined, tier)
).rejects.not.toThrow("Invalid securityTier"); ).rejects.not.toThrow("Invalid securityTier");
} }
client.dispose();
});
});
describe('SecureCompletionClient — header injection validation', () => {
test('apiKey containing CR throws SecurityError', async () => {
const client = new SecureCompletionClient({
routerUrl: 'https://api.example.com',
keyRotationInterval: 0,
});
await (client as unknown as { generateKeys: () => Promise<void> }).generateKeys();
jest.spyOn(client as unknown as { encryptPayload: (p: object) => Promise<ArrayBuffer> }, 'encryptPayload')
.mockResolvedValue(new ArrayBuffer(8));
await expect(
client.sendSecureRequest({}, 'id', 'key\rwith\rcr')
).rejects.toBeInstanceOf(SecurityError);
client.dispose();
}, 30000);
test('apiKey containing LF throws SecurityError', async () => {
const client = new SecureCompletionClient({
routerUrl: 'https://api.example.com',
keyRotationInterval: 0,
});
await (client as unknown as { generateKeys: () => Promise<void> }).generateKeys();
jest.spyOn(client as unknown as { encryptPayload: (p: object) => Promise<ArrayBuffer> }, 'encryptPayload')
.mockResolvedValue(new ArrayBuffer(8));
await expect(
client.sendSecureRequest({}, 'id', 'key\nwith\nlf')
).rejects.toBeInstanceOf(SecurityError);
client.dispose();
}, 30000);
});
describe('SecureCompletionClient — error detail sanitization', () => {
test('long server detail is truncated to ≤100 chars in error message', async () => {
const client = new SecureCompletionClient({
routerUrl: 'https://api.example.com:12434',
keyRotationInterval: 0,
});
const http = mockHttpClient(async () => makeJsonResponse(400, { detail: 'x'.repeat(200) }));
(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: (p: object) => Promise<ArrayBuffer> }, 'encryptPayload')
.mockResolvedValue(new ArrayBuffer(8));
try {
await client.sendSecureRequest({}, 'id');
} catch (err) {
expect(err).toBeInstanceOf(Error);
// "Bad request: " prefix + max 100 char detail
expect((err as Error).message.length).toBeLessThanOrEqual(115);
}
client.dispose();
}, 30000);
});
describe('SecureCompletionClient — debug flag', () => {
test('console.log not called during construction when debug=false', () => {
const spy = jest.spyOn(console, 'log').mockImplementation(() => undefined);
const client = new SecureCompletionClient({
routerUrl: 'https://api.example.com',
debug: false,
keyRotationInterval: 0,
});
expect(spy).not.toHaveBeenCalled();
spy.mockRestore();
client.dispose();
});
test('console.log called during construction when debug=true', () => {
const spy = jest.spyOn(console, 'log').mockImplementation(() => undefined);
const client = new SecureCompletionClient({
routerUrl: 'https://api.example.com',
debug: true,
keyRotationInterval: 0,
});
expect(spy).toHaveBeenCalled();
spy.mockRestore();
client.dispose();
}); });
}); });
describe('SecureCompletionClient.buildErrorFromResponse (via sendSecureRequest)', () => { 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) { async function clientWithMockedHttp(statusCode: number, body: object) {
const client = new SecureCompletionClient({ const client = new SecureCompletionClient({
routerUrl: 'https://api.example.com:12434', routerUrl: 'https://api.example.com:12434',
keyRotationInterval: 0,
}); });
// Inject mocked HTTP client
const http = mockHttpClient(async (url: string) => { const http = mockHttpClient(async (url: string) => {
if (url.includes('/pki/public_key')) { if (url.includes('/pki/public_key')) {
// Should not be reached in error tests
throw new Error('unexpected pki call'); throw new Error('unexpected pki call');
} }
return makeJsonResponse(statusCode, body); return makeJsonResponse(statusCode, body);
@ -118,104 +253,99 @@ describe('SecureCompletionClient.buildErrorFromResponse (via sendSecureRequest)'
test('401 → AuthenticationError', async () => { test('401 → AuthenticationError', async () => {
const client = await clientWithMockedHttp(401, { detail: 'bad key' }); 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(); await (client as unknown as { generateKeys: () => Promise<void> }).generateKeys();
jest.spyOn(client as unknown as { encryptPayload: (p: object) => Promise<ArrayBuffer> }, 'encryptPayload')
// Mock encryptPayload to skip actual encryption
jest.spyOn(client as unknown as { encryptPayload: () => Promise<ArrayBuffer> }, 'encryptPayload')
.mockResolvedValue(new ArrayBuffer(8)); .mockResolvedValue(new ArrayBuffer(8));
await expect( await expect(
client.sendSecureRequest({ model: 'test', messages: [] }, 'id-1') client.sendSecureRequest({ model: 'test', messages: [] }, 'id-1')
).rejects.toBeInstanceOf(AuthenticationError); ).rejects.toBeInstanceOf(AuthenticationError);
client.dispose();
}, 30000); }, 30000);
test('403 → ForbiddenError', async () => { test('403 → ForbiddenError', async () => {
const client = await clientWithMockedHttp(403, { detail: 'not allowed' }); const client = await clientWithMockedHttp(403, { detail: 'not allowed' });
await (client as unknown as { generateKeys: () => Promise<void> }).generateKeys(); await (client as unknown as { generateKeys: () => Promise<void> }).generateKeys();
jest.spyOn(client as unknown as { encryptPayload: () => Promise<ArrayBuffer> }, 'encryptPayload') jest.spyOn(client as unknown as { encryptPayload: (p: object) => Promise<ArrayBuffer> }, 'encryptPayload')
.mockResolvedValue(new ArrayBuffer(8)); .mockResolvedValue(new ArrayBuffer(8));
await expect( await expect(
client.sendSecureRequest({ model: 'test', messages: [] }, 'id-1') client.sendSecureRequest({ model: 'test', messages: [] }, 'id-1')
).rejects.toBeInstanceOf(ForbiddenError); ).rejects.toBeInstanceOf(ForbiddenError);
client.dispose();
}, 30000); }, 30000);
test('429 → RateLimitError', async () => { test('429 → RateLimitError', async () => {
const client = await clientWithMockedHttp(429, { detail: 'too many' }); const client = await clientWithMockedHttp(429, { detail: 'too many' });
await (client as unknown as { generateKeys: () => Promise<void> }).generateKeys(); await (client as unknown as { generateKeys: () => Promise<void> }).generateKeys();
jest.spyOn(client as unknown as { encryptPayload: () => Promise<ArrayBuffer> }, 'encryptPayload') jest.spyOn(client as unknown as { encryptPayload: (p: object) => Promise<ArrayBuffer> }, 'encryptPayload')
.mockResolvedValue(new ArrayBuffer(8)); .mockResolvedValue(new ArrayBuffer(8));
await expect( await expect(
client.sendSecureRequest({ model: 'test', messages: [] }, 'id-1') client.sendSecureRequest({ model: 'test', messages: [] }, 'id-1')
).rejects.toBeInstanceOf(RateLimitError); ).rejects.toBeInstanceOf(RateLimitError);
client.dispose();
}, 30000); }, 30000);
test('503 → ServiceUnavailableError', async () => { test('503 → ServiceUnavailableError', async () => {
const client = await clientWithMockedHttp(503, { detail: 'down' }); const client = await clientWithMockedHttp(503, { detail: 'down' });
await (client as unknown as { generateKeys: () => Promise<void> }).generateKeys(); await (client as unknown as { generateKeys: () => Promise<void> }).generateKeys();
jest.spyOn(client as unknown as { encryptPayload: () => Promise<ArrayBuffer> }, 'encryptPayload') jest.spyOn(client as unknown as { encryptPayload: (p: object) => Promise<ArrayBuffer> }, 'encryptPayload')
.mockResolvedValue(new ArrayBuffer(8)); .mockResolvedValue(new ArrayBuffer(8));
await expect( await expect(
client.sendSecureRequest({ model: 'test', messages: [] }, 'id-1') client.sendSecureRequest({ model: 'test', messages: [] }, 'id-1')
).rejects.toBeInstanceOf(ServiceUnavailableError); ).rejects.toBeInstanceOf(ServiceUnavailableError);
client.dispose();
}, 30000); }, 30000);
test('network error → APIConnectionError (not wrapping typed errors)', async () => { test('network error → APIConnectionError', async () => {
const client = new SecureCompletionClient({ const client = new SecureCompletionClient({
routerUrl: 'https://api.example.com:12434', routerUrl: 'https://api.example.com:12434',
keyRotationInterval: 0,
}); });
const http = mockHttpClient(async () => { throw new Error('ECONNREFUSED'); }); const http = mockHttpClient(async () => { throw new Error('ECONNREFUSED'); });
(client as unknown as { httpClient: typeof http }).httpClient = http; (client as unknown as { httpClient: typeof http }).httpClient = http;
await (client as unknown as { generateKeys: () => Promise<void> }).generateKeys(); await (client as unknown as { generateKeys: () => Promise<void> }).generateKeys();
jest.spyOn(client as unknown as { encryptPayload: () => Promise<ArrayBuffer> }, 'encryptPayload') jest.spyOn(client as unknown as { encryptPayload: (p: object) => Promise<ArrayBuffer> }, 'encryptPayload')
.mockResolvedValue(new ArrayBuffer(8)); .mockResolvedValue(new ArrayBuffer(8));
await expect( await expect(
client.sendSecureRequest({ model: 'test', messages: [] }, 'id-1') client.sendSecureRequest({ model: 'test', messages: [] }, 'id-1')
).rejects.toBeInstanceOf(APIConnectionError); ).rejects.toBeInstanceOf(APIConnectionError);
client.dispose();
}, 30000); }, 30000);
}); });
describe('SecureCompletionClient encrypt/decrypt roundtrip', () => { describe('SecureCompletionClient encrypt/decrypt roundtrip', () => {
test('encryptPayload + decryptResponse roundtrip', async () => { test('encryptPayload + decryptResponse roundtrip', async () => {
// Use two clients: one for "client", one to simulate "server" const clientA = new SecureCompletionClient({ routerUrl: 'https://x', allowHttp: true, keyRotationInterval: 0 });
const clientA = new SecureCompletionClient({ routerUrl: 'https://x', allowHttp: true }); const clientB = new SecureCompletionClient({ routerUrl: 'https://x', allowHttp: true, keyRotationInterval: 0 });
const clientB = new SecureCompletionClient({ routerUrl: 'https://x', allowHttp: true });
await (clientA as unknown as { generateKeys: () => Promise<void> }).generateKeys(); await (clientA as unknown as { generateKeys: () => Promise<void> }).generateKeys();
await (clientB 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' }] }; 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(); 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') jest.spyOn(clientA as unknown as { fetchServerPublicKey: () => Promise<string> }, 'fetchServerPublicKey')
.mockResolvedValue(serverPublicKeyPem); .mockResolvedValue(serverPublicKeyPem);
const encrypted = await clientA.encryptPayload(payload); const encrypted = await clientA.encryptPayload(payload);
expect(encrypted.byteLength).toBeGreaterThan(0); 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)); const pkg = JSON.parse(new TextDecoder().decode(encrypted));
expect(pkg.version).toBe('1.0'); expect(pkg.version).toBe('1.0');
expect(pkg.algorithm).toBe('hybrid-aes256-rsa4096'); expect(pkg.algorithm).toBe('hybrid-aes256-rsa4096');
expect(pkg.encrypted_payload.ciphertext).toBeTruthy(); expect(pkg.encrypted_payload.ciphertext).toBeTruthy();
expect(pkg.encrypted_payload.nonce).toBeTruthy(); expect(pkg.encrypted_payload.nonce).toBeTruthy();
expect(pkg.encrypted_payload.tag).toBeTruthy(); // tag must be present expect(pkg.encrypted_payload.tag).toBeTruthy();
expect(pkg.encrypted_aes_key).toBeTruthy(); expect(pkg.encrypted_aes_key).toBeTruthy();
clientA.dispose();
clientB.dispose();
}, 60000); }, 60000);
}); });