feat:
- added retry logic with exponential backoff - per request base_url setting - configurable key_dir - protocol downgrade protection - public secure memory API
This commit is contained in:
parent
3b1792e613
commit
76b2a284d5
5 changed files with 220 additions and 37 deletions
|
|
@ -72,6 +72,8 @@ export class SecureCompletionClient {
|
|||
private keyRotationTimer?: ReturnType<typeof setInterval>;
|
||||
private readonly keyRotationDir?: string;
|
||||
private readonly keyRotationPassword?: string;
|
||||
private readonly maxRetries: number;
|
||||
private readonly keyDir: string;
|
||||
private _isHttps: boolean = true;
|
||||
|
||||
// Promise-based mutex: serialises concurrent ensureKeys() calls
|
||||
|
|
@ -88,6 +90,8 @@ export class SecureCompletionClient {
|
|||
keyRotationInterval = 86400000, // 24 hours
|
||||
keyRotationDir,
|
||||
keyRotationPassword,
|
||||
maxRetries = 2,
|
||||
keyDir = 'client_keys',
|
||||
} = config;
|
||||
|
||||
this.debugMode = debug;
|
||||
|
|
@ -95,6 +99,8 @@ export class SecureCompletionClient {
|
|||
this.keyRotationInterval = keyRotationInterval;
|
||||
this.keyRotationDir = keyRotationDir;
|
||||
this.keyRotationPassword = keyRotationPassword;
|
||||
this.maxRetries = maxRetries;
|
||||
this.keyDir = keyDir;
|
||||
this.keySize = keySize;
|
||||
this.allowHttp = allowHttp;
|
||||
this.secureMemory = secureMemory;
|
||||
|
|
@ -243,29 +249,29 @@ export class SecureCompletionClient {
|
|||
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 the configured directory (Node.js only)
|
||||
if (typeof window === 'undefined') {
|
||||
try {
|
||||
const fs = require('fs').promises as { access: (p: string) => Promise<void> };
|
||||
const path = require('path') as { join: (...p: string[]) => string };
|
||||
|
||||
const privateKeyPath = path.join('client_keys', 'private_key.pem');
|
||||
const publicKeyPath = path.join('client_keys', 'public_key.pem');
|
||||
const privateKeyPath = path.join(this.keyDir, 'private_key.pem');
|
||||
const publicKeyPath = path.join(this.keyDir, 'public_key.pem');
|
||||
|
||||
await fs.access(privateKeyPath);
|
||||
await fs.access(publicKeyPath);
|
||||
|
||||
await this.loadKeys(privateKeyPath, publicKeyPath);
|
||||
if (this.debugMode) console.log('Loaded existing keys from client_keys/');
|
||||
if (this.debugMode) console.log(`Loaded existing keys from ${this.keyDir}/`);
|
||||
return;
|
||||
} catch (_error) {
|
||||
if (this.debugMode) console.log('No existing keys found, generating new keys...');
|
||||
if (this.debugMode) console.log(`No existing keys found in ${this.keyDir}/, generating new keys...`);
|
||||
}
|
||||
}
|
||||
|
||||
await this.generateKeys({
|
||||
saveToFile: typeof window === 'undefined',
|
||||
keyDir: 'client_keys',
|
||||
keyDir: this.keyDir,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -439,6 +445,22 @@ export class SecureCompletionClient {
|
|||
}
|
||||
}
|
||||
|
||||
// Validate version and algorithm to prevent downgrade attacks
|
||||
const SUPPORTED_VERSION = '1.0';
|
||||
const SUPPORTED_ALGORITHM = 'hybrid-aes256-rsa4096';
|
||||
if (packageData.version !== SUPPORTED_VERSION) {
|
||||
throw new Error(
|
||||
`Unsupported protocol version: '${String(packageData.version)}'. ` +
|
||||
`Expected: '${SUPPORTED_VERSION}'`
|
||||
);
|
||||
}
|
||||
if (packageData.algorithm !== SUPPORTED_ALGORITHM) {
|
||||
throw new Error(
|
||||
`Unsupported encryption algorithm: '${String(packageData.algorithm)}'. ` +
|
||||
`Expected: '${SUPPORTED_ALGORITHM}'`
|
||||
);
|
||||
}
|
||||
|
||||
const encryptedPayload = packageData.encrypted_payload as Record<string, unknown>;
|
||||
if (typeof encryptedPayload !== 'object' || encryptedPayload === null) {
|
||||
throw new Error('Invalid encrypted_payload: must be an object');
|
||||
|
|
@ -515,6 +537,9 @@ export class SecureCompletionClient {
|
|||
/**
|
||||
* Send a secure chat completion request to the router.
|
||||
*
|
||||
* Retries on transient errors (429, 500, 502, 503, 504, network errors)
|
||||
* with exponential backoff matching the Python SDK's `max_retries` behaviour.
|
||||
*
|
||||
* @param securityTier Optional routing tier: "standard" | "high" | "maximum"
|
||||
*/
|
||||
async sendSecureRequest(
|
||||
|
|
@ -545,8 +570,6 @@ export class SecureCompletionClient {
|
|||
|
||||
await this.ensureKeys();
|
||||
|
||||
const encryptedPayload = await this.encryptPayload(payload);
|
||||
|
||||
const publicKeyPem = await this.keyManager.getPublicKeyPEM();
|
||||
const headers: Record<string, string> = {
|
||||
'X-Payload-ID': payloadId,
|
||||
|
|
@ -565,31 +588,86 @@ export class SecureCompletionClient {
|
|||
const url = `${this.routerUrl}/v1/chat/secure_completion`;
|
||||
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: this.requestTimeout,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message === 'Request timeout') {
|
||||
throw new APIConnectionError('Connection to server timed out');
|
||||
// Retry loop — mirrors Python SDK's max_retries + exponential backoff.
|
||||
// The payload is re-encrypted on every attempt so each attempt gets a
|
||||
// fresh AES key and nonce (the HTTP client zeros the buffer after write).
|
||||
let lastError: Error = new APIConnectionError('Request failed');
|
||||
|
||||
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
|
||||
if (attempt > 0) {
|
||||
const delaySec = Math.pow(2, attempt - 1); // 1 s, 2 s, 4 s, …
|
||||
if (this.debugMode) {
|
||||
console.warn(
|
||||
`Retrying request (attempt ${attempt}/${this.maxRetries}) ` +
|
||||
`after ${delaySec}s...`
|
||||
);
|
||||
}
|
||||
throw new APIConnectionError(`Failed to connect to router: ${error.message}`);
|
||||
await new Promise<void>(resolve => setTimeout(resolve, delaySec * 1000));
|
||||
}
|
||||
throw error;
|
||||
|
||||
// Re-encrypt each attempt (throws non-retryable errors like SecurityError
|
||||
// or DisposedError — let those propagate immediately)
|
||||
const encryptedPayload = await this.encryptPayload(payload);
|
||||
|
||||
let response: { statusCode: number; body: ArrayBuffer };
|
||||
try {
|
||||
response = await this.httpClient.post(url, {
|
||||
headers,
|
||||
body: encryptedPayload,
|
||||
timeout: this.requestTimeout,
|
||||
});
|
||||
} catch (error) {
|
||||
// Network / timeout errors from the HTTP client
|
||||
let connError: APIConnectionError;
|
||||
if (error instanceof Error) {
|
||||
connError = error.message === 'Request timeout'
|
||||
? new APIConnectionError('Connection to server timed out')
|
||||
: new APIConnectionError(`Failed to connect to router: ${error.message}`);
|
||||
} else {
|
||||
connError = new APIConnectionError('Failed to connect to router: unknown error');
|
||||
}
|
||||
lastError = connError;
|
||||
if (attempt < this.maxRetries) {
|
||||
if (this.debugMode) console.warn(`Network error on attempt ${attempt}: ${connError.message}`);
|
||||
continue;
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
if (this.debugMode) console.log(`HTTP Status: ${response.statusCode}`);
|
||||
|
||||
if (response.statusCode === 200) {
|
||||
return await this.decryptResponse(response.body, payloadId);
|
||||
}
|
||||
|
||||
const err = this.buildErrorFromResponse(response);
|
||||
|
||||
if (this.isRetryableError(err) && attempt < this.maxRetries) {
|
||||
if (this.debugMode) {
|
||||
console.warn(`Got retryable status ${response.statusCode}: retrying...`);
|
||||
}
|
||||
lastError = err;
|
||||
continue;
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (this.debugMode) console.log(`HTTP Status: ${response.statusCode}`);
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
if (response.statusCode === 200) {
|
||||
return await this.decryptResponse(response.body, payloadId);
|
||||
}
|
||||
|
||||
// Map HTTP error status codes to typed errors
|
||||
throw this.buildErrorFromResponse(response);
|
||||
/**
|
||||
* Return true for errors that warrant a retry (transient failures).
|
||||
* Non-retryable errors (auth, bad request, forbidden, etc.) propagate immediately.
|
||||
*/
|
||||
private isRetryableError(error: Error): boolean {
|
||||
if (error instanceof APIConnectionError) return true;
|
||||
if (error instanceof RateLimitError) return true;
|
||||
if (error instanceof ServerError) return true;
|
||||
if (error instanceof ServiceUnavailableError) return true;
|
||||
// 502 Bad Gateway and 504 Gateway Timeout fall through as generic APIError
|
||||
if (error instanceof APIError && (error.statusCode === 502 || error.statusCode === 504)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue