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
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:
parent
00388ccb01
commit
eafab3d9ac
10 changed files with 535 additions and 17 deletions
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue