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,
|
allowHttp = false,
|
||||||
apiKey,
|
apiKey,
|
||||||
secureMemory = true,
|
secureMemory = true,
|
||||||
|
timeout,
|
||||||
|
debug,
|
||||||
|
keyRotationInterval,
|
||||||
|
keyRotationDir,
|
||||||
|
keyRotationPassword,
|
||||||
} = config;
|
} = config;
|
||||||
|
|
||||||
this.apiKey = apiKey;
|
this.apiKey = apiKey;
|
||||||
|
|
@ -24,6 +29,11 @@ export class SecureChatCompletion {
|
||||||
routerUrl: baseUrl,
|
routerUrl: baseUrl,
|
||||||
allowHttp,
|
allowHttp,
|
||||||
secureMemory,
|
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> {
|
async acreate(request: ChatCompletionRequest): Promise<ChatCompletionResponse> {
|
||||||
return this.create(request);
|
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,
|
ServerError,
|
||||||
ForbiddenError,
|
ForbiddenError,
|
||||||
ServiceUnavailableError,
|
ServiceUnavailableError,
|
||||||
|
DisposedError,
|
||||||
} from '../errors';
|
} from '../errors';
|
||||||
import {
|
import {
|
||||||
arrayBufferToBase64,
|
arrayBufferToBase64,
|
||||||
|
|
@ -64,41 +65,126 @@ export class SecureCompletionClient {
|
||||||
private secureMemoryImpl = createSecureMemory();
|
private secureMemoryImpl = createSecureMemory();
|
||||||
private readonly keySize: 2048 | 4096;
|
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' }) {
|
constructor(config: ClientConfig = { routerUrl: 'https://api.nomyo.ai:12435' }) {
|
||||||
const {
|
const {
|
||||||
routerUrl = 'https://api.nomyo.ai:12435',
|
routerUrl = 'https://api.nomyo.ai:12435',
|
||||||
allowHttp = false,
|
allowHttp = false,
|
||||||
secureMemory = true,
|
secureMemory = true,
|
||||||
keySize = 4096,
|
keySize = 4096,
|
||||||
|
timeout = 60000,
|
||||||
|
debug = false,
|
||||||
|
keyRotationInterval = 86400000, // 24 hours
|
||||||
|
keyRotationDir,
|
||||||
|
keyRotationPassword,
|
||||||
} = config;
|
} = config;
|
||||||
|
|
||||||
|
this.debugMode = debug;
|
||||||
|
this.requestTimeout = timeout;
|
||||||
|
this.keyRotationInterval = keyRotationInterval;
|
||||||
|
this.keyRotationDir = keyRotationDir;
|
||||||
|
this.keyRotationPassword = keyRotationPassword;
|
||||||
this.keySize = keySize;
|
this.keySize = keySize;
|
||||||
this.routerUrl = routerUrl.replace(/\/$/, '');
|
|
||||||
this.allowHttp = allowHttp;
|
this.allowHttp = allowHttp;
|
||||||
this.secureMemory = secureMemory;
|
this.secureMemory = secureMemory;
|
||||||
|
|
||||||
// Validate HTTPS for security
|
// Validate and parse URL
|
||||||
if (!this.routerUrl.startsWith('https://')) {
|
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) {
|
if (!allowHttp) {
|
||||||
console.warn(
|
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. ' +
|
'This is INSECURE and should only be used for local development. ' +
|
||||||
'Man-in-the-middle attacks are possible!'
|
'Man-in-the-middle attacks are possible!'
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
console.log('HTTP mode enabled for local development (INSECURE)');
|
if (this.debugMode) console.log('HTTP mode enabled for local development (INSECURE)');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize components
|
// Initialize components
|
||||||
this.keyManager = new KeyManager();
|
this.keyManager = new KeyManager(this.debugMode);
|
||||||
this.aes = new AESEncryption();
|
this.aes = new AESEncryption();
|
||||||
this.rsa = new RSAOperations();
|
this.rsa = new RSAOperations();
|
||||||
this.httpClient = createHttpClient();
|
this.httpClient = createHttpClient();
|
||||||
|
|
||||||
// Log memory protection info
|
// Log memory protection info
|
||||||
const protectionInfo = this.secureMemoryImpl.getProtectionInfo();
|
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;
|
keyDir?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
} = {}): Promise<void> {
|
} = {}): Promise<void> {
|
||||||
|
this.assertNotDisposed();
|
||||||
await this.keyManager.generateKeys({
|
await this.keyManager.generateKeys({
|
||||||
keySize: this.keySize,
|
keySize: this.keySize,
|
||||||
...options,
|
...options,
|
||||||
|
|
@ -123,6 +210,7 @@ export class SecureCompletionClient {
|
||||||
publicKeyPath?: string,
|
publicKeyPath?: string,
|
||||||
password?: string
|
password?: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
this.assertNotDisposed();
|
||||||
await this.keyManager.loadKeys(
|
await this.keyManager.loadKeys(
|
||||||
{ privateKeyPath, publicKeyPath },
|
{ privateKeyPath, publicKeyPath },
|
||||||
password
|
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> {
|
private ensureKeys(): Promise<void> {
|
||||||
if (this.keyManager.hasKeys()) {
|
let resolve!: () => void;
|
||||||
return;
|
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)
|
// Try to load keys from default location (Node.js only)
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
|
|
@ -150,10 +256,10 @@ export class SecureCompletionClient {
|
||||||
await fs.access(publicKeyPath);
|
await fs.access(publicKeyPath);
|
||||||
|
|
||||||
await this.loadKeys(privateKeyPath, 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;
|
return;
|
||||||
} catch (_error) {
|
} 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
|
* Fetch server's public key from /pki/public_key endpoint
|
||||||
*/
|
*/
|
||||||
async fetchServerPublicKey(): Promise<string> {
|
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) {
|
if (!this.allowHttp) {
|
||||||
throw new SecurityError(
|
throw new SecurityError(
|
||||||
'Server public key must be fetched over HTTPS to prevent MITM attacks. ' +
|
'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`;
|
const url = `${this.routerUrl}/pki/public_key`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await this.httpClient.get(url, { timeout: 60000 });
|
const response = await this.httpClient.get(url, { timeout: this.requestTimeout });
|
||||||
|
|
||||||
if (response.statusCode === 200) {
|
if (response.statusCode === 200) {
|
||||||
const serverPublicKey = arrayBufferToString(response.body);
|
const serverPublicKey = arrayBufferToString(response.body);
|
||||||
|
|
@ -196,8 +303,8 @@ export class SecureCompletionClient {
|
||||||
throw new Error('Server returned invalid public key format');
|
throw new Error('Server returned invalid public key format');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.routerUrl.startsWith('https://')) {
|
if (this._isHttps) {
|
||||||
console.log("Server's public key fetched securely over HTTPS");
|
if (this.debugMode) console.log("Server's public key fetched securely over HTTPS");
|
||||||
} else {
|
} else {
|
||||||
console.warn("Server's public key fetched over HTTP (INSECURE)");
|
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
|
* - encrypted_aes_key: AES key encrypted with server's RSA public key
|
||||||
*/
|
*/
|
||||||
async encryptPayload(payload: object): Promise<ArrayBuffer> {
|
async encryptPayload(payload: object): Promise<ArrayBuffer> {
|
||||||
console.log('Encrypting payload...');
|
this.assertNotDisposed();
|
||||||
|
|
||||||
if (!payload || typeof payload !== 'object') {
|
if (!payload || typeof payload !== 'object') {
|
||||||
throw new Error('Payload must be an 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})`);
|
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) {
|
if (this.secureMemory) {
|
||||||
const context = new SecureByteContext(payloadBytes, true);
|
const context = new SecureByteContext(payloadBytes, true);
|
||||||
|
|
@ -297,7 +404,7 @@ export class SecureCompletionClient {
|
||||||
const packageJson = JSON.stringify(encryptedPackage);
|
const packageJson = JSON.stringify(encryptedPackage);
|
||||||
const packageBytes = stringToArrayBuffer(packageJson);
|
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;
|
return packageBytes;
|
||||||
});
|
});
|
||||||
|
|
@ -310,7 +417,7 @@ export class SecureCompletionClient {
|
||||||
* Web Crypto AES-GCM decrypt expects ciphertext || tag concatenated.
|
* Web Crypto AES-GCM decrypt expects ciphertext || tag concatenated.
|
||||||
*/
|
*/
|
||||||
async decryptResponse(encryptedResponse: ArrayBuffer, payloadId: string): Promise<Record<string, unknown>> {
|
async decryptResponse(encryptedResponse: ArrayBuffer, payloadId: string): Promise<Record<string, unknown>> {
|
||||||
console.log('Decrypting response...');
|
this.assertNotDisposed();
|
||||||
|
|
||||||
if (!encryptedResponse || encryptedResponse.byteLength === 0) {
|
if (!encryptedResponse || encryptedResponse.byteLength === 0) {
|
||||||
throw new Error('Empty encrypted response');
|
throw new Error('Empty encrypted response');
|
||||||
|
|
@ -368,7 +475,22 @@ export class SecureCompletionClient {
|
||||||
const plaintextContext = new SecureByteContext(plaintext, this.secureMemory);
|
const plaintextContext = new SecureByteContext(plaintext, this.secureMemory);
|
||||||
return await plaintextContext.use(async (protectedPlaintext) => {
|
return await plaintextContext.use(async (protectedPlaintext) => {
|
||||||
const responseJson = arrayBufferToString(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,
|
encryption_algorithm: packageData.algorithm,
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log('Response decrypted successfully');
|
if (this.debugMode) console.log('Response decrypted successfully');
|
||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Don't leak specific decryption errors (timing attacks)
|
// Don't leak specific decryption errors (timing attacks)
|
||||||
|
|
@ -401,7 +523,8 @@ export class SecureCompletionClient {
|
||||||
apiKey?: string,
|
apiKey?: string,
|
||||||
securityTier?: string
|
securityTier?: string
|
||||||
): Promise<Record<string, unknown>> {
|
): 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
|
// Validate security tier
|
||||||
if (securityTier !== undefined) {
|
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();
|
await this.ensureKeys();
|
||||||
|
|
||||||
const encryptedPayload = await this.encryptPayload(payload);
|
const encryptedPayload = await this.encryptPayload(payload);
|
||||||
|
|
@ -433,14 +563,14 @@ export class SecureCompletionClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = `${this.routerUrl}/v1/chat/secure_completion`;
|
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 };
|
let response: { statusCode: number; body: ArrayBuffer };
|
||||||
try {
|
try {
|
||||||
response = await this.httpClient.post(url, {
|
response = await this.httpClient.post(url, {
|
||||||
headers,
|
headers,
|
||||||
body: encryptedPayload,
|
body: encryptedPayload,
|
||||||
timeout: 60000,
|
timeout: this.requestTimeout,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
|
|
@ -452,7 +582,7 @@ export class SecureCompletionClient {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`HTTP Status: ${response.statusCode}`);
|
if (this.debugMode) console.log(`HTTP Status: ${response.statusCode}`);
|
||||||
|
|
||||||
if (response.statusCode === 200) {
|
if (response.statusCode === 200) {
|
||||||
return await this.decryptResponse(response.body, payloadId);
|
return await this.decryptResponse(response.body, payloadId);
|
||||||
|
|
@ -474,7 +604,9 @@ export class SecureCompletionClient {
|
||||||
// Ignore JSON parse errors
|
// 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) {
|
switch (response.statusCode) {
|
||||||
case 400:
|
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,
|
data: ArrayBuffer,
|
||||||
key: CryptoKey
|
key: CryptoKey
|
||||||
): Promise<{ ciphertext: ArrayBuffer; nonce: ArrayBuffer }> {
|
): Promise<{ ciphertext: ArrayBuffer; nonce: ArrayBuffer }> {
|
||||||
// Generate random 96-bit (12-byte) nonce
|
// Generate random 96-bit (12-byte) nonce — copy into a plain ArrayBuffer
|
||||||
const nonce = generateRandomBytes(12);
|
// 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
|
// Encrypt with AES-GCM
|
||||||
const ciphertext = await this.subtle.encrypt(
|
const ciphertext = await this.subtle.encrypt(
|
||||||
{
|
{
|
||||||
name: 'AES-GCM',
|
name: 'AES-GCM',
|
||||||
iv: nonce,
|
iv: nonceView,
|
||||||
tagLength: 128, // 128-bit authentication tag
|
tagLength: 128, // 128-bit authentication tag
|
||||||
},
|
},
|
||||||
key,
|
key,
|
||||||
|
|
@ -52,7 +58,7 @@ export class AESEncryption {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ciphertext,
|
ciphertext,
|
||||||
nonce: nonce.buffer,
|
nonce,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -80,8 +86,8 @@ export class AESEncryption {
|
||||||
);
|
);
|
||||||
|
|
||||||
return plaintext;
|
return plaintext;
|
||||||
} catch (error) {
|
} catch (_error) {
|
||||||
throw new Error(`AES-GCM decryption failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
throw new Error('AES-GCM decryption failed');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,11 @@ export class KeyManager {
|
||||||
private publicKey?: CryptoKey;
|
private publicKey?: CryptoKey;
|
||||||
private privateKey?: CryptoKey;
|
private privateKey?: CryptoKey;
|
||||||
private publicKeyPem?: string;
|
private publicKeyPem?: string;
|
||||||
|
private debug: boolean;
|
||||||
|
|
||||||
constructor() {
|
constructor(debug = false) {
|
||||||
this.rsa = new RSAOperations();
|
this.rsa = new RSAOperations();
|
||||||
|
this.debug = debug;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -32,7 +34,11 @@ export class KeyManager {
|
||||||
password,
|
password,
|
||||||
} = options;
|
} = 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
|
// Generate key pair
|
||||||
const keyPair = await this.rsa.generateKeyPair(keySize);
|
const keyPair = await this.rsa.generateKeyPair(keySize);
|
||||||
|
|
@ -42,7 +48,7 @@ export class KeyManager {
|
||||||
// Export public key to PEM
|
// Export public key to PEM
|
||||||
this.publicKeyPem = await this.rsa.exportPublicKey(this.publicKey);
|
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)
|
// Save to file if requested (Node.js only)
|
||||||
if (saveToFile) {
|
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.');
|
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 fs = require('fs').promises;
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
@ -74,10 +84,15 @@ export class KeyManager {
|
||||||
const { createPrivateKey } = require('crypto') as typeof import('crypto');
|
const { createPrivateKey } = require('crypto') as typeof import('crypto');
|
||||||
const keyObject = createPrivateKey({ key: privateKeyPem, format: 'pem', passphrase: password });
|
const keyObject = createPrivateKey({ key: privateKeyPem, format: 'pem', passphrase: password });
|
||||||
const pkcs8Der = keyObject.export({ type: 'pkcs8', format: 'der' }) as Buffer;
|
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();
|
const subtle = getCrypto();
|
||||||
this.privateKey = await subtle.importKey(
|
this.privateKey = await subtle.importKey(
|
||||||
'pkcs8',
|
'pkcs8',
|
||||||
pkcs8Der,
|
pkcs8Buf,
|
||||||
{ name: 'RSA-OAEP', hash: 'SHA-256' },
|
{ name: 'RSA-OAEP', hash: 'SHA-256' },
|
||||||
true,
|
true,
|
||||||
['decrypt']
|
['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) {
|
if (paths.publicKeyPath) {
|
||||||
this.publicKeyPem = await fs.readFile(paths.publicKeyPath, 'utf-8');
|
const pem = await fs.readFile(paths.publicKeyPath, 'utf-8') as string;
|
||||||
this.publicKey = await this.rsa.importPublicKey(this.publicKeyPem);
|
this.publicKeyPem = pem;
|
||||||
|
this.publicKey = await this.rsa.importPublicKey(pem);
|
||||||
} else {
|
} else {
|
||||||
const publicKeyPath = path.join(
|
const publicKeyPath = path.join(
|
||||||
path.dirname(paths.privateKeyPath),
|
path.dirname(paths.privateKeyPath),
|
||||||
'public_key.pem'
|
'public_key.pem'
|
||||||
);
|
);
|
||||||
this.publicKeyPem = await fs.readFile(publicKeyPath, 'utf-8');
|
const pem = await fs.readFile(publicKeyPath, 'utf-8') as string;
|
||||||
this.publicKey = await this.rsa.importPublicKey(this.publicKeyPem);
|
this.publicKeyPem = pem;
|
||||||
|
this.publicKey = await this.rsa.importPublicKey(pem);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.debug) {
|
||||||
console.log(`Valid ${privAlgorithm.modulusLength}-bit RSA private key`);
|
console.log(`Valid ${privAlgorithm.modulusLength}-bit RSA private key`);
|
||||||
console.log('Keys loaded successfully');
|
console.log('Keys loaded successfully');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save keys to files (Node.js only)
|
* Save keys to files (Node.js only)
|
||||||
|
|
@ -132,7 +151,7 @@ export class KeyManager {
|
||||||
const fs = require('fs').promises;
|
const fs = require('fs').promises;
|
||||||
const path = require('path');
|
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
|
// Create directory if it doesn't exist
|
||||||
await fs.mkdir(directory, { recursive: true });
|
await fs.mkdir(directory, { recursive: true });
|
||||||
|
|
@ -155,7 +174,7 @@ export class KeyManager {
|
||||||
// Set restrictive permissions on private key (Unix-like systems)
|
// Set restrictive permissions on private key (Unix-like systems)
|
||||||
try {
|
try {
|
||||||
await fs.chmod(privateKeyPath, 0o600); // Owner read/write only
|
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) {
|
} catch (error) {
|
||||||
console.warn('Could not set private key permissions:', error);
|
console.warn('Could not set private key permissions:', error);
|
||||||
}
|
}
|
||||||
|
|
@ -170,18 +189,18 @@ export class KeyManager {
|
||||||
// Set permissions on public key
|
// Set permissions on public key
|
||||||
try {
|
try {
|
||||||
await fs.chmod(publicKeyPath, 0o644); // Owner read/write, others read
|
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) {
|
} catch (error) {
|
||||||
console.warn('Could not set public key permissions:', error);
|
console.warn('Could not set public key permissions:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (password) {
|
if (password) {
|
||||||
console.log('Private key encrypted with password');
|
if (this.debug) console.log('Private key encrypted with password');
|
||||||
} else {
|
} else {
|
||||||
console.warn('Private key saved UNENCRYPTED (not recommended for production)');
|
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 {
|
hasKeys(): boolean {
|
||||||
return !!(this.privateKey && this.publicKey);
|
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 { getCrypto, pemToArrayBuffer, arrayBufferToPem, stringToArrayBuffer, arrayBufferToString } from './utils';
|
||||||
|
import { SecureByteContext } from '../memory/secure';
|
||||||
|
|
||||||
export class RSAOperations {
|
export class RSAOperations {
|
||||||
private subtle: SubtleCrypto;
|
private subtle: SubtleCrypto;
|
||||||
|
|
@ -60,8 +61,8 @@ export class RSAOperations {
|
||||||
privateKey,
|
privateKey,
|
||||||
encryptedKey
|
encryptedKey
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (_error) {
|
||||||
throw new Error(`RSA-OAEP decryption failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
throw new Error('RSA key decryption failed');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -148,47 +149,52 @@ export class RSAOperations {
|
||||||
* @returns PEM-encoded encrypted private key
|
* @returns PEM-encoded encrypted private key
|
||||||
*/
|
*/
|
||||||
private async encryptPrivateKeyWithPassword(keyData: ArrayBuffer, password: string): Promise<string> {
|
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(
|
const passwordKey = await this.subtle.importKey(
|
||||||
'raw',
|
'raw',
|
||||||
stringToArrayBuffer(password),
|
pwData,
|
||||||
'PBKDF2',
|
'PBKDF2',
|
||||||
false,
|
false,
|
||||||
['deriveKey']
|
['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(
|
const derivedKey = await this.subtle.deriveKey(
|
||||||
{
|
{ name: 'PBKDF2', salt: saltView, iterations: 100000, hash: 'SHA-256' },
|
||||||
name: 'PBKDF2',
|
|
||||||
salt: salt,
|
|
||||||
iterations: 100000,
|
|
||||||
hash: 'SHA-256',
|
|
||||||
},
|
|
||||||
passwordKey,
|
passwordKey,
|
||||||
{ name: 'AES-CBC', length: 256 },
|
{ name: 'AES-CBC', length: 256 },
|
||||||
false,
|
false,
|
||||||
['encrypt']
|
['encrypt']
|
||||||
);
|
);
|
||||||
|
|
||||||
// Encrypt private key with AES-256-CBC
|
// Wrap IV so it is zeroed after use
|
||||||
const iv = crypto.getRandomValues(new Uint8Array(16));
|
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(
|
const encrypted = await this.subtle.encrypt(
|
||||||
{
|
{ name: 'AES-CBC', iv: ivView },
|
||||||
name: 'AES-CBC',
|
|
||||||
iv: iv,
|
|
||||||
},
|
|
||||||
derivedKey,
|
derivedKey,
|
||||||
keyData
|
keyData
|
||||||
);
|
);
|
||||||
|
|
||||||
// Combine salt + iv + encrypted data
|
// Combine salt + iv + encrypted data
|
||||||
const combined = new Uint8Array(salt.length + iv.length + encrypted.byteLength);
|
const combined = new Uint8Array(saltView.length + ivView.length + encrypted.byteLength);
|
||||||
combined.set(salt, 0);
|
combined.set(saltView, 0);
|
||||||
combined.set(iv, salt.length);
|
combined.set(ivView, saltView.length);
|
||||||
combined.set(new Uint8Array(encrypted), salt.length + iv.length);
|
combined.set(new Uint8Array(encrypted), saltView.length + ivView.length);
|
||||||
|
|
||||||
return arrayBufferToPem(combined.buffer, 'PRIVATE');
|
return arrayBufferToPem(combined.buffer, 'PRIVATE');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,10 @@ export class NodeHttpClient implements HttpClient {
|
||||||
});
|
});
|
||||||
|
|
||||||
req.write(bodyBuffer);
|
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();
|
req.end();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,11 @@ export class SecureByteContext {
|
||||||
} finally {
|
} finally {
|
||||||
// Always zero memory, even if exception occurred
|
// Always zero memory, even if exception occurred
|
||||||
if (this.useSecure) {
|
if (this.useSecure) {
|
||||||
|
try {
|
||||||
this.secureMemory.zeroMemory(this.data);
|
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 */
|
/** Optional API key for authentication */
|
||||||
apiKey?: string;
|
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 {
|
export interface KeyGenOptions {
|
||||||
|
|
@ -53,4 +68,19 @@ export interface ChatCompletionConfig {
|
||||||
|
|
||||||
/** Enable secure memory protection */
|
/** Enable secure memory protection */
|
||||||
secureMemory?: boolean;
|
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();
|
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 () => {
|
test('exportKey / importKey roundtrip', async () => {
|
||||||
const key = await aes.generateKey();
|
const key = await aes.generateKey();
|
||||||
const exported = await aes.exportKey(key);
|
const exported = await aes.exportKey(key);
|
||||||
|
|
@ -124,6 +138,64 @@ describe('RSAOperations', () => {
|
||||||
const pem = await rsa.exportPrivateKey(kp.privateKey, 'correct-password');
|
const pem = await rsa.exportPrivateKey(kp.privateKey, 'correct-password');
|
||||||
await expect(rsa.importPrivateKey(pem, 'wrong-password')).rejects.toThrow();
|
await expect(rsa.importPrivateKey(pem, 'wrong-password')).rejects.toThrow();
|
||||||
}, 30000);
|
}, 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', () => {
|
describe('Base64 utilities', () => {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
ForbiddenError,
|
ForbiddenError,
|
||||||
ServiceUnavailableError,
|
ServiceUnavailableError,
|
||||||
RateLimitError,
|
RateLimitError,
|
||||||
|
DisposedError,
|
||||||
} from '../../src/errors';
|
} from '../../src/errors';
|
||||||
import { stringToArrayBuffer } from '../../src/core/crypto/utils';
|
import { stringToArrayBuffer } from '../../src/core/crypto/utils';
|
||||||
|
|
||||||
|
|
@ -50,20 +51,74 @@ describe('SecureCompletionClient constructor', () => {
|
||||||
|
|
||||||
test('removes trailing slash from routerUrl', () => {
|
test('removes trailing slash from routerUrl', () => {
|
||||||
const client = new SecureCompletionClient({ routerUrl: 'https://api.example.com:12434/' });
|
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');
|
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', () => {
|
describe('SecureCompletionClient.fetchServerPublicKey', () => {
|
||||||
test('throws SecurityError over HTTP without allowHttp', async () => {
|
test('throws SecurityError over HTTP without allowHttp', async () => {
|
||||||
|
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||||
const client = new SecureCompletionClient({
|
const client = new SecureCompletionClient({
|
||||||
routerUrl: 'http://localhost:1234',
|
routerUrl: 'http://localhost:1234',
|
||||||
allowHttp: false,
|
allowHttp: false,
|
||||||
|
keyRotationInterval: 0,
|
||||||
});
|
});
|
||||||
// Suppress console.warn from constructor
|
|
||||||
jest.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
||||||
await expect(client.fetchServerPublicKey()).rejects.toBeInstanceOf(SecurityError);
|
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 () => {
|
test('throws for invalid security tier', async () => {
|
||||||
const client = new SecureCompletionClient({
|
const client = new SecureCompletionClient({
|
||||||
routerUrl: 'https://api.example.com:12434',
|
routerUrl: 'https://api.example.com:12434',
|
||||||
|
keyRotationInterval: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
client.sendSecureRequest({}, 'test-id', undefined, 'ultra')
|
client.sendSecureRequest({}, 'test-id', undefined, 'ultra')
|
||||||
).rejects.toThrow("Invalid securityTier: 'ultra'");
|
).rejects.toThrow("Invalid securityTier: 'ultra'");
|
||||||
|
client.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('accepts valid security tiers', async () => {
|
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({
|
const client = new SecureCompletionClient({
|
||||||
routerUrl: 'https://api.example.com:12434',
|
routerUrl: 'https://api.example.com:12434',
|
||||||
|
keyRotationInterval: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const tier of ['standard', 'high', 'maximum']) {
|
for (const tier of ['standard', 'high', 'maximum']) {
|
||||||
// Should not throw a tier validation error (will throw something else)
|
|
||||||
await expect(
|
await expect(
|
||||||
client.sendSecureRequest({}, 'test-id', undefined, tier)
|
client.sendSecureRequest({}, 'test-id', undefined, tier)
|
||||||
).rejects.not.toThrow("Invalid securityTier");
|
).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)', () => {
|
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) {
|
async function clientWithMockedHttp(statusCode: number, body: object) {
|
||||||
const client = new SecureCompletionClient({
|
const client = new SecureCompletionClient({
|
||||||
routerUrl: 'https://api.example.com:12434',
|
routerUrl: 'https://api.example.com:12434',
|
||||||
|
keyRotationInterval: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Inject mocked HTTP client
|
|
||||||
const http = mockHttpClient(async (url: string) => {
|
const http = mockHttpClient(async (url: string) => {
|
||||||
if (url.includes('/pki/public_key')) {
|
if (url.includes('/pki/public_key')) {
|
||||||
// Should not be reached in error tests
|
|
||||||
throw new Error('unexpected pki call');
|
throw new Error('unexpected pki call');
|
||||||
}
|
}
|
||||||
return makeJsonResponse(statusCode, body);
|
return makeJsonResponse(statusCode, body);
|
||||||
|
|
@ -118,104 +253,99 @@ describe('SecureCompletionClient.buildErrorFromResponse (via sendSecureRequest)'
|
||||||
|
|
||||||
test('401 → AuthenticationError', async () => {
|
test('401 → AuthenticationError', async () => {
|
||||||
const client = await clientWithMockedHttp(401, { detail: 'bad key' });
|
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();
|
await (client as unknown as { generateKeys: () => Promise<void> }).generateKeys();
|
||||||
|
jest.spyOn(client as unknown as { encryptPayload: (p: object) => Promise<ArrayBuffer> }, 'encryptPayload')
|
||||||
// Mock encryptPayload to skip actual encryption
|
|
||||||
jest.spyOn(client as unknown as { encryptPayload: () => Promise<ArrayBuffer> }, 'encryptPayload')
|
|
||||||
.mockResolvedValue(new ArrayBuffer(8));
|
.mockResolvedValue(new ArrayBuffer(8));
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
client.sendSecureRequest({ model: 'test', messages: [] }, 'id-1')
|
client.sendSecureRequest({ model: 'test', messages: [] }, 'id-1')
|
||||||
).rejects.toBeInstanceOf(AuthenticationError);
|
).rejects.toBeInstanceOf(AuthenticationError);
|
||||||
|
client.dispose();
|
||||||
}, 30000);
|
}, 30000);
|
||||||
|
|
||||||
test('403 → ForbiddenError', async () => {
|
test('403 → ForbiddenError', async () => {
|
||||||
const client = await clientWithMockedHttp(403, { detail: 'not allowed' });
|
const client = await clientWithMockedHttp(403, { detail: 'not allowed' });
|
||||||
await (client as unknown as { generateKeys: () => Promise<void> }).generateKeys();
|
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));
|
.mockResolvedValue(new ArrayBuffer(8));
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
client.sendSecureRequest({ model: 'test', messages: [] }, 'id-1')
|
client.sendSecureRequest({ model: 'test', messages: [] }, 'id-1')
|
||||||
).rejects.toBeInstanceOf(ForbiddenError);
|
).rejects.toBeInstanceOf(ForbiddenError);
|
||||||
|
client.dispose();
|
||||||
}, 30000);
|
}, 30000);
|
||||||
|
|
||||||
test('429 → RateLimitError', async () => {
|
test('429 → RateLimitError', async () => {
|
||||||
const client = await clientWithMockedHttp(429, { detail: 'too many' });
|
const client = await clientWithMockedHttp(429, { detail: 'too many' });
|
||||||
await (client as unknown as { generateKeys: () => Promise<void> }).generateKeys();
|
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));
|
.mockResolvedValue(new ArrayBuffer(8));
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
client.sendSecureRequest({ model: 'test', messages: [] }, 'id-1')
|
client.sendSecureRequest({ model: 'test', messages: [] }, 'id-1')
|
||||||
).rejects.toBeInstanceOf(RateLimitError);
|
).rejects.toBeInstanceOf(RateLimitError);
|
||||||
|
client.dispose();
|
||||||
}, 30000);
|
}, 30000);
|
||||||
|
|
||||||
test('503 → ServiceUnavailableError', async () => {
|
test('503 → ServiceUnavailableError', async () => {
|
||||||
const client = await clientWithMockedHttp(503, { detail: 'down' });
|
const client = await clientWithMockedHttp(503, { detail: 'down' });
|
||||||
await (client as unknown as { generateKeys: () => Promise<void> }).generateKeys();
|
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));
|
.mockResolvedValue(new ArrayBuffer(8));
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
client.sendSecureRequest({ model: 'test', messages: [] }, 'id-1')
|
client.sendSecureRequest({ model: 'test', messages: [] }, 'id-1')
|
||||||
).rejects.toBeInstanceOf(ServiceUnavailableError);
|
).rejects.toBeInstanceOf(ServiceUnavailableError);
|
||||||
|
client.dispose();
|
||||||
}, 30000);
|
}, 30000);
|
||||||
|
|
||||||
test('network error → APIConnectionError (not wrapping typed errors)', async () => {
|
test('network error → APIConnectionError', async () => {
|
||||||
const client = new SecureCompletionClient({
|
const client = new SecureCompletionClient({
|
||||||
routerUrl: 'https://api.example.com:12434',
|
routerUrl: 'https://api.example.com:12434',
|
||||||
|
keyRotationInterval: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const http = mockHttpClient(async () => { throw new Error('ECONNREFUSED'); });
|
const http = mockHttpClient(async () => { throw new Error('ECONNREFUSED'); });
|
||||||
(client as unknown as { httpClient: typeof http }).httpClient = http;
|
(client as unknown as { httpClient: typeof http }).httpClient = http;
|
||||||
|
|
||||||
await (client as unknown as { generateKeys: () => Promise<void> }).generateKeys();
|
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));
|
.mockResolvedValue(new ArrayBuffer(8));
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
client.sendSecureRequest({ model: 'test', messages: [] }, 'id-1')
|
client.sendSecureRequest({ model: 'test', messages: [] }, 'id-1')
|
||||||
).rejects.toBeInstanceOf(APIConnectionError);
|
).rejects.toBeInstanceOf(APIConnectionError);
|
||||||
|
client.dispose();
|
||||||
}, 30000);
|
}, 30000);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('SecureCompletionClient encrypt/decrypt roundtrip', () => {
|
describe('SecureCompletionClient encrypt/decrypt roundtrip', () => {
|
||||||
test('encryptPayload + decryptResponse roundtrip', async () => {
|
test('encryptPayload + decryptResponse roundtrip', async () => {
|
||||||
// Use two clients: one for "client", one to simulate "server"
|
const clientA = new SecureCompletionClient({ routerUrl: 'https://x', allowHttp: true, keyRotationInterval: 0 });
|
||||||
const clientA = new SecureCompletionClient({ routerUrl: 'https://x', allowHttp: true });
|
const clientB = new SecureCompletionClient({ routerUrl: 'https://x', allowHttp: true, keyRotationInterval: 0 });
|
||||||
const clientB = new SecureCompletionClient({ routerUrl: 'https://x', allowHttp: true });
|
|
||||||
|
|
||||||
await (clientA as unknown as { generateKeys: () => Promise<void> }).generateKeys();
|
await (clientA as unknown as { generateKeys: () => Promise<void> }).generateKeys();
|
||||||
await (clientB 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' }] };
|
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();
|
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')
|
jest.spyOn(clientA as unknown as { fetchServerPublicKey: () => Promise<string> }, 'fetchServerPublicKey')
|
||||||
.mockResolvedValue(serverPublicKeyPem);
|
.mockResolvedValue(serverPublicKeyPem);
|
||||||
|
|
||||||
const encrypted = await clientA.encryptPayload(payload);
|
const encrypted = await clientA.encryptPayload(payload);
|
||||||
expect(encrypted.byteLength).toBeGreaterThan(0);
|
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));
|
const pkg = JSON.parse(new TextDecoder().decode(encrypted));
|
||||||
expect(pkg.version).toBe('1.0');
|
expect(pkg.version).toBe('1.0');
|
||||||
expect(pkg.algorithm).toBe('hybrid-aes256-rsa4096');
|
expect(pkg.algorithm).toBe('hybrid-aes256-rsa4096');
|
||||||
expect(pkg.encrypted_payload.ciphertext).toBeTruthy();
|
expect(pkg.encrypted_payload.ciphertext).toBeTruthy();
|
||||||
expect(pkg.encrypted_payload.nonce).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();
|
expect(pkg.encrypted_aes_key).toBeTruthy();
|
||||||
|
|
||||||
|
clientA.dispose();
|
||||||
|
clientB.dispose();
|
||||||
}, 60000);
|
}, 60000);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue