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,
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<ChatCompletionResponse> {
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,
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<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' }) {
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<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;
password?: string;
} = {}): Promise<void> {
this.assertNotDisposed();
await this.keyManager.generateKeys({
keySize: this.keySize,
...options,
@ -123,6 +210,7 @@ export class SecureCompletionClient {
publicKeyPath?: string,
password?: string
): Promise<void> {
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<void> {
if (this.keyManager.hasKeys()) {
return;
}
private ensureKeys(): Promise<void> {
let resolve!: () => void;
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)
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<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) {
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<ArrayBuffer> {
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<Record<string, unknown>> {
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<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,
};
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<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
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`);
}
}

View file

@ -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');
}
}

View file

@ -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<void> {
this.zeroKeys();
await this.generateKeys(options);
}
}

View file

@ -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<string> {
// 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');
});
});
});
}
/**

View file

@ -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();
});
}

View file

@ -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
}
}
}
}

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 */
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;
}

View file

@ -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', () => {

View file

@ -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<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)', () => {
// 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<void> }).generateKeys();
// Mock encryptPayload to skip actual encryption
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));
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<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));
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<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));
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<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));
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<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));
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<void> }).generateKeys();
await (clientB as unknown as { generateKeys: () => Promise<void> }).generateKeys();
const payload = { model: 'test', messages: [{ role: 'user', content: 'hi' }] };
// clientA encrypts, clientB decrypts (simulating server responding)
// We can only test the client-side encrypt → client-side decrypt roundtrip
// because the server uses its own key pair to encrypt the response.
// Directly test encryptPayload → decryptResponse using the SAME client's keys
// (as the server would decrypt with its private key and re-encrypt with client's public key)
// For a full roundtrip test we encrypt with clientB's public key and decrypt with clientB's private key.
const serverPublicKeyPem = await (clientB as unknown as { keyManager: { getPublicKeyPEM: () => Promise<string> } }).keyManager.getPublicKeyPEM();
// Mock fetchServerPublicKey on clientA to return clientB's public key
jest.spyOn(clientA as unknown as { fetchServerPublicKey: () => Promise<string> }, 'fetchServerPublicKey')
.mockResolvedValue(serverPublicKeyPem);
const encrypted = await clientA.encryptPayload(payload);
expect(encrypted.byteLength).toBeGreaterThan(0);
// Now simulate clientB decrypting (server decrypts the payload — we can only test
// structure here since decryptResponse expects server-format encrypted response)
const pkg = JSON.parse(new TextDecoder().decode(encrypted));
expect(pkg.version).toBe('1.0');
expect(pkg.algorithm).toBe('hybrid-aes256-rsa4096');
expect(pkg.encrypted_payload.ciphertext).toBeTruthy();
expect(pkg.encrypted_payload.nonce).toBeTruthy();
expect(pkg.encrypted_payload.tag).toBeTruthy(); // tag must be present
expect(pkg.encrypted_payload.tag).toBeTruthy();
expect(pkg.encrypted_aes_key).toBeTruthy();
clientA.dispose();
clientB.dispose();
}, 60000);
});