- 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

@ -10,6 +10,8 @@ import { ChatCompletionRequest, ChatCompletionResponse } from '../types/api';
export class SecureChatCompletion { export class SecureChatCompletion {
private client: SecureCompletionClient; private client: SecureCompletionClient;
private apiKey?: string; private apiKey?: string;
/** Stored config used to spin up a temporary per-request instance when base_url is overridden */
private readonly _config: ChatCompletionConfig;
constructor(config: ChatCompletionConfig = {}) { constructor(config: ChatCompletionConfig = {}) {
const { const {
@ -22,8 +24,11 @@ export class SecureChatCompletion {
keyRotationInterval, keyRotationInterval,
keyRotationDir, keyRotationDir,
keyRotationPassword, keyRotationPassword,
maxRetries,
keyDir,
} = config; } = config;
this._config = config;
this.apiKey = apiKey; this.apiKey = apiKey;
this.client = new SecureCompletionClient({ this.client = new SecureCompletionClient({
routerUrl: baseUrl, routerUrl: baseUrl,
@ -34,6 +39,8 @@ export class SecureChatCompletion {
...(keyRotationInterval !== undefined && { keyRotationInterval }), ...(keyRotationInterval !== undefined && { keyRotationInterval }),
...(keyRotationDir !== undefined && { keyRotationDir }), ...(keyRotationDir !== undefined && { keyRotationDir }),
...(keyRotationPassword !== undefined && { keyRotationPassword }), ...(keyRotationPassword !== undefined && { keyRotationPassword }),
...(maxRetries !== undefined && { maxRetries }),
...(keyDir !== undefined && { keyDir }),
}); });
} }
@ -43,18 +50,19 @@ export class SecureChatCompletion {
* Supports additional NOMYO-specific fields: * Supports additional NOMYO-specific fields:
* - `security_tier`: "standard" | "high" | "maximum" controls hardware routing * - `security_tier`: "standard" | "high" | "maximum" controls hardware routing
* - `api_key`: per-request API key override (takes precedence over constructor key) * - `api_key`: per-request API key override (takes precedence over constructor key)
* - `base_url`: per-request router URL override (creates a temporary client for
* this single call, matching the Python SDK's `create(base_url=...)` behaviour)
*/ */
async create(request: ChatCompletionRequest): Promise<ChatCompletionResponse> { async create(request: ChatCompletionRequest): Promise<ChatCompletionResponse> {
const payloadId = generateUUID(); const payloadId = generateUUID();
// Extract NOMYO-specific fields that must not go into the encrypted payload // Extract NOMYO-specific fields that must not go into the encrypted payload
const { security_tier, api_key, ...payload } = request as ChatCompletionRequest & { const { security_tier, api_key, base_url, ...payload } = request as ChatCompletionRequest & {
security_tier?: string; security_tier?: string;
api_key?: string; api_key?: string;
base_url?: string;
}; };
const apiKey = api_key ?? this.apiKey;
if (!payload.model) { if (!payload.model) {
throw new Error('Missing required field: model'); throw new Error('Missing required field: model');
} }
@ -62,6 +70,29 @@ export class SecureChatCompletion {
throw new Error('Missing or invalid required field: messages'); throw new Error('Missing or invalid required field: messages');
} }
const apiKey = api_key ?? this.apiKey;
// Per-request base_url: spin up a temporary client for this one call,
// inheriting all other config from the current instance.
if (base_url !== undefined) {
const tempInstance = new SecureChatCompletion({
...this._config,
baseUrl: base_url,
apiKey: this.apiKey,
});
try {
const response = await tempInstance.client.sendSecureRequest(
payload,
payloadId,
apiKey,
security_tier
);
return response as unknown as ChatCompletionResponse;
} finally {
tempInstance.dispose();
}
}
const response = await this.client.sendSecureRequest( const response = await this.client.sendSecureRequest(
payload, payload,
payloadId, payloadId,

View file

@ -72,6 +72,8 @@ export class SecureCompletionClient {
private keyRotationTimer?: ReturnType<typeof setInterval>; private keyRotationTimer?: ReturnType<typeof setInterval>;
private readonly keyRotationDir?: string; private readonly keyRotationDir?: string;
private readonly keyRotationPassword?: string; private readonly keyRotationPassword?: string;
private readonly maxRetries: number;
private readonly keyDir: string;
private _isHttps: boolean = true; private _isHttps: boolean = true;
// Promise-based mutex: serialises concurrent ensureKeys() calls // Promise-based mutex: serialises concurrent ensureKeys() calls
@ -88,6 +90,8 @@ export class SecureCompletionClient {
keyRotationInterval = 86400000, // 24 hours keyRotationInterval = 86400000, // 24 hours
keyRotationDir, keyRotationDir,
keyRotationPassword, keyRotationPassword,
maxRetries = 2,
keyDir = 'client_keys',
} = config; } = config;
this.debugMode = debug; this.debugMode = debug;
@ -95,6 +99,8 @@ export class SecureCompletionClient {
this.keyRotationInterval = keyRotationInterval; this.keyRotationInterval = keyRotationInterval;
this.keyRotationDir = keyRotationDir; this.keyRotationDir = keyRotationDir;
this.keyRotationPassword = keyRotationPassword; this.keyRotationPassword = keyRotationPassword;
this.maxRetries = maxRetries;
this.keyDir = keyDir;
this.keySize = keySize; this.keySize = keySize;
this.allowHttp = allowHttp; this.allowHttp = allowHttp;
this.secureMemory = secureMemory; this.secureMemory = secureMemory;
@ -243,29 +249,29 @@ export class SecureCompletionClient {
private async _doEnsureKeys(): Promise<void> { private async _doEnsureKeys(): Promise<void> {
if (this.keyManager.hasKeys()) return; 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') { if (typeof window === 'undefined') {
try { try {
const fs = require('fs').promises as { access: (p: string) => Promise<void> }; const fs = require('fs').promises as { access: (p: string) => Promise<void> };
const path = require('path') as { join: (...p: string[]) => string }; const path = require('path') as { join: (...p: string[]) => string };
const privateKeyPath = path.join('client_keys', 'private_key.pem'); const privateKeyPath = path.join(this.keyDir, 'private_key.pem');
const publicKeyPath = path.join('client_keys', 'public_key.pem'); const publicKeyPath = path.join(this.keyDir, 'public_key.pem');
await fs.access(privateKeyPath); await fs.access(privateKeyPath);
await fs.access(publicKeyPath); await fs.access(publicKeyPath);
await this.loadKeys(privateKeyPath, 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; return;
} catch (_error) { } 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({ await this.generateKeys({
saveToFile: typeof window === 'undefined', 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>; const encryptedPayload = packageData.encrypted_payload as Record<string, unknown>;
if (typeof encryptedPayload !== 'object' || encryptedPayload === null) { if (typeof encryptedPayload !== 'object' || encryptedPayload === null) {
throw new Error('Invalid encrypted_payload: must be an object'); 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. * 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" * @param securityTier Optional routing tier: "standard" | "high" | "maximum"
*/ */
async sendSecureRequest( async sendSecureRequest(
@ -545,8 +570,6 @@ export class SecureCompletionClient {
await this.ensureKeys(); await this.ensureKeys();
const encryptedPayload = await this.encryptPayload(payload);
const publicKeyPem = await this.keyManager.getPublicKeyPEM(); const publicKeyPem = await this.keyManager.getPublicKeyPEM();
const headers: Record<string, string> = { const headers: Record<string, string> = {
'X-Payload-ID': payloadId, 'X-Payload-ID': payloadId,
@ -565,31 +588,86 @@ export class SecureCompletionClient {
const url = `${this.routerUrl}/v1/chat/secure_completion`; const url = `${this.routerUrl}/v1/chat/secure_completion`;
if (this.debugMode) console.log(`Target URL: ${url}`); if (this.debugMode) console.log(`Target URL: ${url}`);
let response: { statusCode: number; body: ArrayBuffer }; // Retry loop — mirrors Python SDK's max_retries + exponential backoff.
try { // The payload is re-encrypted on every attempt so each attempt gets a
response = await this.httpClient.post(url, { // fresh AES key and nonce (the HTTP client zeros the buffer after write).
headers, let lastError: Error = new APIConnectionError('Request failed');
body: encryptedPayload,
timeout: this.requestTimeout, for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
}); if (attempt > 0) {
} catch (error) { const delaySec = Math.pow(2, attempt - 1); // 1 s, 2 s, 4 s, …
if (error instanceof Error) { if (this.debugMode) {
if (error.message === 'Request timeout') { console.warn(
throw new APIConnectionError('Connection to server timed out'); `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); * Return true for errors that warrant a retry (transient failures).
} * Non-retryable errors (auth, bad request, forbidden, etc.) propagate immediately.
*/
// Map HTTP error status codes to typed errors private isRetryableError(error: Error): boolean {
throw this.buildErrorFromResponse(response); 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;
} }
/** /**

View file

@ -1,5 +1,5 @@
/** /**
* Secure memory interface and context manager * Secure memory interface, context manager, and public API.
* *
* IMPORTANT: This is a pure JavaScript implementation that provides memory zeroing only. * IMPORTANT: This is a pure JavaScript implementation that provides memory zeroing only.
* OS-level memory locking (mlock) is NOT implemented in this version. * OS-level memory locking (mlock) is NOT implemented in this version.
@ -10,6 +10,37 @@
import { ProtectionInfo } from '../../types/crypto'; import { ProtectionInfo } from '../../types/crypto';
// ─── Global secure-memory state ──────────────────────────────────────────────
/** Module-level flag, mirrors Python's global _secure_memory.enabled. */
let _globalSecureMemoryEnabled = true;
/**
* Disable secure memory operations globally.
* Affects new SecureByteContext instances created without an explicit `useSecure` argument.
* Existing client instances are unaffected (they pass `useSecure` explicitly).
* Mirrors Python's `disable_secure_memory()`.
*/
export function disableSecureMemory(): void {
_globalSecureMemoryEnabled = false;
}
/**
* Re-enable secure memory operations globally.
* Mirrors Python's `enable_secure_memory()`.
*/
export function enableSecureMemory(): void {
_globalSecureMemoryEnabled = true;
}
/**
* Return information about the memory protection capabilities available on this
* platform/runtime. Mirrors Python's `get_memory_protection_info()`.
*/
export function getMemoryProtectionInfo(): ProtectionInfo {
return createSecureMemory().getProtectionInfo();
}
export interface SecureMemory { export interface SecureMemory {
/** /**
* Zero memory (fill with zeros) * Zero memory (fill with zeros)
@ -24,15 +55,19 @@ export interface SecureMemory {
} }
/** /**
* Secure byte context manager * Secure byte context manager.
* Ensures memory is zeroed even if an exception occurs (similar to Python's context manager) * Ensures memory is zeroed even if an exception occurs (analogous to Python's
* `secure_bytearray()` context manager and `SecureBuffer` class).
*
* When `useSecure` is omitted, the module-level global flag set by
* `disableSecureMemory()` / `enableSecureMemory()` is consulted.
*/ */
export class SecureByteContext { export class SecureByteContext {
private data: ArrayBuffer; private data: ArrayBuffer;
private secureMemory: SecureMemory; private secureMemory: SecureMemory;
private useSecure: boolean; private useSecure: boolean;
constructor(data: ArrayBuffer, useSecure: boolean = true) { constructor(data: ArrayBuffer, useSecure: boolean = _globalSecureMemoryEnabled) {
this.data = data; this.data = data;
this.useSecure = useSecure; this.useSecure = useSecure;
this.secureMemory = createSecureMemory(); this.secureMemory = createSecureMemory();

View file

@ -13,3 +13,13 @@ export * from './types/crypto';
// Export errors // Export errors
export * from './errors'; export * from './errors';
// Secure memory public API — mirrors Python's get_memory_protection_info(),
// disable_secure_memory(), enable_secure_memory(), and SecureBuffer/secure_bytearray()
export {
getMemoryProtectionInfo,
disableSecureMemory,
enableSecureMemory,
SecureByteContext,
createSecureMemory,
} from './core/memory/secure';

View file

@ -32,6 +32,20 @@ export interface ClientConfig {
/** Password to encrypt rotated private key files */ /** Password to encrypt rotated private key files */
keyRotationPassword?: string; keyRotationPassword?: string;
/**
* Directory to load/save RSA keys on startup.
* If the directory contains an existing key pair it is loaded; otherwise a
* new pair is generated and saved there. Default: 'client_keys'.
* Matches the Python SDK's `key_dir` constructor parameter.
*/
keyDir?: string;
/**
* Maximum number of retries on retryable errors (429, 500, 502, 503, 504,
* network errors). Uses exponential backoff (1 s, 2 s, 4 s, ). Default: 2.
*/
maxRetries?: number;
} }
export interface KeyGenOptions { export interface KeyGenOptions {
@ -83,4 +97,19 @@ export interface ChatCompletionConfig {
/** Password to encrypt rotated private key files */ /** Password to encrypt rotated private key files */
keyRotationPassword?: string; keyRotationPassword?: string;
/**
* Directory to load/save RSA keys on startup.
* If the directory contains an existing key pair it is loaded; otherwise a
* new pair is generated and saved there.
* Omit (or set to undefined) to use the default 'client_keys/' directory.
* Matches the Python SDK's `key_dir` constructor parameter.
*/
keyDir?: string;
/**
* Maximum number of retries on retryable errors (429, 500, 502, 503, 504,
* network errors). Uses exponential backoff (1 s, 2 s, 4 s, ). Default: 2.
*/
maxRetries?: number;
} }