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
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,22 +112,26 @@ 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);
|
||||
}
|
||||
|
||||
if (this.debug) {
|
||||
console.log(`Valid ${privAlgorithm.modulusLength}-bit RSA private key`);
|
||||
console.log('Keys loaded successfully');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save keys to files (Node.js only)
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// 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',
|
||||
stringToArrayBuffer(password),
|
||||
pwData,
|
||||
'PBKDF2',
|
||||
false,
|
||||
['deriveKey']
|
||||
);
|
||||
|
||||
const salt = crypto.getRandomValues(new Uint8Array(16));
|
||||
// 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: salt,
|
||||
iterations: 100000,
|
||||
hash: 'SHA-256',
|
||||
},
|
||||
{ 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));
|
||||
// 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: iv,
|
||||
},
|
||||
{ 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);
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,7 +48,11 @@ export class SecureByteContext {
|
|||
} finally {
|
||||
// Always zero memory, even if exception occurred
|
||||
if (this.useSecure) {
|
||||
try {
|
||||
this.secureMemory.zeroMemory(this.data);
|
||||
} catch (_zeroErr) {
|
||||
// zeroMemory failure must not mask the original error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue