feat: align timeout, error types and docs with the Python SDK
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>
This commit is contained in:
Alpha Nerd 2026-07-19 12:17:52 +02:00
parent 00388ccb01
commit eafab3d9ac
Signed by: alpha-nerd
SSH key fingerprint: SHA256:QkkAgVoYi9TQ0UKPkiKSfnerZy2h4qhi3SVPXJmBN+M
10 changed files with 535 additions and 17 deletions

View file

@ -33,6 +33,27 @@ import {
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();
@ -93,7 +114,10 @@ export class SecureCompletionClient {
allowHttp = false,
secureMemory = true,
keySize = 4096,
timeout = 60000,
// 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,
@ -344,7 +368,7 @@ export class SecureCompletionClient {
try {
await this.rsa.importPublicKey(serverPublicKey);
} catch (_error) {
throw new Error('Server returned invalid public key format');
throw formatError('Server returned invalid public key format');
}
if (this._isHttps) {
@ -355,12 +379,18 @@ export class SecureCompletionClient {
return serverPublicKey;
} else {
throw new Error(`Failed to fetch server's public key: HTTP ${response.statusCode}`);
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}`);
}
@ -520,6 +550,15 @@ export class SecureCompletionClient {
}
}
// 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();
@ -543,7 +582,15 @@ export class SecureCompletionClient {
const plaintextContext = new SecureByteContext(plaintext, this.secureMemory);
return await plaintextContext.use(async (protectedPlaintext) => {
const responseJson = arrayBufferToString(protectedPlaintext);
const decoded = JSON.parse(responseJson) as Record<string, unknown>;
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 (
@ -575,7 +622,16 @@ export class SecureCompletionClient {
if (this.debugMode) console.log('Response decrypted successfully');
return response;
} catch (error) {
// Don't leak specific decryption errors (timing attacks)
// 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');
}
}

View file

@ -39,7 +39,7 @@ export interface ClientConfig extends AttestationConfig {
/** Optional API key for authentication */
apiKey?: string;
/** Request timeout in milliseconds (default: 60000) */
/** Request timeout in milliseconds (default: 900000 = 15 min, matching the Python SDK) */
timeout?: number;
/** Enable debug logging (default: false) */
@ -109,7 +109,7 @@ export interface ChatCompletionConfig extends AttestationConfig {
/** Enable secure memory protection */
secureMemory?: boolean;
/** Request timeout in milliseconds (default: 60000) */
/** Request timeout in milliseconds (default: 900000 = 15 min, matching the Python SDK) */
timeout?: number;
/** Enable debug logging (default: false) */