From d9d2ec98db2dab5074523df3e55302e93f6203bd Mon Sep 17 00:00:00 2001 From: alpha-nerd-nomyo Date: Wed, 1 Apr 2026 14:28:05 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20Added=20DisposedError=20Wrapped=20zeroMe?= =?UTF-8?q?mory()=20in=20its=20own=20try/catch=20in=20finally=20Generic=20?= =?UTF-8?q?error=20message;=20fixed=20ArrayBufferLike=20TypeScript=20type?= =?UTF-8?q?=20issue=20Generic=20error=20message;=20password/salt/IV=20wrap?= =?UTF-8?q?ped=20in=20SecureByteContext=20Password=20=E2=89=A58=20chars=20?= =?UTF-8?q?enforced;=20zeroKeys();=20rotateKeys();=20debug-gated=20logs;?= =?UTF-8?q?=20TS=20type=20fix=20Zero=20source=20ArrayBuffer=20after=20req.?= =?UTF-8?q?write()=20Added=20timeout,=20debug,=20keyRotationInterval,=20ke?= =?UTF-8?q?yRotationDir,=20keyRotationPassword=20dispose(),=20assertNotDis?= =?UTF-8?q?posed(),=20startKeyRotationTimer(),=20rotateKeys();=20Promise-m?= =?UTF-8?q?utex=20on=20ensureKeys();=20new=20URL()=20validation;=20CR/LF?= =?UTF-8?q?=20API=20key=20check;=20server=20error=20detail=20truncation;?= =?UTF-8?q?=20response=20schema=20validation;=20all=20console.log=20behind?= =?UTF-8?q?=20debugMode=20Propagates=20new=20config=20fields;=20dispose()?= =?UTF-8?q?=20Tests=20for=20dispose,=20timer,=20header=20injection,=20URL?= =?UTF-8?q?=20validation,=20error=20sanitization,=20debug=20flag=20Tests?= =?UTF-8?q?=20for=20generic=20error=20messages,=20password=20validation,?= =?UTF-8?q?=20zeroKeys()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/SecureChatCompletion.ts | 17 +++ src/core/SecureCompletionClient.ts | 194 +++++++++++++++++++++++----- src/core/crypto/encryption.ts | 18 ++- src/core/crypto/keys.ts | 77 ++++++++--- src/core/crypto/rsa.ts | 84 ++++++------ src/core/http/node.ts | 4 + src/core/memory/secure.ts | 6 +- src/errors/index.ts | 11 ++ src/types/client.ts | 30 +++++ tests/unit/crypto.test.ts | 72 +++++++++++ tests/unit/secure_client.test.ts | 198 ++++++++++++++++++++++++----- 11 files changed, 582 insertions(+), 129 deletions(-) diff --git a/src/api/SecureChatCompletion.ts b/src/api/SecureChatCompletion.ts index 9f04c47..53c92e2 100644 --- a/src/api/SecureChatCompletion.ts +++ b/src/api/SecureChatCompletion.ts @@ -17,6 +17,11 @@ export class SecureChatCompletion { allowHttp = false, apiKey, secureMemory = true, + timeout, + debug, + keyRotationInterval, + keyRotationDir, + keyRotationPassword, } = config; this.apiKey = apiKey; @@ -24,6 +29,11 @@ export class SecureChatCompletion { routerUrl: baseUrl, allowHttp, 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 { return this.create(request); } + + /** + * Release resources: stop key rotation timer and zero in-memory key material. + */ + dispose(): void { + this.client.dispose(); + } } diff --git a/src/core/SecureCompletionClient.ts b/src/core/SecureCompletionClient.ts index 30e83f1..de88dfa 100644 --- a/src/core/SecureCompletionClient.ts +++ b/src/core/SecureCompletionClient.ts @@ -21,6 +21,7 @@ import { ServerError, ForbiddenError, ServiceUnavailableError, + DisposedError, } from '../errors'; import { arrayBufferToBase64, @@ -64,41 +65,126 @@ export class SecureCompletionClient { private secureMemoryImpl = createSecureMemory(); private readonly keySize: 2048 | 4096; + private disposed = false; + private readonly debugMode: boolean; + private readonly requestTimeout: number; + private readonly keyRotationInterval: number; + private keyRotationTimer?: ReturnType; + private readonly keyRotationDir?: string; + private readonly keyRotationPassword?: string; + private _isHttps: boolean = true; + + // Promise-based mutex: serialises concurrent ensureKeys() calls + private ensureKeysLock: Promise = Promise.resolve(); + constructor(config: ClientConfig = { routerUrl: 'https://api.nomyo.ai:12435' }) { const { routerUrl = 'https://api.nomyo.ai:12435', allowHttp = false, secureMemory = true, keySize = 4096, + timeout = 60000, + debug = false, + keyRotationInterval = 86400000, // 24 hours + keyRotationDir, + keyRotationPassword, } = config; + this.debugMode = debug; + this.requestTimeout = timeout; + this.keyRotationInterval = keyRotationInterval; + this.keyRotationDir = keyRotationDir; + this.keyRotationPassword = keyRotationPassword; this.keySize = keySize; - this.routerUrl = routerUrl.replace(/\/$/, ''); this.allowHttp = allowHttp; this.secureMemory = secureMemory; - // Validate HTTPS for security - if (!this.routerUrl.startsWith('https://')) { + // Validate and parse URL + 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) { 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. ' + 'Man-in-the-middle attacks are possible!' ); } else { - console.log('HTTP mode enabled for local development (INSECURE)'); + if (this.debugMode) console.log('HTTP mode enabled for local development (INSECURE)'); } } // Initialize components - this.keyManager = new KeyManager(); + this.keyManager = new KeyManager(this.debugMode); this.aes = new AESEncryption(); this.rsa = new RSAOperations(); this.httpClient = createHttpClient(); // Log memory protection info 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 { + 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; password?: string; } = {}): Promise { + this.assertNotDisposed(); await this.keyManager.generateKeys({ keySize: this.keySize, ...options, @@ -123,6 +210,7 @@ export class SecureCompletionClient { publicKeyPath?: string, password?: string ): Promise { + this.assertNotDisposed(); await this.keyManager.loadKeys( { privateKeyPath, publicKeyPath }, 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 { - if (this.keyManager.hasKeys()) { - return; - } + private ensureKeys(): Promise { + let resolve!: () => void; + let reject!: (e: unknown) => void; + const callerPromise = new Promise((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 { + if (this.keyManager.hasKeys()) return; // Try to load keys from default location (Node.js only) if (typeof window === 'undefined') { @@ -150,10 +256,10 @@ export class SecureCompletionClient { await fs.access(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; } 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 */ async fetchServerPublicKey(): Promise { - 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) { throw new SecurityError( '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`; try { - const response = await this.httpClient.get(url, { timeout: 60000 }); + const response = await this.httpClient.get(url, { timeout: this.requestTimeout }); if (response.statusCode === 200) { const serverPublicKey = arrayBufferToString(response.body); @@ -196,8 +303,8 @@ export class SecureCompletionClient { throw new Error('Server returned invalid public key format'); } - if (this.routerUrl.startsWith('https://')) { - console.log("Server's public key fetched securely over HTTPS"); + if (this._isHttps) { + if (this.debugMode) console.log("Server's public key fetched securely over HTTPS"); } else { 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 */ async encryptPayload(payload: object): Promise { - console.log('Encrypting payload...'); + this.assertNotDisposed(); if (!payload || typeof payload !== '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})`); } - console.log(`Payload size: ${payloadBytes.byteLength} bytes`); + if (this.debugMode) console.log(`Payload size: ${payloadBytes.byteLength} bytes`); if (this.secureMemory) { const context = new SecureByteContext(payloadBytes, true); @@ -297,7 +404,7 @@ export class SecureCompletionClient { const packageJson = JSON.stringify(encryptedPackage); 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; }); @@ -310,7 +417,7 @@ export class SecureCompletionClient { * Web Crypto AES-GCM decrypt expects ciphertext || tag concatenated. */ async decryptResponse(encryptedResponse: ArrayBuffer, payloadId: string): Promise> { - console.log('Decrypting response...'); + this.assertNotDisposed(); if (!encryptedResponse || encryptedResponse.byteLength === 0) { throw new Error('Empty encrypted response'); @@ -368,7 +475,22 @@ export class SecureCompletionClient { const plaintextContext = new SecureByteContext(plaintext, this.secureMemory); return await plaintextContext.use(async (protectedPlaintext) => { const responseJson = arrayBufferToString(protectedPlaintext); - return JSON.parse(responseJson) as Record; + const decoded = JSON.parse(responseJson) as Record; + + // 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, }; - console.log('Response decrypted successfully'); + if (this.debugMode) console.log('Response decrypted successfully'); return response; } catch (error) { // Don't leak specific decryption errors (timing attacks) @@ -401,7 +523,8 @@ export class SecureCompletionClient { apiKey?: string, securityTier?: string ): Promise> { - console.log('Sending secure chat completion request...'); + this.assertNotDisposed(); + if (this.debugMode) console.log('Sending secure chat completion request...'); // Validate security tier 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(); const encryptedPayload = await this.encryptPayload(payload); @@ -433,14 +563,14 @@ export class SecureCompletionClient { } 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 }; try { response = await this.httpClient.post(url, { headers, body: encryptedPayload, - timeout: 60000, + timeout: this.requestTimeout, }); } catch (error) { if (error instanceof Error) { @@ -452,7 +582,7 @@ export class SecureCompletionClient { throw error; } - console.log(`HTTP Status: ${response.statusCode}`); + if (this.debugMode) console.log(`HTTP Status: ${response.statusCode}`); if (response.statusCode === 200) { return await this.decryptResponse(response.body, payloadId); @@ -474,7 +604,9 @@ export class SecureCompletionClient { // 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) { 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`); } } diff --git a/src/core/crypto/encryption.ts b/src/core/crypto/encryption.ts index 780696b..f511e96 100644 --- a/src/core/crypto/encryption.ts +++ b/src/core/crypto/encryption.ts @@ -36,14 +36,20 @@ export class AESEncryption { data: ArrayBuffer, key: CryptoKey ): Promise<{ ciphertext: ArrayBuffer; nonce: ArrayBuffer }> { - // Generate random 96-bit (12-byte) nonce - const nonce = generateRandomBytes(12); + // Generate random 96-bit (12-byte) nonce — copy into a plain ArrayBuffer + // 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 const ciphertext = await this.subtle.encrypt( { name: 'AES-GCM', - iv: nonce, + iv: nonceView, tagLength: 128, // 128-bit authentication tag }, key, @@ -52,7 +58,7 @@ export class AESEncryption { return { ciphertext, - nonce: nonce.buffer, + nonce, }; } @@ -80,8 +86,8 @@ export class AESEncryption { ); return plaintext; - } catch (error) { - throw new Error(`AES-GCM decryption failed: ${error instanceof Error ? error.message : 'Unknown error'}`); + } catch (_error) { + throw new Error('AES-GCM decryption failed'); } } diff --git a/src/core/crypto/keys.ts b/src/core/crypto/keys.ts index 376b42d..1fa4e02 100644 --- a/src/core/crypto/keys.ts +++ b/src/core/crypto/keys.ts @@ -1,7 +1,7 @@ /** * Key management for RSA key pairs * Handles key generation, loading, and persistence - * + * * NOTE: Browser storage is NOT implemented in this version for security reasons. * Keys are kept in-memory only in browsers. For persistent keys, use Node.js. */ @@ -15,9 +15,11 @@ export class KeyManager { private publicKey?: CryptoKey; private privateKey?: CryptoKey; private publicKeyPem?: string; + private debug: boolean; - constructor() { + constructor(debug = false) { this.rsa = new RSAOperations(); + this.debug = debug; } /** @@ -32,7 +34,11 @@ export class KeyManager { password, } = 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 const keyPair = await this.rsa.generateKeyPair(keySize); @@ -42,7 +48,7 @@ export class KeyManager { // Export public key to PEM 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) 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.'); } - 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 path = require('path'); @@ -74,10 +84,15 @@ export class KeyManager { const { createPrivateKey } = require('crypto') as typeof import('crypto'); const keyObject = createPrivateKey({ key: privateKeyPem, format: 'pem', passphrase: password }); 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(); this.privateKey = await subtle.importKey( 'pkcs8', - pkcs8Der, + pkcs8Buf, { name: 'RSA-OAEP', hash: 'SHA-256' }, true, ['decrypt'] @@ -97,21 +112,25 @@ export class KeyManager { ); } - // Load or derive public key + // Load or derive public key — use local variables to satisfy strict null checks if (paths.publicKeyPath) { - this.publicKeyPem = await fs.readFile(paths.publicKeyPath, 'utf-8'); - this.publicKey = await this.rsa.importPublicKey(this.publicKeyPem); + const pem = await fs.readFile(paths.publicKeyPath, 'utf-8') as string; + this.publicKeyPem = pem; + this.publicKey = await this.rsa.importPublicKey(pem); } else { const publicKeyPath = path.join( path.dirname(paths.privateKeyPath), 'public_key.pem' ); - this.publicKeyPem = await fs.readFile(publicKeyPath, 'utf-8'); - this.publicKey = await this.rsa.importPublicKey(this.publicKeyPem); + const pem = await fs.readFile(publicKeyPath, 'utf-8') as string; + this.publicKeyPem = pem; + this.publicKey = await this.rsa.importPublicKey(pem); } - console.log(`Valid ${privAlgorithm.modulusLength}-bit RSA private key`); - console.log('Keys loaded successfully'); + if (this.debug) { + console.log(`Valid ${privAlgorithm.modulusLength}-bit RSA private key`); + console.log('Keys loaded successfully'); + } } /** @@ -132,7 +151,7 @@ export class KeyManager { const fs = require('fs').promises; 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 await fs.mkdir(directory, { recursive: true }); @@ -155,7 +174,7 @@ export class KeyManager { // Set restrictive permissions on private key (Unix-like systems) try { 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) { console.warn('Could not set private key permissions:', error); } @@ -170,18 +189,18 @@ export class KeyManager { // Set permissions on public key try { 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) { console.warn('Could not set public key permissions:', error); } if (password) { - console.log('Private key encrypted with password'); + if (this.debug) console.log('Private key encrypted with password'); } else { 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 { 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 { + this.zeroKeys(); + await this.generateKeys(options); + } } diff --git a/src/core/crypto/rsa.ts b/src/core/crypto/rsa.ts index fbf6668..6b15057 100644 --- a/src/core/crypto/rsa.ts +++ b/src/core/crypto/rsa.ts @@ -4,6 +4,7 @@ */ import { getCrypto, pemToArrayBuffer, arrayBufferToPem, stringToArrayBuffer, arrayBufferToString } from './utils'; +import { SecureByteContext } from '../memory/secure'; export class RSAOperations { private subtle: SubtleCrypto; @@ -60,8 +61,8 @@ export class RSAOperations { privateKey, encryptedKey ); - } catch (error) { - throw new Error(`RSA-OAEP decryption failed: ${error instanceof Error ? error.message : 'Unknown error'}`); + } catch (_error) { + throw new Error('RSA key decryption failed'); } } @@ -148,47 +149,52 @@ export class RSAOperations { * @returns PEM-encoded encrypted private key */ private async encryptPrivateKeyWithPassword(keyData: ArrayBuffer, password: string): Promise { - // Derive encryption key from password using PBKDF2 - const passwordKey = await this.subtle.importKey( - 'raw', - stringToArrayBuffer(password), - 'PBKDF2', - false, - ['deriveKey'] - ); + // 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( + 'raw', + pwData, + 'PBKDF2', + false, + ['deriveKey'] + ); - const salt = crypto.getRandomValues(new Uint8Array(16)); - const derivedKey = await this.subtle.deriveKey( - { - name: 'PBKDF2', - salt: salt, - iterations: 100000, - hash: 'SHA-256', - }, - passwordKey, - { name: 'AES-CBC', length: 256 }, - false, - ['encrypt'] - ); + // 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( + { name: 'PBKDF2', salt: saltView, iterations: 100000, hash: 'SHA-256' }, + passwordKey, + { name: 'AES-CBC', length: 256 }, + false, + ['encrypt'] + ); - // Encrypt private key with AES-256-CBC - const iv = crypto.getRandomValues(new Uint8Array(16)); - const encrypted = await this.subtle.encrypt( - { - name: 'AES-CBC', - iv: iv, - }, - derivedKey, - keyData - ); + // Wrap IV so it is zeroed after use + 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( + { name: 'AES-CBC', iv: ivView }, + derivedKey, + keyData + ); - // Combine salt + iv + encrypted data - const combined = new Uint8Array(salt.length + iv.length + encrypted.byteLength); - combined.set(salt, 0); - combined.set(iv, salt.length); - combined.set(new Uint8Array(encrypted), salt.length + iv.length); + // Combine salt + iv + encrypted data + const combined = new Uint8Array(saltView.length + ivView.length + encrypted.byteLength); + combined.set(saltView, 0); + combined.set(ivView, saltView.length); + combined.set(new Uint8Array(encrypted), saltView.length + ivView.length); - return arrayBufferToPem(combined.buffer, 'PRIVATE'); + return arrayBufferToPem(combined.buffer, 'PRIVATE'); + }); + }); + }); } /** diff --git a/src/core/http/node.ts b/src/core/http/node.ts index 9579ab7..3f7cc12 100644 --- a/src/core/http/node.ts +++ b/src/core/http/node.ts @@ -75,6 +75,10 @@ export class NodeHttpClient implements HttpClient { }); 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(); }); } diff --git a/src/core/memory/secure.ts b/src/core/memory/secure.ts index 8ae115b..6e1cd15 100644 --- a/src/core/memory/secure.ts +++ b/src/core/memory/secure.ts @@ -48,7 +48,11 @@ export class SecureByteContext { } finally { // Always zero memory, even if exception occurred if (this.useSecure) { - this.secureMemory.zeroMemory(this.data); + try { + this.secureMemory.zeroMemory(this.data); + } catch (_zeroErr) { + // zeroMemory failure must not mask the original error + } } } } diff --git a/src/errors/index.ts b/src/errors/index.ts index 60b1d47..f8ecbb9 100644 --- a/src/errors/index.ts +++ b/src/errors/index.ts @@ -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); + } + } +} diff --git a/src/types/client.ts b/src/types/client.ts index 6f9accb..973c59e 100644 --- a/src/types/client.ts +++ b/src/types/client.ts @@ -17,6 +17,21 @@ export interface ClientConfig { /** Optional API key for authentication */ 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 { @@ -53,4 +68,19 @@ export interface ChatCompletionConfig { /** Enable secure memory protection */ 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; } diff --git a/tests/unit/crypto.test.ts b/tests/unit/crypto.test.ts index 42b0298..e4c3ac7 100644 --- a/tests/unit/crypto.test.ts +++ b/tests/unit/crypto.test.ts @@ -40,6 +40,20 @@ describe('AESEncryption', () => { 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 () => { const key = await aes.generateKey(); const exported = await aes.exportKey(key); @@ -124,6 +138,64 @@ describe('RSAOperations', () => { const pem = await rsa.exportPrivateKey(kp.privateKey, 'correct-password'); await expect(rsa.importPrivateKey(pem, 'wrong-password')).rejects.toThrow(); }, 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', () => { diff --git a/tests/unit/secure_client.test.ts b/tests/unit/secure_client.test.ts index c16b673..28e139a 100644 --- a/tests/unit/secure_client.test.ts +++ b/tests/unit/secure_client.test.ts @@ -10,6 +10,7 @@ import { ForbiddenError, ServiceUnavailableError, RateLimitError, + DisposedError, } from '../../src/errors'; import { stringToArrayBuffer } from '../../src/core/crypto/utils'; @@ -50,20 +51,74 @@ describe('SecureCompletionClient constructor', () => { 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'); + 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', () => { test('throws SecurityError over HTTP without allowHttp', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined); const client = new SecureCompletionClient({ routerUrl: 'http://localhost:1234', allowHttp: false, + keyRotationInterval: 0, }); - // Suppress console.warn from constructor - jest.spyOn(console, 'warn').mockImplementation(() => undefined); 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 () => { const client = new SecureCompletionClient({ routerUrl: 'https://api.example.com:12434', + keyRotationInterval: 0, }); await expect( client.sendSecureRequest({}, 'test-id', undefined, 'ultra') ).rejects.toThrow("Invalid securityTier: 'ultra'"); + client.dispose(); }); 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', + keyRotationInterval: 0, }); 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"); } + 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 }).generateKeys(); + jest.spyOn(client as unknown as { encryptPayload: (p: object) => Promise }, '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 }).generateKeys(); + jest.spyOn(client as unknown as { encryptPayload: (p: object) => Promise }, '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 }).generateKeys(); + jest.spyOn(client as unknown as { encryptPayload: (p: object) => Promise }, '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)', () => { - // 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', + keyRotationInterval: 0, }); - // 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); @@ -118,104 +253,99 @@ describe('SecureCompletionClient.buildErrorFromResponse (via sendSecureRequest)' 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 }).generateKeys(); - - // Mock encryptPayload to skip actual encryption - jest.spyOn(client as unknown as { encryptPayload: () => Promise }, 'encryptPayload') + jest.spyOn(client as unknown as { encryptPayload: (p: object) => Promise }, 'encryptPayload') .mockResolvedValue(new ArrayBuffer(8)); await expect( client.sendSecureRequest({ model: 'test', messages: [] }, 'id-1') ).rejects.toBeInstanceOf(AuthenticationError); + client.dispose(); }, 30000); test('403 → ForbiddenError', async () => { const client = await clientWithMockedHttp(403, { detail: 'not allowed' }); await (client as unknown as { generateKeys: () => Promise }).generateKeys(); - jest.spyOn(client as unknown as { encryptPayload: () => Promise }, 'encryptPayload') + jest.spyOn(client as unknown as { encryptPayload: (p: object) => Promise }, 'encryptPayload') .mockResolvedValue(new ArrayBuffer(8)); await expect( client.sendSecureRequest({ model: 'test', messages: [] }, 'id-1') ).rejects.toBeInstanceOf(ForbiddenError); + client.dispose(); }, 30000); test('429 → RateLimitError', async () => { const client = await clientWithMockedHttp(429, { detail: 'too many' }); await (client as unknown as { generateKeys: () => Promise }).generateKeys(); - jest.spyOn(client as unknown as { encryptPayload: () => Promise }, 'encryptPayload') + jest.spyOn(client as unknown as { encryptPayload: (p: object) => Promise }, 'encryptPayload') .mockResolvedValue(new ArrayBuffer(8)); await expect( client.sendSecureRequest({ model: 'test', messages: [] }, 'id-1') ).rejects.toBeInstanceOf(RateLimitError); + client.dispose(); }, 30000); test('503 → ServiceUnavailableError', async () => { const client = await clientWithMockedHttp(503, { detail: 'down' }); await (client as unknown as { generateKeys: () => Promise }).generateKeys(); - jest.spyOn(client as unknown as { encryptPayload: () => Promise }, 'encryptPayload') + jest.spyOn(client as unknown as { encryptPayload: (p: object) => Promise }, 'encryptPayload') .mockResolvedValue(new ArrayBuffer(8)); await expect( client.sendSecureRequest({ model: 'test', messages: [] }, 'id-1') ).rejects.toBeInstanceOf(ServiceUnavailableError); + client.dispose(); }, 30000); - test('network error → APIConnectionError (not wrapping typed errors)', async () => { + test('network error → APIConnectionError', async () => { const client = new SecureCompletionClient({ routerUrl: 'https://api.example.com:12434', + keyRotationInterval: 0, }); const http = mockHttpClient(async () => { throw new Error('ECONNREFUSED'); }); (client as unknown as { httpClient: typeof http }).httpClient = http; await (client as unknown as { generateKeys: () => Promise }).generateKeys(); - jest.spyOn(client as unknown as { encryptPayload: () => Promise }, 'encryptPayload') + jest.spyOn(client as unknown as { encryptPayload: (p: object) => Promise }, 'encryptPayload') .mockResolvedValue(new ArrayBuffer(8)); await expect( client.sendSecureRequest({ model: 'test', messages: [] }, 'id-1') ).rejects.toBeInstanceOf(APIConnectionError); + client.dispose(); }, 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 }); + const clientA = new SecureCompletionClient({ routerUrl: 'https://x', allowHttp: true, keyRotationInterval: 0 }); + const clientB = new SecureCompletionClient({ routerUrl: 'https://x', allowHttp: true, keyRotationInterval: 0 }); await (clientA as unknown as { generateKeys: () => Promise }).generateKeys(); await (clientB as unknown as { generateKeys: () => Promise }).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 } }).keyManager.getPublicKeyPEM(); - // Mock fetchServerPublicKey on clientA to return clientB's public key jest.spyOn(clientA as unknown as { fetchServerPublicKey: () => Promise }, '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_payload.tag).toBeTruthy(); expect(pkg.encrypted_aes_key).toBeTruthy(); + + clientA.dispose(); + clientB.dispose(); }, 60000); });