fix:
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:
parent
76703e2e3e
commit
d9d2ec98db
11 changed files with 582 additions and 129 deletions
|
|
@ -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`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue