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

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