/** * 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 { 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 { SgxAttestationVerifier } from './attestation/SgxAttestationVerifier'; import { SecurityError, APIConnectionError, APIError, AuthenticationError, InvalidRequestError, RateLimitError, ServerError, ForbiddenError, ServiceUnavailableError, DisposedError, } from '../errors'; import { arrayBufferToBase64, base64ToArrayBuffer, stringToArrayBuffer, arrayBufferToString, } from './crypto/utils'; const VALID_SECURITY_TIERS = ['standard', 'high', 'maximum'] as const; function generateUUID(): string { if (typeof crypto !== 'undefined' && typeof (crypto as Crypto & { randomUUID?: () => string }).randomUUID === 'function') { return (crypto as Crypto & { randomUUID: () => string }).randomUUID(); } // Node.js fallback try { const nodeCrypto = require('crypto') as { randomUUID?: () => string }; if (nodeCrypto.randomUUID) { return nodeCrypto.randomUUID(); } } catch (_e) { /* not in Node.js */ } // Last-resort fallback (not RFC-compliant but collision-resistant enough) const bytes = new Uint8Array(16); if (typeof crypto !== 'undefined' && crypto.getRandomValues) { crypto.getRandomValues(bytes); } bytes[6] = (bytes[6] & 0x0f) | 0x40; bytes[8] = (bytes[8] & 0x3f) | 0x80; const hex = Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join(''); return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; } 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(); 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 readonly maxRetries: number; /** Undefined means ephemeral: keys live in memory only and are never written to disk. */ private readonly keyDir?: string; private _isHttps: boolean = true; /** * Only constructed when an attestation policy is supplied, so existing * callers get byte-for-byte the previous behaviour. */ private readonly attestation?: SgxAttestationVerifier; // Promise-based mutex: serialises concurrent ensureKeys() calls private ensureKeysLock: Promise = Promise.resolve(); constructor(config: ClientConfig = { routerUrl: 'https://api.nomyo.ai' }) { const { routerUrl = 'https://api.nomyo.ai', allowHttp = false, secureMemory = true, keySize = 4096, timeout = 60000, debug = false, keyRotationInterval = 86400000, // 24 hours keyRotationDir, keyRotationPassword, maxRetries = 2, keyDir, attestationPolicy, quoteVerifier, } = config; this.debugMode = debug; this.requestTimeout = timeout; this.keyRotationInterval = keyRotationInterval; this.keyRotationDir = keyRotationDir; this.keyRotationPassword = keyRotationPassword; this.maxRetries = maxRetries; // null and undefined both mean ephemeral (Python's key_dir=None default) this.keyDir = keyDir ?? undefined; this.keySize = keySize; this.allowHttp = allowHttp; this.secureMemory = secureMemory; // 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. ' + 'This is INSECURE and should only be used for local development. ' + 'Man-in-the-middle attacks are possible!' ); } else { if (this.debugMode) console.log('HTTP mode enabled for local development (INSECURE)'); } } // Optional SGX attestation: only construct the verifier when a policy is // provided, so existing callers see unchanged behaviour. if (attestationPolicy !== undefined) { this.attestation = new SgxAttestationVerifier({ routerUrl: this.routerUrl, policy: attestationPolicy, verifier: quoteVerifier, allowHttp, timeout: this.requestTimeout, debug: this.debugMode, }); } // Initialize components 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(); 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 { // Persist rotated keys only where a directory was configured. In // ephemeral mode (the default) rotation stays in memory, so rotation // never silently starts writing private keys to disk. const rotationDir = typeof window === 'undefined' ? (this.keyRotationDir ?? this.keyDir) : undefined; await this.keyManager.rotateKeys({ keySize: this.keySize, saveToFile: rotationDir !== undefined, ...(rotationDir !== undefined && { keyDir: rotationDir }), 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'); } } /** * Generate RSA key pair */ async generateKeys(options: { saveToFile?: boolean; keyDir?: string; password?: string; } = {}): Promise { this.assertNotDisposed(); await this.keyManager.generateKeys({ keySize: this.keySize, ...options, }); } /** * Load existing keys from files (Node.js only) */ async loadKeys( privateKeyPath: string, publicKeyPath?: string, password?: string ): Promise { this.assertNotDisposed(); await this.keyManager.loadKeys( { privateKeyPath, publicKeyPath }, password ); } /** * Ensure keys are loaded, generate if necessary. * Uses a Promise-chain mutex to prevent concurrent key generation races. */ 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; // Ephemeral mode (the default, matching Python's key_dir=None): the key // pair exists only for this session and never touches disk. Also the only // possible mode in browsers, which have no filesystem. if (this.keyDir === undefined || typeof window !== 'undefined') { if (this.debugMode) console.log('Generating ephemeral in-memory key pair (not persisted)'); await this.generateKeys(); return; } // Persistent mode: reuse the key pair in keyDir, or create and save one there. try { const fs = require('fs').promises as { access: (p: string) => Promise }; const path = require('path') as { join: (...p: string[]) => string }; const privateKeyPath = path.join(this.keyDir, 'private_key.pem'); const publicKeyPath = path.join(this.keyDir, 'public_key.pem'); await fs.access(privateKeyPath); await fs.access(publicKeyPath); await this.loadKeys(privateKeyPath, publicKeyPath); if (this.debugMode) console.log(`Loaded existing keys from ${this.keyDir}/`); return; } catch (_error) { if (this.debugMode) console.log(`No existing keys found in ${this.keyDir}/, generating new keys...`); } await this.generateKeys({ saveToFile: true, keyDir: this.keyDir, }); } /** * Fetch server's public key from /pki/public_key endpoint */ async fetchServerPublicKey(): Promise { this.assertNotDisposed(); if (this.debugMode) console.log("Fetching server's public key..."); if (!this._isHttps) { 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:12435", 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: this.requestTimeout }); 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._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)"); } 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). * * The encrypted package format matches the Python server's expected format: * - ciphertext: AES-GCM ciphertext WITHOUT auth tag * - nonce: 12-byte GCM nonce * - tag: 16-byte GCM auth tag (split from Web Crypto output) * - encrypted_aes_key: AES key encrypted with server's RSA public key * * @param serverPublicKeyPem Optional pre-fetched server public key PEM. When * provided the payload is encrypted to exactly this key (used after SGX * attestation so we encrypt to the attested key, with no re-fetch); * otherwise the key is fetched during encryption. */ async encryptPayload(payload: object, serverPublicKeyPem?: string): Promise { this.assertNotDisposed(); if (!payload || typeof payload !== 'object') { throw new Error('Payload must be an object'); } await this.ensureKeys(); const payloadJson = JSON.stringify(payload); const payloadBytes = stringToArrayBuffer(payloadJson); const MAX_PAYLOAD_SIZE = 10 * 1024 * 1024; // 10MB if (payloadBytes.byteLength > MAX_PAYLOAD_SIZE) { throw new Error(`Payload too large: ${payloadBytes.byteLength} bytes (max: ${MAX_PAYLOAD_SIZE})`); } if (this.debugMode) console.log(`Payload size: ${payloadBytes.byteLength} bytes`); if (this.secureMemory) { const context = new SecureByteContext(payloadBytes, true); return await context.use(async (protectedPayload) => { return await this.performEncryption(protectedPayload, serverPublicKeyPem); }); } else { return await this.performEncryption(payloadBytes, serverPublicKeyPem); } } /** * Perform the actual encryption. * * Web Crypto AES-GCM encrypt returns ciphertext || tag (last 16 bytes). * We split the tag out to match Python's cryptography library format * which sends ciphertext and tag as separate fields. */ private async performEncryption( payloadBytes: ArrayBuffer, serverPublicKeyPem?: string ): Promise { const aesKey = await this.aes.generateKey(); const aesKeyBytes = await this.aes.exportKey(aesKey); const aesContext = new SecureByteContext(aesKeyBytes, this.secureMemory); return await aesContext.use(async (protectedAesKey) => { const { ciphertext, nonce } = await this.aes.encrypt(payloadBytes, aesKey); // Web Crypto appends 16-byte GCM auth tag to ciphertext. // Split it to match Python's format (separate ciphertext and tag fields). const TAG_LENGTH = 16; const ciphertextBytes = new Uint8Array(ciphertext); const ciphertextOnly = ciphertextBytes.slice(0, ciphertextBytes.length - TAG_LENGTH); const tag = ciphertextBytes.slice(ciphertextBytes.length - TAG_LENGTH); const keyPem = serverPublicKeyPem ?? await this.fetchServerPublicKey(); const serverPublicKey = await this.rsa.importPublicKey(keyPem); const encryptedAesKey = await this.rsa.encryptKey(protectedAesKey, serverPublicKey); const encryptedPackage = { version: '1.0', algorithm: 'hybrid-aes256-rsa4096', encrypted_payload: { ciphertext: arrayBufferToBase64(ciphertextOnly.buffer), nonce: arrayBufferToBase64(nonce), tag: arrayBufferToBase64(tag.buffer), }, encrypted_aes_key: arrayBufferToBase64(encryptedAesKey), key_algorithm: 'RSA-OAEP-SHA256', payload_algorithm: 'AES-256-GCM', }; const packageJson = JSON.stringify(encryptedPackage); const packageBytes = stringToArrayBuffer(packageJson); if (this.debugMode) console.log(`Encrypted package size: ${packageBytes.byteLength} bytes`); return packageBytes; }); } /** * Decrypt a response from the secure endpoint. * * The server (Python) sends ciphertext and tag as separate fields. * Web Crypto AES-GCM decrypt expects ciphertext || tag concatenated. */ async decryptResponse(encryptedResponse: ArrayBuffer, payloadId: string): Promise> { this.assertNotDisposed(); if (!encryptedResponse || encryptedResponse.byteLength === 0) { throw new Error('Empty encrypted response'); } let packageData: Record; try { const packageJson = arrayBufferToString(encryptedResponse); packageData = JSON.parse(packageJson) as Record; } catch (_error) { throw new Error('Invalid encrypted package format: malformed JSON'); } // Validate top-level 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 version and algorithm to prevent downgrade attacks const SUPPORTED_VERSION = '1.0'; const SUPPORTED_ALGORITHM = 'hybrid-aes256-rsa4096'; if (packageData.version !== SUPPORTED_VERSION) { throw new Error( `Unsupported protocol version: '${String(packageData.version)}'. ` + `Expected: '${SUPPORTED_VERSION}'` ); } if (packageData.algorithm !== SUPPORTED_ALGORITHM) { throw new Error( `Unsupported encryption algorithm: '${String(packageData.algorithm)}'. ` + `Expected: '${SUPPORTED_ALGORITHM}'` ); } const encryptedPayload = packageData.encrypted_payload as Record; if (typeof encryptedPayload !== 'object' || encryptedPayload === null) { throw new Error('Invalid encrypted_payload: must be an object'); } // All three fields required to match Python server format const payloadRequired = ['ciphertext', 'nonce', 'tag']; for (const field of payloadRequired) { if (!(field in encryptedPayload)) { throw new Error(`Missing field in encrypted_payload: ${field}`); } } try { const encryptedAesKey = base64ToArrayBuffer(packageData.encrypted_aes_key as string); const privateKey = this.keyManager.getPrivateKey(); const aesKeyBytes = await this.rsa.decryptKey(encryptedAesKey, privateKey); const aesContext = new SecureByteContext(aesKeyBytes, this.secureMemory); const response = await aesContext.use(async (protectedAesKey) => { const aesKey = await this.aes.importKey(protectedAesKey); const ciphertext = base64ToArrayBuffer(encryptedPayload.ciphertext as string); const nonce = base64ToArrayBuffer(encryptedPayload.nonce as string); const tag = base64ToArrayBuffer(encryptedPayload.tag as string); // Concatenate ciphertext + tag for Web Crypto (expects them joined) const combined = new Uint8Array(ciphertext.byteLength + tag.byteLength); combined.set(new Uint8Array(ciphertext), 0); combined.set(new Uint8Array(tag), ciphertext.byteLength); const plaintext = await this.aes.decrypt(combined.buffer, nonce, aesKey); const plaintextContext = new SecureByteContext(plaintext, this.secureMemory); return await plaintextContext.use(async (protectedPlaintext) => { const responseJson = arrayBufferToString(protectedPlaintext); 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; }); }); // Add metadata const metadata = (response._metadata as Record) ?? {}; response._metadata = { ...metadata, payload_id: payloadId, processed_at: packageData.processed_at, is_encrypted: true, encryption_algorithm: packageData.algorithm, }; if (this.debugMode) 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. * * Retries on transient errors (429, 500, 502, 503, 504, network errors) * with exponential backoff matching the Python SDK's `max_retries` behaviour. * * @param securityTier Optional routing tier: "standard" | "high" | "maximum" */ async sendSecureRequest( payload: object, payloadId: string, apiKey?: string, securityTier?: string ): Promise> { this.assertNotDisposed(); if (this.debugMode) console.log('Sending secure chat completion request...'); // Validate security tier if (securityTier !== undefined) { if (!(VALID_SECURITY_TIERS as readonly string[]).includes(securityTier)) { throw new Error( `Invalid securityTier: '${securityTier}'. ` + `Must be one of: ${VALID_SECURITY_TIERS.join(', ')}` ); } } // 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(); // Step 0: SGX attestation handshake (when enabled). Fetch the server // public key once, verify the enclave + key binding BEFORE building any // plaintext, then encrypt to exactly that attested key. In enforce mode a // failure throws here, so plaintext is never constructed or sent. let serverPublicKeyPem: string | undefined; if (this.attestation !== undefined) { serverPublicKeyPem = await this.fetchServerPublicKey(); const pubkeyDer = await this.spkiDerFromPem(serverPublicKeyPem); await this.attestation.ensureVerified(pubkeyDer, securityTier); } const publicKeyPem = await this.keyManager.getPublicKeyPEM(); const headers: Record = { 'X-Payload-ID': payloadId, 'X-Public-Key': encodeURIComponent(publicKeyPem), 'Content-Type': 'application/octet-stream', }; if (apiKey) { headers['Authorization'] = `Bearer ${apiKey}`; } if (securityTier) { headers['X-Security-Tier'] = securityTier; } const url = `${this.routerUrl}/v1/chat/secure_completion`; if (this.debugMode) console.log(`Target URL: ${url}`); // Retry loop — mirrors Python SDK's max_retries + exponential backoff. // The payload is re-encrypted on every attempt so each attempt gets a // fresh AES key and nonce (the HTTP client zeros the buffer after write). let lastError: Error = new APIConnectionError('Request failed'); for (let attempt = 0; attempt <= this.maxRetries; attempt++) { if (attempt > 0) { const delaySec = Math.pow(2, attempt - 1); // 1 s, 2 s, 4 s, … if (this.debugMode) { console.warn( `Retrying request (attempt ${attempt}/${this.maxRetries}) ` + `after ${delaySec}s...` ); } await new Promise(resolve => setTimeout(resolve, delaySec * 1000)); } // Re-encrypt each attempt (throws non-retryable errors like SecurityError // or DisposedError — let those propagate immediately) const encryptedPayload = await this.encryptPayload(payload, serverPublicKeyPem); let response: { statusCode: number; body: ArrayBuffer }; try { response = await this.httpClient.post(url, { headers, body: encryptedPayload, timeout: this.requestTimeout, }); } catch (error) { // Network / timeout errors from the HTTP client let connError: APIConnectionError; if (error instanceof Error) { connError = error.message === 'Request timeout' ? new APIConnectionError('Connection to server timed out') : new APIConnectionError(`Failed to connect to router: ${error.message}`); } else { connError = new APIConnectionError('Failed to connect to router: unknown error'); } lastError = connError; if (attempt < this.maxRetries) { if (this.debugMode) console.warn(`Network error on attempt ${attempt}: ${connError.message}`); continue; } throw lastError; } if (this.debugMode) console.log(`HTTP Status: ${response.statusCode}`); if (response.statusCode === 200) { return await this.decryptResponse(response.body, payloadId); } const err = this.buildErrorFromResponse(response); if (this.isRetryableError(err) && attempt < this.maxRetries) { if (this.debugMode) { console.warn(`Got retryable status ${response.statusCode}: retrying...`); } lastError = err; continue; } throw err; } throw lastError; } /** * Return true for errors that warrant a retry (transient failures). * Non-retryable errors (auth, bad request, forbidden, etc.) propagate immediately. */ private isRetryableError(error: Error): boolean { if (error instanceof APIConnectionError) return true; if (error instanceof RateLimitError) return true; if (error instanceof ServerError) return true; if (error instanceof ServiceUnavailableError) return true; // 502 Bad Gateway and 504 Gateway Timeout fall through as generic APIError if (error instanceof APIError && (error.statusCode === 502 || error.statusCode === 504)) return true; return false; } /** * Build a typed error from an HTTP error response */ private buildErrorFromResponse(response: { statusCode: number; body: ArrayBuffer }): Error { let errorData: Record = {}; try { const errorJson = arrayBufferToString(response.body); errorData = JSON.parse(errorJson) as Record; } catch (_e) { // Ignore JSON parse errors } // 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: return new InvalidRequestError(`Bad request: ${detail}`, 400, errorData); case 401: return new AuthenticationError( `Invalid API key or authentication failed: ${detail}`, 401, errorData ); case 403: return new ForbiddenError( `Forbidden: ${detail}`, 403, 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); case 503: return new ServiceUnavailableError( `Service unavailable: ${detail}`, 503, errorData ); default: return new APIError( `Unexpected status code: ${response.statusCode} ${detail}`, response.statusCode, errorData ); } } /** * Return the X.509 SubjectPublicKeyInfo DER bytes for the server public key. * * This is exactly the value the SGX report_data binding is computed over * (SHA-512 of these bytes). We re-serialize via the parsed key (rather than * base64-decoding the PEM body directly) so the bytes are canonical SPKI DER * regardless of PEM wrapping. */ private async spkiDerFromPem(pubkeyPem: string): Promise { const key = await this.rsa.importPublicKey(pubkeyPem); return await this.rsa.exportPublicKeyDer(key); } /** * Validate RSA key size (minimum 2048 bits) */ validateRsaKeySize(key: CryptoKey, keyType: 'private' | 'public' = 'private'): void { const algorithm = key.algorithm as RsaHashedKeyAlgorithm; const MIN_KEY_SIZE = 2048; if (algorithm.modulusLength < MIN_KEY_SIZE) { throw new Error( `Key size ${algorithm.modulusLength} is too small. ` + `Minimum recommended size is ${MIN_KEY_SIZE} bits.` ); } if (this.debugMode) console.log(`Valid ${algorithm.modulusLength}-bit RSA ${keyType} key`); } } export { generateUUID };