All checks were successful
NYX Security Scan / nyx-scan (pull_request) Successful in 5m43s
Completes the parity work (step 4). Request timeout now defaults to 900 s, matching Python, instead of 60 s. Encrypted inference cannot stream, so an entire completion arrives in one response; a long generation on a busy backend legitimately takes minutes and was timing out here while succeeding in the Python client. Error types now distinguish malformed data from integrity failures. Python raises ValueError for a bad package, a non-200 or unparseable /pki/public_key, and plaintext that will not parse, reserving SecurityError for crypto failures. This port wrapped nearly all of it in SecurityError — so a server sending malformed JSON was reported as an authentication failure, pointing debugging in exactly the wrong direction. Malformed data is now a plain Error (the JS equivalent of ValueError), carried past the deliberately opaque catch-all by a symbol marker rather than a new exported class. Genuine crypto failures still report a single vague message so they cannot serve as a decryption oracle. Also adds the missing guard Python has: decrypting without a private key now says so, instead of failing later and being reported as an integrity failure. doc/attestation.md ports the Python attestation guide to the JS API, and documents the two deliberate divergences: no verify_ssl escape hatch, and jose injection instead of a runtime dynamic import. Version 0.1.0 -> 0.3.0 to match the Python client's feature level, now that the two are at parity. Not ported: Python's warning when secure_memory=True but the SecureMemory module is unavailable. There is no JS equivalent — zeroing is always available, and the weaker case (mlock unavailable) is already reported honestly by getProtectionInfo(). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
869 lines
34 KiB
TypeScript
869 lines
34 KiB
TypeScript
/**
|
|
* Secure Completion Client
|
|
* Main client class for encrypted communication with NOMYO router
|
|
*
|
|
* 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';
|
|
import { SgxAttestationVerifier } from './attestation/SgxAttestationVerifier';
|
|
import {
|
|
SecurityError,
|
|
APIConnectionError,
|
|
APIError,
|
|
AuthenticationError,
|
|
InvalidRequestError,
|
|
RateLimitError,
|
|
ServerError,
|
|
ForbiddenError,
|
|
ServiceUnavailableError,
|
|
DisposedError,
|
|
} from '../errors';
|
|
import {
|
|
arrayBufferToBase64,
|
|
base64ToArrayBuffer,
|
|
stringToArrayBuffer,
|
|
arrayBufferToString,
|
|
} from './crypto/utils';
|
|
|
|
const VALID_SECURITY_TIERS = ['standard', 'high', 'maximum'] as const;
|
|
|
|
/**
|
|
* Marks errors that describe malformed *data* rather than a failed integrity
|
|
* check, so they survive the deliberately opaque catch-all in decryptResponse.
|
|
*
|
|
* Python distinguishes these by raising ValueError vs SecurityError; the JS
|
|
* equivalent of ValueError is a plain Error, so the distinction rides on this
|
|
* symbol instead of a new exported class.
|
|
*/
|
|
const FORMAT_ERROR = Symbol('nomyo.formatError');
|
|
|
|
function formatError(message: string): Error {
|
|
const err = new Error(message);
|
|
(err as Error & { [FORMAT_ERROR]?: true })[FORMAT_ERROR] = true;
|
|
return err;
|
|
}
|
|
|
|
function isFormatError(error: unknown): error is Error {
|
|
return error instanceof Error
|
|
&& (error as Error & { [FORMAT_ERROR]?: true })[FORMAT_ERROR] === true;
|
|
}
|
|
|
|
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)}`;
|
|
}
|
|
|
|
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;
|
|
|
|
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;
|
|
|
|
/**
|
|
* 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();
|
|
|
|
constructor(config: ClientConfig = { routerUrl: 'https://api.nomyo.ai' }) {
|
|
const {
|
|
routerUrl = 'https://api.nomyo.ai',
|
|
allowHttp = false,
|
|
secureMemory = true,
|
|
keySize = 4096,
|
|
// 900 s, matching the Python SDK. Encrypted inference cannot stream,
|
|
// so the whole completion arrives in one response — a long generation
|
|
// on a busy backend legitimately takes minutes.
|
|
timeout = 900000,
|
|
debug = false,
|
|
keyRotationInterval = 86400000, // 24 hours
|
|
keyRotationDir,
|
|
keyRotationPassword,
|
|
maxRetries = 2,
|
|
keyDir,
|
|
attestationPolicy,
|
|
quoteVerifier,
|
|
} = 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;
|
|
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) {
|
|
if (!allowHttp) {
|
|
console.warn(
|
|
'WARNING: Using HTTP instead of HTTPS. ' +
|
|
'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)');
|
|
}
|
|
}
|
|
|
|
// 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,
|
|
});
|
|
}
|
|
|
|
// Initialize components
|
|
this.keyManager = new KeyManager(this.debugMode);
|
|
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');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate RSA key pair
|
|
*/
|
|
async generateKeys(options: {
|
|
saveToFile?: boolean;
|
|
keyDir?: string;
|
|
password?: string;
|
|
} = {}): Promise<void> {
|
|
this.assertNotDisposed();
|
|
await this.keyManager.generateKeys({
|
|
keySize: this.keySize,
|
|
...options,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Load existing keys from files (Node.js only)
|
|
*/
|
|
async loadKeys(
|
|
privateKeyPath: string,
|
|
publicKeyPath?: string,
|
|
password?: string
|
|
): Promise<void> {
|
|
this.assertNotDisposed();
|
|
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.
|
|
*/
|
|
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;
|
|
|
|
// 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;
|
|
}
|
|
|
|
// 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 };
|
|
|
|
const privateKeyPath = path.join(this.keyDir, 'private_key.pem');
|
|
const publicKeyPath = path.join(this.keyDir, 'public_key.pem');
|
|
|
|
await fs.access(privateKeyPath);
|
|
await fs.access(publicKeyPath);
|
|
|
|
await this.loadKeys(privateKeyPath, publicKeyPath);
|
|
if (this.debugMode) console.log(`Loaded existing keys from ${this.keyDir}/`);
|
|
return;
|
|
} catch (_error) {
|
|
if (this.debugMode) console.log(`No existing keys found in ${this.keyDir}/, generating new keys...`);
|
|
}
|
|
|
|
await this.generateKeys({
|
|
saveToFile: true,
|
|
keyDir: this.keyDir,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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...");
|
|
|
|
if (!this._isHttps) {
|
|
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 })'
|
|
);
|
|
} 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 });
|
|
|
|
if (response.statusCode === 200) {
|
|
const serverPublicKey = arrayBufferToString(response.body);
|
|
|
|
// Validate it's a valid PEM key
|
|
try {
|
|
await this.rsa.importPublicKey(serverPublicKey);
|
|
} catch (_error) {
|
|
throw formatError('Server returned invalid public key format');
|
|
}
|
|
|
|
if (this._isHttps) {
|
|
if (this.debugMode) console.log("Server's public key fetched securely over HTTPS");
|
|
} else {
|
|
console.warn("Server's public key fetched over HTTP (INSECURE)");
|
|
}
|
|
|
|
return serverPublicKey;
|
|
} else {
|
|
throw formatError(`Failed to fetch server's public key: HTTP ${response.statusCode}`);
|
|
}
|
|
} catch (error) {
|
|
if (error instanceof SecurityError) {
|
|
throw error;
|
|
}
|
|
// A bad key or a bad status is a server-response problem, not a
|
|
// connection problem — mirrors Python raising ValueError here and
|
|
// reserving ConnectionError for transport failures.
|
|
if (isFormatError(error)) {
|
|
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
|
|
*
|
|
* @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.
|
|
*/
|
|
async encryptPayload(payload: object, serverPublicKeyPem?: string): Promise<ArrayBuffer> {
|
|
this.assertNotDisposed();
|
|
|
|
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
|
|
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`);
|
|
|
|
if (this.secureMemory) {
|
|
const context = new SecureByteContext(payloadBytes, true);
|
|
return await context.use(async (protectedPayload) => {
|
|
return await this.performEncryption(protectedPayload, serverPublicKeyPem);
|
|
});
|
|
} else {
|
|
return await this.performEncryption(payloadBytes, serverPublicKeyPem);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
private async performEncryption(
|
|
payloadBytes: ArrayBuffer,
|
|
serverPublicKeyPem?: string
|
|
): Promise<ArrayBuffer> {
|
|
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);
|
|
|
|
const keyPem = serverPublicKeyPem ?? await this.fetchServerPublicKey();
|
|
const serverPublicKey = await this.rsa.importPublicKey(keyPem);
|
|
const encryptedAesKey = await this.rsa.encryptKey(protectedAesKey, serverPublicKey);
|
|
|
|
const encryptedPackage = {
|
|
version: '1.0',
|
|
algorithm: 'hybrid-aes256-rsa4096',
|
|
encrypted_payload: {
|
|
ciphertext: arrayBufferToBase64(ciphertextOnly.buffer),
|
|
nonce: arrayBufferToBase64(nonce),
|
|
tag: arrayBufferToBase64(tag.buffer),
|
|
},
|
|
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`);
|
|
|
|
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.
|
|
*/
|
|
async decryptResponse(encryptedResponse: ArrayBuffer, payloadId: string): Promise<Record<string, unknown>> {
|
|
this.assertNotDisposed();
|
|
|
|
if (!encryptedResponse || encryptedResponse.byteLength === 0) {
|
|
throw new Error('Empty encrypted response');
|
|
}
|
|
|
|
let packageData: Record<string, unknown>;
|
|
try {
|
|
const packageJson = arrayBufferToString(encryptedResponse);
|
|
packageData = JSON.parse(packageJson) as Record<string, unknown>;
|
|
} catch (_error) {
|
|
throw new Error('Invalid encrypted package format: malformed JSON');
|
|
}
|
|
|
|
// Validate top-level structure
|
|
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'];
|
|
for (const field of payloadRequired) {
|
|
if (!(field in encryptedPayload)) {
|
|
throw new Error(`Missing field in encrypted_payload: ${field}`);
|
|
}
|
|
}
|
|
|
|
// Guard: the private key must exist before we attempt decryption, so a
|
|
// missing key reports itself instead of hiding behind the opaque
|
|
// integrity-failure message below (matches Python).
|
|
if (!this.keyManager.hasKeys()) {
|
|
throw new SecurityError(
|
|
'Private key not initialized. Call generateKeys() or loadKeys() first.'
|
|
);
|
|
}
|
|
|
|
try {
|
|
const encryptedAesKey = base64ToArrayBuffer(packageData.encrypted_aes_key as string);
|
|
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);
|
|
|
|
const plaintext = await this.aes.decrypt(combined.buffer, nonce, aesKey);
|
|
|
|
const plaintextContext = new SecureByteContext(plaintext, this.secureMemory);
|
|
return await plaintextContext.use(async (protectedPlaintext) => {
|
|
const responseJson = arrayBufferToString(protectedPlaintext);
|
|
|
|
let decoded: Record<string, unknown>;
|
|
try {
|
|
decoded = JSON.parse(responseJson) as Record<string, unknown>;
|
|
} catch (_parseError) {
|
|
// GCM already authenticated this plaintext, so bad JSON
|
|
// is the peer sending us garbage, not an integrity failure.
|
|
throw formatError('Decrypted response is not valid JSON');
|
|
}
|
|
|
|
// 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;
|
|
});
|
|
});
|
|
|
|
// Add metadata
|
|
const metadata = (response._metadata as Record<string, unknown>) ?? {};
|
|
response._metadata = {
|
|
...metadata,
|
|
payload_id: payloadId,
|
|
processed_at: packageData.processed_at,
|
|
is_encrypted: true,
|
|
encryption_algorithm: packageData.algorithm,
|
|
};
|
|
|
|
if (this.debugMode) console.log('Response decrypted successfully');
|
|
return response;
|
|
} catch (error) {
|
|
// Malformed-but-authenticated plaintext is a format problem; keep it
|
|
// distinguishable instead of reporting it as an integrity failure.
|
|
if (isFormatError(error)) {
|
|
throw error;
|
|
}
|
|
// Our own schema rejection already carries a safe, specific message.
|
|
if (error instanceof SecurityError) {
|
|
throw error;
|
|
}
|
|
// Anything else is a genuine crypto failure — stay opaque (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"
|
|
*/
|
|
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...');
|
|
|
|
// 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');
|
|
}
|
|
}
|
|
|
|
await this.ensureKeys();
|
|
|
|
// 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);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
const url = `${this.routerUrl}/v1/chat/secure_completion`;
|
|
if (this.debugMode) console.log(`Target URL: ${url}`);
|
|
|
|
// 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...`
|
|
);
|
|
}
|
|
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)
|
|
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);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Build a typed error from an HTTP error response
|
|
*/
|
|
private buildErrorFromResponse(response: { statusCode: number; body: ArrayBuffer }): Error {
|
|
let errorData: Record<string, unknown> = {};
|
|
try {
|
|
const errorJson = arrayBufferToString(response.body);
|
|
errorData = JSON.parse(errorJson) as Record<string, unknown>;
|
|
} catch (_e) {
|
|
// 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, '');
|
|
|
|
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
|
|
);
|
|
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
|
|
);
|
|
default:
|
|
return new APIError(
|
|
`Unexpected status code: ${response.statusCode} ${detail}`,
|
|
response.statusCode,
|
|
errorData
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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.`
|
|
);
|
|
}
|
|
|
|
if (this.debugMode) console.log(`Valid ${algorithm.modulusLength}-bit RSA ${keyType} key`);
|
|
}
|
|
}
|
|
|
|
export { generateUUID };
|