/** * Secure Completion Client * Main client class for encrypted communication with NOMYO router * * Port of Python's SecureCompletionClient with full API compatibility */ import { ClientConfig } from '../types/client'; import { EncryptedPackage } from '../types/crypto'; import { KeyManager } from './crypto/keys'; import { AESEncryption } from './crypto/encryption'; import { RSAOperations } from './crypto/rsa'; import { createHttpClient, HttpClient } from './http/client'; import { createSecureMemory, SecureByteContext } from './memory/secure'; import { SecurityError, APIConnectionError } from '../errors'; import { arrayBufferToBase64, base64ToArrayBuffer, stringToArrayBuffer, arrayBufferToString, } from './crypto/utils'; export class SecureCompletionClient { private routerUrl: string; private allowHttp: boolean; private secureMemory: boolean; private keyManager: KeyManager; private aes: AESEncryption; private rsa: RSAOperations; private httpClient: HttpClient; private secureMemoryImpl = createSecureMemory(); constructor(config: ClientConfig = { routerUrl: 'https://api.nomyo.ai:12434' }) { const { routerUrl = 'https://api.nomyo.ai:12434', allowHttp = false, secureMemory = true, keySize = 4096, } = config; this.routerUrl = routerUrl.replace(/\/$/, ''); // Remove trailing slash this.allowHttp = allowHttp; this.secureMemory = secureMemory; // Validate HTTPS for security if (!this.routerUrl.startsWith('https://')) { if (!allowHttp) { console.warn( '⚠️ 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)'); } } // Initialize components this.keyManager = new KeyManager(); 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})`); } /** * Generate RSA key pair */ async generateKeys(options: { saveToFile?: boolean; keyDir?: string; password?: string; } = {}): Promise { await this.keyManager.generateKeys({ keySize: 4096, ...options, }); } /** * Load existing keys from files (Node.js only) */ async loadKeys( privateKeyPath: string, publicKeyPath?: string, password?: string ): Promise { await this.keyManager.loadKeys( { privateKeyPath, publicKeyPath }, password ); } /** * Ensure keys are loaded, generate if necessary */ private async ensureKeys(): Promise { if (this.keyManager.hasKeys()) { return; } // Try to load keys from default location (Node.js only) if (typeof window === 'undefined') { try { const fs = require('fs').promises; const path = require('path'); const privateKeyPath = path.join('client_keys', 'private_key.pem'); const publicKeyPath = path.join('client_keys', 'public_key.pem'); // Check if keys exist await fs.access(privateKeyPath); await fs.access(publicKeyPath); // Load keys await this.loadKeys(privateKeyPath, publicKeyPath); console.log('Loaded existing keys from client_keys/'); return; } catch (error) { // Keys don't exist, generate new ones console.log('No existing keys found, generating new keys...'); } } // Generate new keys await this.generateKeys({ saveToFile: typeof window === 'undefined', // Only save in Node.js keyDir: 'client_keys', }); } /** * Fetch server's public key from /pki/public_key endpoint */ async fetchServerPublicKey(): Promise { console.log("Fetching server's public key..."); // Security check: Ensure HTTPS is used unless HTTP explicitly allowed if (!this.routerUrl.startsWith('https://')) { if (!this.allowHttp) { throw new SecurityError( 'Server public key must be fetched over HTTPS to prevent MITM attacks. ' + 'For local development, initialize with allowHttp=true: ' + 'new SecureChatCompletion({ baseUrl: "http://localhost:12434", allowHttp: true })' ); } else { console.warn('Fetching key over HTTP (local development mode)'); } } const url = `${this.routerUrl}/pki/public_key`; try { const response = await this.httpClient.get(url, { timeout: 60000 }); if (response.statusCode === 200) { const serverPublicKey = arrayBufferToString(response.body); // Validate it's a valid PEM key try { await this.rsa.importPublicKey(serverPublicKey); } catch (error) { throw new Error('Server returned invalid public key format'); } if (this.routerUrl.startsWith('https://')) { console.log("Server's public key fetched securely over HTTPS"); } else { console.warn("Server's public key fetched over HTTP (INSECURE)"); } return serverPublicKey; } else { throw new Error(`Failed to fetch server's public key: HTTP ${response.statusCode}`); } } catch (error) { if (error instanceof SecurityError) { throw error; } if (error instanceof Error) { throw new APIConnectionError(`Failed to fetch server's public key: ${error.message}`); } throw error; } } /** * Encrypt a payload using hybrid encryption (AES-256-GCM + RSA-OAEP) */ async encryptPayload(payload: object): Promise { console.log('Encrypting payload...'); // Validate payload if (!payload || typeof payload !== 'object') { throw new Error('Payload must be an object'); } // Ensure keys are loaded await this.ensureKeys(); // Serialize payload to JSON const payloadJson = JSON.stringify(payload); const payloadBytes = stringToArrayBuffer(payloadJson); // Validate payload size (prevent DoS) const MAX_PAYLOAD_SIZE = 10 * 1024 * 1024; // 10MB limit if (payloadBytes.byteLength > MAX_PAYLOAD_SIZE) { throw new Error(`Payload too large: ${payloadBytes.byteLength} bytes (max: ${MAX_PAYLOAD_SIZE})`); } console.log(`Payload size: ${payloadBytes.byteLength} bytes`); // Use secure memory context if enabled if (this.secureMemory) { const context = new SecureByteContext(payloadBytes, true); return await context.use(async (protectedPayload) => { return await this.performEncryption(protectedPayload); }); } else { return await this.performEncryption(payloadBytes); } } /** * Perform the actual encryption (separated for secure memory context) */ private async performEncryption(payloadBytes: ArrayBuffer): Promise { // Generate AES key const aesKey = await this.aes.generateKey(); const aesKeyBytes = await this.aes.exportKey(aesKey); // Protect AES key in memory const aesContext = new SecureByteContext(aesKeyBytes, this.secureMemory); return await aesContext.use(async (protectedAesKey) => { // Encrypt payload with AES-GCM const { ciphertext, nonce } = await this.aes.encrypt(payloadBytes, aesKey); // Fetch server's public key const serverPublicKeyPem = await this.fetchServerPublicKey(); const serverPublicKey = await this.rsa.importPublicKey(serverPublicKeyPem); // Encrypt AES key with server's RSA public key const encryptedAesKey = await this.rsa.encryptKey(protectedAesKey, serverPublicKey); // Create encrypted package (matching Python format) const encryptedPackage = { version: '1.0', algorithm: 'hybrid-aes256-rsa4096', encrypted_payload: { ciphertext: arrayBufferToBase64(ciphertext), nonce: arrayBufferToBase64(nonce), // Note: GCM tag is included in ciphertext in Web Crypto API }, encrypted_aes_key: arrayBufferToBase64(encryptedAesKey), key_algorithm: 'RSA-OAEP-SHA256', payload_algorithm: 'AES-256-GCM', }; // Serialize package to JSON const packageJson = JSON.stringify(encryptedPackage); const packageBytes = stringToArrayBuffer(packageJson); console.log(`Encrypted package size: ${packageBytes.byteLength} bytes`); return packageBytes; }); } /** * Decrypt a response from the secure endpoint */ async decryptResponse(encryptedResponse: ArrayBuffer, payloadId: string): Promise { console.log('Decrypting response...'); // Validate input if (!encryptedResponse || encryptedResponse.byteLength === 0) { throw new Error('Empty encrypted response'); } // Parse encrypted package let packageData: any; try { const packageJson = arrayBufferToString(encryptedResponse); packageData = JSON.parse(packageJson); } catch (error) { throw new Error('Invalid encrypted package format: malformed JSON'); } // Validate package structure const requiredFields = ['version', 'algorithm', 'encrypted_payload', 'encrypted_aes_key']; for (const field of requiredFields) { if (!(field in packageData)) { throw new Error(`Missing required field in encrypted package: ${field}`); } } // Validate encrypted_payload structure const payloadRequired = ['ciphertext', 'nonce']; for (const field of payloadRequired) { if (!(field in packageData.encrypted_payload)) { throw new Error(`Missing field in encrypted_payload: ${field}`); } } // Decrypt AES key with private key try { const encryptedAesKey = base64ToArrayBuffer(packageData.encrypted_aes_key); const privateKey = this.keyManager.getPrivateKey(); const aesKeyBytes = await this.rsa.decryptKey(encryptedAesKey, privateKey); // Use secure memory context for AES key const aesContext = new SecureByteContext(aesKeyBytes, this.secureMemory); const response = await aesContext.use(async (protectedAesKey) => { // Import AES key const aesKey = await this.aes.importKey(protectedAesKey); // Decrypt payload with AES-GCM const ciphertext = base64ToArrayBuffer(packageData.encrypted_payload.ciphertext); const nonce = base64ToArrayBuffer(packageData.encrypted_payload.nonce); const plaintext = await this.aes.decrypt(ciphertext, nonce, aesKey); // Use secure memory for plaintext const plaintextContext = new SecureByteContext(plaintext, this.secureMemory); return await plaintextContext.use(async (protectedPlaintext) => { // Parse decrypted response const responseJson = arrayBufferToString(protectedPlaintext); return JSON.parse(responseJson); }); }); // Add metadata if (!response._metadata) { response._metadata = {}; } response._metadata = { ...response._metadata, payload_id: payloadId, processed_at: packageData.processed_at, is_encrypted: true, encryption_algorithm: packageData.algorithm, }; console.log('Response decrypted successfully'); return response; } catch (error) { // Don't leak specific decryption errors (timing attacks) throw new SecurityError('Decryption failed: integrity check or authentication failed'); } } /** * Send a secure chat completion request to the router */ async sendSecureRequest( payload: object, payloadId: string, apiKey?: string ): Promise { console.log('Sending secure chat completion request...'); // Ensure keys are loaded await this.ensureKeys(); // Step 1: Encrypt the payload const encryptedPayload = await this.encryptPayload(payload); // Step 2: Prepare headers const publicKeyPem = await this.keyManager.getPublicKeyPEM(); const headers: Record = { 'X-Payload-ID': payloadId, 'X-Public-Key': encodeURIComponent(publicKeyPem), 'Content-Type': 'application/octet-stream', }; // Add Authorization header if api_key is provided if (apiKey) { headers['Authorization'] = `Bearer ${apiKey}`; } // Step 3: Send request to router const url = `${this.routerUrl}/v1/chat/secure_completion`; console.log(`Target URL: ${url}`); try { const response = await this.httpClient.post(url, { headers, body: encryptedPayload, timeout: 60000, }); console.log(`HTTP Status: ${response.statusCode}`); if (response.statusCode === 200) { // Step 4: Decrypt the response const decryptedResponse = await this.decryptResponse(response.body, payloadId); return decryptedResponse; } else { // Handle error responses const { handleErrorResponse } = await import('../errors'); throw this.handleErrorResponse(response); } } catch (error) { if (error instanceof Error) { if (error.message === 'Request timeout') { throw new APIConnectionError('Connection to server timed out'); } throw new APIConnectionError(`Failed to connect to router: ${error.message}`); } throw error; } } /** * Handle error HTTP responses */ private handleErrorResponse(response: { statusCode: number; body: ArrayBuffer }): Error { const { AuthenticationError, InvalidRequestError, RateLimitError, ServerError, APIError, } = require('../errors'); let errorData: any = {}; try { const errorJson = arrayBufferToString(response.body); errorData = JSON.parse(errorJson); } catch (e) { // Ignore JSON parse errors } const detail = errorData.detail || 'Unknown error'; switch (response.statusCode) { case 400: return new InvalidRequestError(`Bad request: ${detail}`, 400, errorData); case 401: return new AuthenticationError(`Invalid API key or authentication failed: ${detail}`, 401, errorData); case 404: return new APIError(`Endpoint not found: ${detail}`, 404, errorData); case 429: return new RateLimitError(`Rate limit exceeded: ${detail}`, 429, errorData); case 500: return new ServerError(`Server error: ${detail}`, 500, errorData); default: return new APIError(`Unexpected status code: ${response.statusCode}`, response.statusCode, errorData); } } }