- 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:
Alpha Nerd 2026-04-16 15:36:20 +02:00
parent 3b1792e613
commit 76b2a284d5
Signed by: alpha-nerd
SSH key fingerprint: SHA256:QkkAgVoYi9TQ0UKPkiKSfnerZy2h4qhi3SVPXJmBN+M
5 changed files with 220 additions and 37 deletions

View file

@ -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;
}
/**