2026-01-17 12:02:08 +01:00
|
|
|
/**
|
|
|
|
|
* Secure Completion Client
|
|
|
|
|
* Main client class for encrypted communication with NOMYO router
|
2026-03-04 11:30:44 +01:00
|
|
|
*
|
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';
|
2026-03-04 11:30:44 +01:00
|
|
|
import {
|
|
|
|
|
SecurityError,
|
|
|
|
|
APIConnectionError,
|
|
|
|
|
APIError,
|
|
|
|
|
AuthenticationError,
|
|
|
|
|
InvalidRequestError,
|
|
|
|
|
RateLimitError,
|
|
|
|
|
ServerError,
|
|
|
|
|
ForbiddenError,
|
|
|
|
|
ServiceUnavailableError,
|
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()
2026-04-01 14:28:05 +02:00
|
|
|
DisposedError,
|
2026-03-04 11:30:44 +01:00
|
|
|
} from '../errors';
|
2026-01-17 12:02:08 +01:00
|
|
|
import {
|
|
|
|
|
arrayBufferToBase64,
|
|
|
|
|
base64ToArrayBuffer,
|
|
|
|
|
stringToArrayBuffer,
|
|
|
|
|
arrayBufferToString,
|
|
|
|
|
} from './crypto/utils';
|
|
|
|
|
|
2026-03-04 11:30:44 +01:00
|
|
|
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();
|
2026-03-04 11:30:44 +01:00
|
|
|
private readonly keySize: 2048 | 4096;
|
2026-01-17 12:02:08 +01:00
|
|
|
|
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()
2026-04-01 14:28:05 +02: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;
|
2026-04-16 15:36:20 +02:00
|
|
|
private readonly maxRetries: number;
|
2026-07-19 11:19:03 +02:00
|
|
|
/** Undefined means ephemeral: keys live in memory only and are never written to disk. */
|
|
|
|
|
private readonly keyDir?: string;
|
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()
2026-04-01 14:28:05 +02:00
|
|
|
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;
|
|
|
|
|
|
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()
2026-04-01 14:28:05 +02:00
|
|
|
// 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,
|
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()
2026-04-01 14:28:05 +02:00
|
|
|
timeout = 60000,
|
|
|
|
|
debug = false,
|
|
|
|
|
keyRotationInterval = 86400000, // 24 hours
|
|
|
|
|
keyRotationDir,
|
|
|
|
|
keyRotationPassword,
|
2026-04-16 15:36:20 +02:00
|
|
|
maxRetries = 2,
|
2026-07-19 11:19:03 +02:00
|
|
|
keyDir,
|
2026-07-19 10:48:34 +02:00
|
|
|
attestationPolicy,
|
|
|
|
|
quoteVerifier,
|
2026-01-17 12:02:08 +01:00
|
|
|
} = config;
|
|
|
|
|
|
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()
2026-04-01 14:28:05 +02:00
|
|
|
this.debugMode = debug;
|
|
|
|
|
this.requestTimeout = timeout;
|
|
|
|
|
this.keyRotationInterval = keyRotationInterval;
|
|
|
|
|
this.keyRotationDir = keyRotationDir;
|
|
|
|
|
this.keyRotationPassword = keyRotationPassword;
|
2026-04-16 15:36:20 +02:00
|
|
|
this.maxRetries = maxRetries;
|
2026-07-19 11:19:03 +02:00
|
|
|
// null and undefined both mean ephemeral (Python's key_dir=None default)
|
|
|
|
|
this.keyDir = keyDir ?? undefined;
|
2026-03-04 11:30:44 +01:00
|
|
|
this.keySize = keySize;
|
2026-01-17 12:02:08 +01:00
|
|
|
this.allowHttp = allowHttp;
|
|
|
|
|
this.secureMemory = secureMemory;
|
|
|
|
|
|
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()
2026-04-01 14:28:05 +02:00
|
|
|
// 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(
|
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()
2026-04-01 14:28:05 +02:00
|
|
|
'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 {
|
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()
2026-04-01 14:28:05 +02:00
|
|
|
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
|
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()
2026-04-01 14:28:05 +02:00
|
|
|
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();
|
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()
2026-04-01 14:28:05 +02:00
|
|
|
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 {
|
2026-07-19 11:19:03 +02:00
|
|
|
// 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;
|
|
|
|
|
|
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()
2026-04-01 14:28:05 +02:00
|
|
|
await this.keyManager.rotateKeys({
|
|
|
|
|
keySize: this.keySize,
|
2026-07-19 11:19:03 +02:00
|
|
|
saveToFile: rotationDir !== undefined,
|
|
|
|
|
...(rotationDir !== undefined && { keyDir: rotationDir }),
|
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()
2026-04-01 14:28:05 +02:00
|
|
|
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> {
|
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()
2026-04-01 14:28:05 +02:00
|
|
|
this.assertNotDisposed();
|
2026-01-17 12:02:08 +01:00
|
|
|
await this.keyManager.generateKeys({
|
2026-03-04 11:30:44 +01:00
|
|
|
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> {
|
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()
2026-04-01 14:28:05 +02:00
|
|
|
this.assertNotDisposed();
|
2026-01-17 12:02:08 +01:00
|
|
|
await this.keyManager.loadKeys(
|
|
|
|
|
{ privateKeyPath, publicKeyPath },
|
|
|
|
|
password
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
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()
2026-04-01 14:28:05 +02:00
|
|
|
* 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
|
|
|
*/
|
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()
2026-04-01 14:28:05 +02: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
|
|
|
|
2026-07-19 11:19:03 +02: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
|
|
|
|
2026-07-19 11:19:03 +02: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
|
|
|
|
2026-07-19 11:19:03 +02: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
|
|
|
|
2026-07-19 11:19:03 +02: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({
|
2026-07-19 11:19:03 +02:00
|
|
|
saveToFile: true,
|
2026-04-16 15:36:20 +02:00
|
|
|
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> {
|
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()
2026-04-01 14:28:05 +02:00
|
|
|
this.assertNotDisposed();
|
|
|
|
|
if (this.debugMode) console.log("Fetching server's public key...");
|
2026-01-17 12:02:08 +01:00
|
|
|
|
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()
2026-04-01 14:28:05 +02: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: ' +
|
2026-04-01 13:38:45 +02:00
|
|
|
'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 {
|
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()
2026-04-01 14:28:05 +02:00
|
|
|
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);
|
2026-03-04 11:30:44 +01:00
|
|
|
} catch (_error) {
|
2026-01-17 12:02:08 +01:00
|
|
|
throw new Error('Server returned invalid public key format');
|
|
|
|
|
}
|
|
|
|
|
|
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()
2026-04-01 14:28:05 +02:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-03-04 11:30:44 +01:00
|
|
|
* 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> {
|
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()
2026-04-01 14:28:05 +02:00
|
|
|
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);
|
|
|
|
|
|
2026-03-04 11:30:44 +01:00
|
|
|
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})`);
|
|
|
|
|
}
|
|
|
|
|
|
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()
2026-04-01 14:28:05 +02:00
|
|
|
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
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-03-04 11:30:44 +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);
|
|
|
|
|
|
2026-03-04 11:30:44 +01:00
|
|
|
// 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: {
|
2026-03-04 11:30:44 +01:00
|
|
|
ciphertext: arrayBufferToBase64(ciphertextOnly.buffer),
|
2026-01-17 12:02:08 +01:00
|
|
|
nonce: arrayBufferToBase64(nonce),
|
2026-03-04 11:30:44 +01:00
|
|
|
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);
|
|
|
|
|
|
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()
2026-04-01 14:28:05 +02:00
|
|
|
if (this.debugMode) console.log(`Encrypted package size: ${packageBytes.byteLength} bytes`);
|
2026-01-17 12:02:08 +01:00
|
|
|
|
|
|
|
|
return packageBytes;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-03-04 11:30:44 +01:00
|
|
|
* 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
|
|
|
*/
|
2026-03-04 11:30:44 +01:00
|
|
|
async decryptResponse(encryptedResponse: ArrayBuffer, payloadId: string): Promise<Record<string, unknown>> {
|
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()
2026-04-01 14:28:05 +02:00
|
|
|
this.assertNotDisposed();
|
2026-01-17 12:02:08 +01:00
|
|
|
|
|
|
|
|
if (!encryptedResponse || encryptedResponse.byteLength === 0) {
|
|
|
|
|
throw new Error('Empty encrypted response');
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-04 11:30:44 +01:00
|
|
|
let packageData: Record<string, unknown>;
|
2026-01-17 12:02:08 +01:00
|
|
|
try {
|
|
|
|
|
const packageJson = arrayBufferToString(encryptedResponse);
|
2026-03-04 11:30:44 +01:00
|
|
|
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');
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-04 11:30:44 +01:00
|
|
|
// 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}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-16 15:36:20 +02:00
|
|
|
// 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}'`
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-04 11:30:44 +01:00
|
|
|
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) {
|
2026-03-04 11:30:44 +01:00
|
|
|
if (!(field in encryptedPayload)) {
|
2026-01-17 12:02:08 +01:00
|
|
|
throw new Error(`Missing field in encrypted_payload: ${field}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
2026-03-04 11:30:44 +01:00
|
|
|
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);
|
|
|
|
|
|
2026-03-04 11:30:44 +01:00
|
|
|
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
|
|
|
|
2026-03-04 11:30:44 +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);
|
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()
2026-04-01 14:28:05 +02:00
|
|
|
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
|
2026-03-04 11:30:44 +01:00
|
|
|
const metadata = (response._metadata as Record<string, unknown>) ?? {};
|
2026-01-17 12:02:08 +01:00
|
|
|
response._metadata = {
|
2026-03-04 11:30:44 +01:00
|
|
|
...metadata,
|
2026-01-17 12:02:08 +01:00
|
|
|
payload_id: payloadId,
|
|
|
|
|
processed_at: packageData.processed_at,
|
|
|
|
|
is_encrypted: true,
|
|
|
|
|
encryption_algorithm: packageData.algorithm,
|
|
|
|
|
};
|
|
|
|
|
|
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()
2026-04-01 14:28:05 +02:00
|
|
|
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');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-03-04 11:30:44 +01:00
|
|
|
* Send a secure chat completion request to the router.
|
|
|
|
|
*
|
2026-04-16 15:36:20 +02:00
|
|
|
* Retries on transient errors (429, 500, 502, 503, 504, network errors)
|
|
|
|
|
* with exponential backoff matching the Python SDK's `max_retries` behaviour.
|
|
|
|
|
*
|
2026-03-04 11:30:44 +01:00
|
|
|
* @param securityTier Optional routing tier: "standard" | "high" | "maximum"
|
2026-01-17 12:02:08 +01:00
|
|
|
*/
|
|
|
|
|
async sendSecureRequest(
|
|
|
|
|
payload: object,
|
|
|
|
|
payloadId: string,
|
2026-03-04 11:30:44 +01:00
|
|
|
apiKey?: string,
|
|
|
|
|
securityTier?: string
|
|
|
|
|
): Promise<Record<string, unknown>> {
|
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()
2026-04-01 14:28:05 +02:00
|
|
|
this.assertNotDisposed();
|
|
|
|
|
if (this.debugMode) console.log('Sending secure chat completion request...');
|
2026-01-17 12:02:08 +01:00
|
|
|
|
2026-03-04 11:30:44 +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(', ')}`
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
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()
2026-04-01 14:28:05 +02:00
|
|
|
// 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}`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-04 11:30:44 +01:00
|
|
|
if (securityTier) {
|
|
|
|
|
headers['X-Security-Tier'] = securityTier;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-17 12:02:08 +01:00
|
|
|
const url = `${this.routerUrl}/v1/chat/secure_completion`;
|
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()
2026-04-01 14:28:05 +02:00
|
|
|
if (this.debugMode) console.log(`Target URL: ${url}`);
|
2026-01-17 12:02:08 +01:00
|
|
|
|
2026-04-16 15:36:20 +02: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
|
|
|
}
|
2026-04-16 15:36:20 +02: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);
|
2026-04-16 15:36:20 +02:00
|
|
|
|
|
|
|
|
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
|
|
|
}
|
2026-03-04 11:30:44 +01:00
|
|
|
|
2026-04-16 15:36:20 +02:00
|
|
|
const err = this.buildErrorFromResponse(response);
|
2026-03-04 11:30:44 +01:00
|
|
|
|
2026-04-16 15:36:20 +02:00
|
|
|
if (this.isRetryableError(err) && attempt < this.maxRetries) {
|
|
|
|
|
if (this.debugMode) {
|
|
|
|
|
console.warn(`Got retryable status ${response.statusCode}: retrying...`);
|
|
|
|
|
}
|
|
|
|
|
lastError = err;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
throw err;
|
2026-03-04 11:30:44 +01:00
|
|
|
}
|
|
|
|
|
|
2026-04-16 15:36:20 +02:00
|
|
|
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
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-03-04 11:30:44 +01:00
|
|
|
* Build a typed error from an HTTP error response
|
2026-01-17 12:02:08 +01:00
|
|
|
*/
|
2026-03-04 11:30:44 +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);
|
2026-03-04 11:30:44 +01:00
|
|
|
errorData = JSON.parse(errorJson) as Record<string, unknown>;
|
|
|
|
|
} catch (_e) {
|
2026-01-17 12:02:08 +01:00
|
|
|
// Ignore JSON parse errors
|
|
|
|
|
}
|
|
|
|
|
|
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()
2026-04-01 14:28:05 +02:00
|
|
|
// 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:
|
2026-03-04 11:30:44 +01:00
|
|
|
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);
|
2026-03-04 11:30:44 +01:00
|
|
|
case 503:
|
|
|
|
|
return new ServiceUnavailableError(
|
|
|
|
|
`Service unavailable: ${detail}`,
|
|
|
|
|
503,
|
|
|
|
|
errorData
|
|
|
|
|
);
|
2026-01-17 12:02:08 +01:00
|
|
|
default:
|
2026-03-04 11:30:44 +01:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-04 11:30:44 +01:00
|
|
|
/**
|
|
|
|
|
* 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
|
|
|
}
|
2026-03-04 11:30:44 +01:00
|
|
|
|
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()
2026-04-01 14:28:05 +02:00
|
|
|
if (this.debugMode) console.log(`Valid ${algorithm.modulusLength}-bit RSA ${keyType} key`);
|
2026-01-17 12:02:08 +01:00
|
|
|
}
|
|
|
|
|
}
|
2026-03-04 11:30:44 +01:00
|
|
|
|
|
|
|
|
export { generateUUID };
|