nomyo-js/src/core/SecureCompletionClient.ts

814 lines
32 KiB
TypeScript
Raw Normal View History

2026-01-17 12:02:08 +01:00
/**
* Secure Completion Client
* Main client class for encrypted communication with NOMYO router
*
2026-01-17 12:02:08 +01:00
* Port of Python's SecureCompletionClient with full API compatibility
*/
import { ClientConfig } from '../types/client';
import { KeyManager } from './crypto/keys';
import { AESEncryption } from './crypto/encryption';
import { RSAOperations } from './crypto/rsa';
import { createHttpClient, HttpClient } from './http/client';
import { createSecureMemory, SecureByteContext } from './memory/secure';
2026-07-19 10:48:34 +02:00
import { SgxAttestationVerifier } from './attestation/SgxAttestationVerifier';
import {
SecurityError,
APIConnectionError,
APIError,
AuthenticationError,
InvalidRequestError,
RateLimitError,
ServerError,
ForbiddenError,
ServiceUnavailableError,
DisposedError,
} from '../errors';
2026-01-17 12:02:08 +01:00
import {
arrayBufferToBase64,
base64ToArrayBuffer,
stringToArrayBuffer,
arrayBufferToString,
} from './crypto/utils';
const VALID_SECURITY_TIERS = ['standard', 'high', 'maximum'] as const;
function generateUUID(): string {
if (typeof crypto !== 'undefined' && typeof (crypto as Crypto & { randomUUID?: () => string }).randomUUID === 'function') {
return (crypto as Crypto & { randomUUID: () => string }).randomUUID();
}
// Node.js fallback
try {
const nodeCrypto = require('crypto') as { randomUUID?: () => string };
if (nodeCrypto.randomUUID) {
return nodeCrypto.randomUUID();
}
} catch (_e) { /* not in Node.js */ }
// Last-resort fallback (not RFC-compliant but collision-resistant enough)
const bytes = new Uint8Array(16);
if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
crypto.getRandomValues(bytes);
}
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
2026-01-17 12:02:08 +01:00
export class SecureCompletionClient {
private routerUrl: string;
private allowHttp: boolean;
private secureMemory: boolean;
private keyManager: KeyManager;
private aes: AESEncryption;
private rsa: RSAOperations;
private httpClient: HttpClient;
private secureMemoryImpl = createSecureMemory();
private readonly keySize: 2048 | 4096;
2026-01-17 12:02:08 +01:00
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 readonly maxRetries: number;
/** Undefined means ephemeral: keys live in memory only and are never written to disk. */
private readonly keyDir?: string;
private _isHttps: boolean = true;
2026-07-19 10:48:34 +02:00
/**
* Only constructed when an attestation policy is supplied, so existing
* callers get byte-for-byte the previous behaviour.
*/
private readonly attestation?: SgxAttestationVerifier;
// Promise-based mutex: serialises concurrent ensureKeys() calls
private ensureKeysLock: Promise<void> = Promise.resolve();
2026-04-16 16:44:26 +02:00
constructor(config: ClientConfig = { routerUrl: 'https://api.nomyo.ai' }) {
2026-01-17 12:02:08 +01:00
const {
2026-04-16 16:44:26 +02:00
routerUrl = 'https://api.nomyo.ai',
2026-01-17 12:02:08 +01:00
allowHttp = false,
secureMemory = true,
keySize = 4096,
timeout = 60000,
debug = false,
keyRotationInterval = 86400000, // 24 hours
keyRotationDir,
keyRotationPassword,
maxRetries = 2,
keyDir,
2026-07-19 10:48:34 +02:00
attestationPolicy,
quoteVerifier,
2026-01-17 12:02:08 +01:00
} = config;
this.debugMode = debug;
this.requestTimeout = timeout;
this.keyRotationInterval = keyRotationInterval;
this.keyRotationDir = keyRotationDir;
this.keyRotationPassword = keyRotationPassword;
this.maxRetries = maxRetries;
// null and undefined both mean ephemeral (Python's key_dir=None default)
this.keyDir = keyDir ?? undefined;
this.keySize = keySize;
2026-01-17 12:02:08 +01:00
this.allowHttp = allowHttp;
this.secureMemory = secureMemory;
// Validate and parse URL
let parsedUrl: URL;
try {
parsedUrl = new URL(routerUrl);
} catch {
throw new Error(`Invalid routerUrl: "${routerUrl}" is not a valid URL`);
}
this._isHttps = parsedUrl.protocol === 'https:';
this.routerUrl = routerUrl.replace(/\/$/, '');
if (!this._isHttps) {
2026-01-17 12:02:08 +01:00
if (!allowHttp) {
console.warn(
'WARNING: Using HTTP instead of HTTPS. ' +
2026-01-17 12:02:08 +01:00
'This is INSECURE and should only be used for local development. ' +
'Man-in-the-middle attacks are possible!'
);
} else {
if (this.debugMode) console.log('HTTP mode enabled for local development (INSECURE)');
2026-01-17 12:02:08 +01:00
}
}
2026-07-19 10:48:34 +02:00
// Optional SGX attestation: only construct the verifier when a policy is
// provided, so existing callers see unchanged behaviour.
if (attestationPolicy !== undefined) {
this.attestation = new SgxAttestationVerifier({
routerUrl: this.routerUrl,
policy: attestationPolicy,
verifier: quoteVerifier,
allowHttp,
timeout: this.requestTimeout,
debug: this.debugMode,
});
}
2026-01-17 12:02:08 +01:00
// Initialize components
this.keyManager = new KeyManager(this.debugMode);
2026-01-17 12:02:08 +01:00
this.aes = new AESEncryption();
this.rsa = new RSAOperations();
this.httpClient = createHttpClient();
// Log memory protection info
const protectionInfo = this.secureMemoryImpl.getProtectionInfo();
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 {
// Persist rotated keys only where a directory was configured. In
// ephemeral mode (the default) rotation stays in memory, so rotation
// never silently starts writing private keys to disk.
const rotationDir = typeof window === 'undefined'
? (this.keyRotationDir ?? this.keyDir)
: undefined;
await this.keyManager.rotateKeys({
keySize: this.keySize,
saveToFile: rotationDir !== undefined,
...(rotationDir !== undefined && { keyDir: rotationDir }),
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');
}
2026-01-17 12:02:08 +01:00
}
/**
* Generate RSA key pair
*/
async generateKeys(options: {
saveToFile?: boolean;
keyDir?: string;
password?: string;
} = {}): Promise<void> {
this.assertNotDisposed();
2026-01-17 12:02:08 +01:00
await this.keyManager.generateKeys({
keySize: this.keySize,
2026-01-17 12:02:08 +01:00
...options,
});
}
/**
* Load existing keys from files (Node.js only)
*/
async loadKeys(
privateKeyPath: string,
publicKeyPath?: string,
password?: string
): Promise<void> {
this.assertNotDisposed();
2026-01-17 12:02:08 +01:00
await this.keyManager.loadKeys(
{ privateKeyPath, publicKeyPath },
password
);
}
/**
* Ensure keys are loaded, generate if necessary.
* Uses a Promise-chain mutex to prevent concurrent key generation races.
2026-01-17 12:02:08 +01:00
*/
private ensureKeys(): Promise<void> {
let resolve!: () => void;
let reject!: (e: unknown) => void;
const callerPromise = new Promise<void>((res, rej) => {
resolve = res;
reject = rej;
});
// Append to the shared chain so callers queue up
this.ensureKeysLock = this.ensureKeysLock.then(async () => {
try {
await this._doEnsureKeys();
resolve();
} catch (e) {
reject(e);
}
});
return callerPromise;
}
private async _doEnsureKeys(): Promise<void> {
if (this.keyManager.hasKeys()) return;
2026-01-17 12:02:08 +01:00
// Ephemeral mode (the default, matching Python's key_dir=None): the key
// pair exists only for this session and never touches disk. Also the only
// possible mode in browsers, which have no filesystem.
if (this.keyDir === undefined || typeof window !== 'undefined') {
if (this.debugMode) console.log('Generating ephemeral in-memory key pair (not persisted)');
await this.generateKeys();
return;
}
2026-01-17 12:02:08 +01:00
// Persistent mode: reuse the key pair in keyDir, or create and save one there.
try {
const fs = require('fs').promises as { access: (p: string) => Promise<void> };
const path = require('path') as { join: (...p: string[]) => string };
2026-01-17 12:02:08 +01:00
const privateKeyPath = path.join(this.keyDir, 'private_key.pem');
const publicKeyPath = path.join(this.keyDir, 'public_key.pem');
2026-01-17 12:02:08 +01:00
await fs.access(privateKeyPath);
await fs.access(publicKeyPath);
await this.loadKeys(privateKeyPath, publicKeyPath);
if (this.debugMode) console.log(`Loaded existing keys from ${this.keyDir}/`);
return;
} catch (_error) {
if (this.debugMode) console.log(`No existing keys found in ${this.keyDir}/, generating new keys...`);
2026-01-17 12:02:08 +01:00
}
await this.generateKeys({
saveToFile: true,
keyDir: this.keyDir,
2026-01-17 12:02:08 +01:00
});
}
/**
* Fetch server's public key from /pki/public_key endpoint
*/
async fetchServerPublicKey(): Promise<string> {
this.assertNotDisposed();
if (this.debugMode) console.log("Fetching server's public key...");
2026-01-17 12:02:08 +01:00
if (!this._isHttps) {
2026-01-17 12:02:08 +01:00
if (!this.allowHttp) {
throw new SecurityError(
'Server public key must be fetched over HTTPS to prevent MITM attacks. ' +
'For local development, initialize with allowHttp=true: ' +
'new SecureChatCompletion({ baseUrl: "http://localhost:12435", allowHttp: true })'
2026-01-17 12:02:08 +01:00
);
} else {
console.warn('Fetching key over HTTP (local development mode)');
}
}
const url = `${this.routerUrl}/pki/public_key`;
try {
const response = await this.httpClient.get(url, { timeout: this.requestTimeout });
2026-01-17 12:02:08 +01:00
if (response.statusCode === 200) {
const serverPublicKey = arrayBufferToString(response.body);
// Validate it's a valid PEM key
try {
await this.rsa.importPublicKey(serverPublicKey);
} catch (_error) {
2026-01-17 12:02:08 +01:00
throw new Error('Server returned invalid public key format');
}
if (this._isHttps) {
if (this.debugMode) console.log("Server's public key fetched securely over HTTPS");
2026-01-17 12:02:08 +01:00
} else {
console.warn("Server's public key fetched over HTTP (INSECURE)");
}
return serverPublicKey;
} else {
throw new Error(`Failed to fetch server's public key: HTTP ${response.statusCode}`);
}
} catch (error) {
if (error instanceof SecurityError) {
throw error;
}
if (error instanceof Error) {
throw new APIConnectionError(`Failed to fetch server's public key: ${error.message}`);
}
throw error;
}
}
/**
* Encrypt a payload using hybrid encryption (AES-256-GCM + RSA-OAEP).
*
* The encrypted package format matches the Python server's expected format:
* - ciphertext: AES-GCM ciphertext WITHOUT auth tag
* - nonce: 12-byte GCM nonce
* - tag: 16-byte GCM auth tag (split from Web Crypto output)
* - encrypted_aes_key: AES key encrypted with server's RSA public key
2026-07-19 10:48:34 +02:00
*
* @param serverPublicKeyPem Optional pre-fetched server public key PEM. When
* provided the payload is encrypted to exactly this key (used after SGX
* attestation so we encrypt to the attested key, with no re-fetch);
* otherwise the key is fetched during encryption.
2026-01-17 12:02:08 +01:00
*/
2026-07-19 10:48:34 +02:00
async encryptPayload(payload: object, serverPublicKeyPem?: string): Promise<ArrayBuffer> {
this.assertNotDisposed();
2026-01-17 12:02:08 +01:00
if (!payload || typeof payload !== 'object') {
throw new Error('Payload must be an object');
}
await this.ensureKeys();
const payloadJson = JSON.stringify(payload);
const payloadBytes = stringToArrayBuffer(payloadJson);
const MAX_PAYLOAD_SIZE = 10 * 1024 * 1024; // 10MB
2026-01-17 12:02:08 +01:00
if (payloadBytes.byteLength > MAX_PAYLOAD_SIZE) {
throw new Error(`Payload too large: ${payloadBytes.byteLength} bytes (max: ${MAX_PAYLOAD_SIZE})`);
}
if (this.debugMode) console.log(`Payload size: ${payloadBytes.byteLength} bytes`);
2026-01-17 12:02:08 +01:00
if (this.secureMemory) {
const context = new SecureByteContext(payloadBytes, true);
return await context.use(async (protectedPayload) => {
2026-07-19 10:48:34 +02:00
return await this.performEncryption(protectedPayload, serverPublicKeyPem);
2026-01-17 12:02:08 +01:00
});
} else {
2026-07-19 10:48:34 +02:00
return await this.performEncryption(payloadBytes, serverPublicKeyPem);
2026-01-17 12:02:08 +01:00
}
}
/**
* Perform the actual encryption.
*
* Web Crypto AES-GCM encrypt returns ciphertext || tag (last 16 bytes).
* We split the tag out to match Python's cryptography library format
* which sends ciphertext and tag as separate fields.
2026-01-17 12:02:08 +01:00
*/
2026-07-19 10:48:34 +02:00
private async performEncryption(
payloadBytes: ArrayBuffer,
serverPublicKeyPem?: string
): Promise<ArrayBuffer> {
2026-01-17 12:02:08 +01:00
const aesKey = await this.aes.generateKey();
const aesKeyBytes = await this.aes.exportKey(aesKey);
const aesContext = new SecureByteContext(aesKeyBytes, this.secureMemory);
return await aesContext.use(async (protectedAesKey) => {
const { ciphertext, nonce } = await this.aes.encrypt(payloadBytes, aesKey);
// Web Crypto appends 16-byte GCM auth tag to ciphertext.
// Split it to match Python's format (separate ciphertext and tag fields).
const TAG_LENGTH = 16;
const ciphertextBytes = new Uint8Array(ciphertext);
const ciphertextOnly = ciphertextBytes.slice(0, ciphertextBytes.length - TAG_LENGTH);
const tag = ciphertextBytes.slice(ciphertextBytes.length - TAG_LENGTH);
2026-07-19 10:48:34 +02:00
const keyPem = serverPublicKeyPem ?? await this.fetchServerPublicKey();
const serverPublicKey = await this.rsa.importPublicKey(keyPem);
2026-01-17 12:02:08 +01:00
const encryptedAesKey = await this.rsa.encryptKey(protectedAesKey, serverPublicKey);
const encryptedPackage = {
version: '1.0',
algorithm: 'hybrid-aes256-rsa4096',
encrypted_payload: {
ciphertext: arrayBufferToBase64(ciphertextOnly.buffer),
2026-01-17 12:02:08 +01:00
nonce: arrayBufferToBase64(nonce),
tag: arrayBufferToBase64(tag.buffer),
2026-01-17 12:02:08 +01:00
},
encrypted_aes_key: arrayBufferToBase64(encryptedAesKey),
key_algorithm: 'RSA-OAEP-SHA256',
payload_algorithm: 'AES-256-GCM',
};
const packageJson = JSON.stringify(encryptedPackage);
const packageBytes = stringToArrayBuffer(packageJson);
if (this.debugMode) console.log(`Encrypted package size: ${packageBytes.byteLength} bytes`);
2026-01-17 12:02:08 +01:00
return packageBytes;
});
}
/**
* Decrypt a response from the secure endpoint.
*
* The server (Python) sends ciphertext and tag as separate fields.
* Web Crypto AES-GCM decrypt expects ciphertext || tag concatenated.
2026-01-17 12:02:08 +01:00
*/
async decryptResponse(encryptedResponse: ArrayBuffer, payloadId: string): Promise<Record<string, unknown>> {
this.assertNotDisposed();
2026-01-17 12:02:08 +01:00
if (!encryptedResponse || encryptedResponse.byteLength === 0) {
throw new Error('Empty encrypted response');
}
let packageData: Record<string, unknown>;
2026-01-17 12:02:08 +01:00
try {
const packageJson = arrayBufferToString(encryptedResponse);
packageData = JSON.parse(packageJson) as Record<string, unknown>;
} catch (_error) {
2026-01-17 12:02:08 +01:00
throw new Error('Invalid encrypted package format: malformed JSON');
}
// Validate top-level structure
2026-01-17 12:02:08 +01:00
const requiredFields = ['version', 'algorithm', 'encrypted_payload', 'encrypted_aes_key'];
for (const field of requiredFields) {
if (!(field in packageData)) {
throw new Error(`Missing required field in encrypted package: ${field}`);
}
}
// 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');
}
// All three fields required to match Python server format
const payloadRequired = ['ciphertext', 'nonce', 'tag'];
2026-01-17 12:02:08 +01:00
for (const field of payloadRequired) {
if (!(field in encryptedPayload)) {
2026-01-17 12:02:08 +01:00
throw new Error(`Missing field in encrypted_payload: ${field}`);
}
}
try {
const encryptedAesKey = base64ToArrayBuffer(packageData.encrypted_aes_key as string);
2026-01-17 12:02:08 +01:00
const privateKey = this.keyManager.getPrivateKey();
const aesKeyBytes = await this.rsa.decryptKey(encryptedAesKey, privateKey);
const aesContext = new SecureByteContext(aesKeyBytes, this.secureMemory);
const response = await aesContext.use(async (protectedAesKey) => {
const aesKey = await this.aes.importKey(protectedAesKey);
const ciphertext = base64ToArrayBuffer(encryptedPayload.ciphertext as string);
const nonce = base64ToArrayBuffer(encryptedPayload.nonce as string);
const tag = base64ToArrayBuffer(encryptedPayload.tag as string);
// Concatenate ciphertext + tag for Web Crypto (expects them joined)
const combined = new Uint8Array(ciphertext.byteLength + tag.byteLength);
combined.set(new Uint8Array(ciphertext), 0);
combined.set(new Uint8Array(tag), ciphertext.byteLength);
2026-01-17 12:02:08 +01:00
const plaintext = await this.aes.decrypt(combined.buffer, nonce, aesKey);
2026-01-17 12:02:08 +01:00
const plaintextContext = new SecureByteContext(plaintext, this.secureMemory);
return await plaintextContext.use(async (protectedPlaintext) => {
const responseJson = arrayBufferToString(protectedPlaintext);
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;
2026-01-17 12:02:08 +01:00
});
});
// Add metadata
const metadata = (response._metadata as Record<string, unknown>) ?? {};
2026-01-17 12:02:08 +01:00
response._metadata = {
...metadata,
2026-01-17 12:02:08 +01:00
payload_id: payloadId,
processed_at: packageData.processed_at,
is_encrypted: true,
encryption_algorithm: packageData.algorithm,
};
if (this.debugMode) console.log('Response decrypted successfully');
2026-01-17 12:02:08 +01:00
return response;
} catch (error) {
// Don't leak specific decryption errors (timing attacks)
throw new SecurityError('Decryption failed: integrity check or authentication failed');
}
}
/**
* 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"
2026-01-17 12:02:08 +01:00
*/
async sendSecureRequest(
payload: object,
payloadId: string,
apiKey?: string,
securityTier?: string
): Promise<Record<string, unknown>> {
this.assertNotDisposed();
if (this.debugMode) console.log('Sending secure chat completion request...');
2026-01-17 12:02:08 +01:00
// Validate security tier
if (securityTier !== undefined) {
if (!(VALID_SECURITY_TIERS as readonly string[]).includes(securityTier)) {
throw new Error(
`Invalid securityTier: '${securityTier}'. ` +
`Must be one of: ${VALID_SECURITY_TIERS.join(', ')}`
);
}
}
// 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');
}
}
2026-01-17 12:02:08 +01:00
await this.ensureKeys();
2026-07-19 10:48:34 +02:00
// Step 0: SGX attestation handshake (when enabled). Fetch the server
// public key once, verify the enclave + key binding BEFORE building any
// plaintext, then encrypt to exactly that attested key. In enforce mode a
// failure throws here, so plaintext is never constructed or sent.
let serverPublicKeyPem: string | undefined;
if (this.attestation !== undefined) {
serverPublicKeyPem = await this.fetchServerPublicKey();
const pubkeyDer = await this.spkiDerFromPem(serverPublicKeyPem);
await this.attestation.ensureVerified(pubkeyDer, securityTier);
}
2026-01-17 12:02:08 +01:00
const publicKeyPem = await this.keyManager.getPublicKeyPEM();
const headers: Record<string, string> = {
'X-Payload-ID': payloadId,
'X-Public-Key': encodeURIComponent(publicKeyPem),
'Content-Type': 'application/octet-stream',
};
if (apiKey) {
headers['Authorization'] = `Bearer ${apiKey}`;
}
if (securityTier) {
headers['X-Security-Tier'] = securityTier;
}
2026-01-17 12:02:08 +01:00
const url = `${this.routerUrl}/v1/chat/secure_completion`;
if (this.debugMode) console.log(`Target URL: ${url}`);
2026-01-17 12:02:08 +01:00
// 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...`
);
2026-01-17 12:02:08 +01:00
}
await new Promise<void>(resolve => setTimeout(resolve, delaySec * 1000));
}
// Re-encrypt each attempt (throws non-retryable errors like SecurityError
// or DisposedError — let those propagate immediately)
2026-07-19 10:48:34 +02:00
const encryptedPayload = await this.encryptPayload(payload, serverPublicKeyPem);
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);
2026-01-17 12:02:08 +01:00
}
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;
}
throw lastError;
}
/**
* 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;
2026-01-17 12:02:08 +01:00
}
/**
* Build a typed error from an HTTP error response
2026-01-17 12:02:08 +01:00
*/
private buildErrorFromResponse(response: { statusCode: number; body: ArrayBuffer }): Error {
let errorData: Record<string, unknown> = {};
2026-01-17 12:02:08 +01:00
try {
const errorJson = arrayBufferToString(response.body);
errorData = JSON.parse(errorJson) as Record<string, unknown>;
} catch (_e) {
2026-01-17 12:02:08 +01:00
// Ignore JSON parse errors
}
// 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, '');
2026-01-17 12:02:08 +01:00
switch (response.statusCode) {
case 400:
return new InvalidRequestError(`Bad request: ${detail}`, 400, errorData);
case 401:
return new AuthenticationError(
`Invalid API key or authentication failed: ${detail}`,
401,
errorData
);
case 403:
return new ForbiddenError(
`Forbidden: ${detail}`,
403,
errorData
);
2026-01-17 12:02:08 +01:00
case 404:
return new APIError(`Endpoint not found: ${detail}`, 404, errorData);
case 429:
return new RateLimitError(`Rate limit exceeded: ${detail}`, 429, errorData);
case 500:
return new ServerError(`Server error: ${detail}`, 500, errorData);
case 503:
return new ServiceUnavailableError(
`Service unavailable: ${detail}`,
503,
errorData
);
2026-01-17 12:02:08 +01:00
default:
return new APIError(
`Unexpected status code: ${response.statusCode} ${detail}`,
response.statusCode,
errorData
);
}
}
2026-07-19 10:48:34 +02:00
/**
* Return the X.509 SubjectPublicKeyInfo DER bytes for the server public key.
*
* This is exactly the value the SGX report_data binding is computed over
* (SHA-512 of these bytes). We re-serialize via the parsed key (rather than
* base64-decoding the PEM body directly) so the bytes are canonical SPKI DER
* regardless of PEM wrapping.
*/
private async spkiDerFromPem(pubkeyPem: string): Promise<ArrayBuffer> {
const key = await this.rsa.importPublicKey(pubkeyPem);
return await this.rsa.exportPublicKeyDer(key);
}
/**
* Validate RSA key size (minimum 2048 bits)
*/
validateRsaKeySize(key: CryptoKey, keyType: 'private' | 'public' = 'private'): void {
const algorithm = key.algorithm as RsaHashedKeyAlgorithm;
const MIN_KEY_SIZE = 2048;
if (algorithm.modulusLength < MIN_KEY_SIZE) {
throw new Error(
`Key size ${algorithm.modulusLength} is too small. ` +
`Minimum recommended size is ${MIN_KEY_SIZE} bits.`
);
2026-01-17 12:02:08 +01:00
}
if (this.debugMode) console.log(`Valid ${algorithm.modulusLength}-bit RSA ${keyType} key`);
2026-01-17 12:02:08 +01:00
}
}
export { generateUUID };