nomyo-js/tests/unit/error_fidelity.test.ts
alpha nerd eafab3d9ac
All checks were successful
NYX Security Scan / nyx-scan (pull_request) Successful in 5m43s
feat: align timeout, error types and docs with the Python SDK
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>
2026-07-19 12:17:52 +02:00

213 lines
8 KiB
TypeScript

/**
* Unit tests for error-type fidelity with the Python SDK.
*
* Python distinguishes malformed *data* (ValueError) from a failed integrity
* check (SecurityError). The JS port previously collapsed both into
* SecurityError, which hid real bugs behind a security-sounding message.
* Malformed data now surfaces as a plain Error; only genuine crypto failures
* stay opaque.
*/
import { SecureCompletionClient } from '../../src/core/SecureCompletionClient';
import { SecurityError, APIConnectionError } from '../../src/errors';
import { AESEncryption } from '../../src/core/crypto/encryption';
import { RSAOperations } from '../../src/core/crypto/rsa';
import {
arrayBufferToBase64,
stringToArrayBuffer,
} from '../../src/core/crypto/utils';
const TAG_LENGTH = 16;
/** Build a wire-format encrypted package addressed to `pubPem`. */
async function sealTo(pubPem: string, plaintext: Uint8Array): Promise<ArrayBuffer> {
const aes = new AESEncryption();
const rsa = new RSAOperations();
const aesKey = await aes.generateKey();
const rawKey = await aes.exportKey(aesKey);
const { ciphertext, nonce } = await aes.encrypt(
plaintext.buffer.slice(
plaintext.byteOffset,
plaintext.byteOffset + plaintext.byteLength
) as ArrayBuffer,
aesKey
);
// Web Crypto appends the GCM tag; the wire format keeps them separate
const combined = new Uint8Array(ciphertext);
const ct = combined.slice(0, combined.length - TAG_LENGTH);
const tag = combined.slice(combined.length - TAG_LENGTH);
const serverKey = await rsa.importPublicKey(pubPem);
const encryptedAesKey = await rsa.encryptKey(rawKey, serverKey);
return stringToArrayBuffer(JSON.stringify({
version: '1.0',
algorithm: 'hybrid-aes256-rsa4096',
encrypted_payload: {
ciphertext: arrayBufferToBase64(ct.buffer),
nonce: arrayBufferToBase64(nonce),
tag: arrayBufferToBase64(tag.buffer),
},
encrypted_aes_key: arrayBufferToBase64(encryptedAesKey),
key_algorithm: 'RSA-OAEP-SHA256',
payload_algorithm: 'AES-256-GCM',
}));
}
function newClient(): SecureCompletionClient {
return new SecureCompletionClient({
routerUrl: 'https://router.test',
keySize: 2048,
keyRotationInterval: 0,
});
}
async function withKeys(client: SecureCompletionClient): Promise<string> {
await client.generateKeys();
return (client as unknown as {
keyManager: { getPublicKeyPEM: () => Promise<string> };
}).keyManager.getPublicKeyPEM();
}
describe('decryptResponse error types', () => {
test('authenticated-but-unparseable plaintext is a format error, not a security error', async () => {
const client = newClient();
const pubPem = await withKeys(client);
const sealed = await sealTo(pubPem, new TextEncoder().encode('not json at all'));
const error = await client.decryptResponse(sealed, 'pid').catch((e: unknown) => e);
expect(error).toBeInstanceOf(Error);
expect(error).not.toBeInstanceOf(SecurityError);
expect((error as Error).message).toBe('Decrypted response is not valid JSON');
client.dispose();
});
test('valid JSON of the wrong shape is reported as a schema failure', async () => {
const client = newClient();
const pubPem = await withKeys(client);
const sealed = await sealTo(pubPem, new TextEncoder().encode('{"not":"a completion"}'));
await expect(client.decryptResponse(sealed, 'pid'))
.rejects.toThrow('does not conform to expected schema');
client.dispose();
});
test('a tampered ciphertext stays opaque', async () => {
const client = newClient();
const pubPem = await withKeys(client);
const sealed = await sealTo(pubPem, new TextEncoder().encode('{"a":1}'));
// Flip a byte of the ciphertext so GCM authentication fails
const pkg = JSON.parse(new TextDecoder().decode(sealed)) as {
encrypted_payload: { ciphertext: string };
};
const raw = Buffer.from(pkg.encrypted_payload.ciphertext, 'base64');
raw[0] ^= 0xff;
pkg.encrypted_payload.ciphertext = raw.toString('base64');
const error = await client
.decryptResponse(stringToArrayBuffer(JSON.stringify(pkg)), 'pid')
.catch((e: unknown) => e);
expect(error).toBeInstanceOf(SecurityError);
// No detail about which stage failed
expect((error as Error).message)
.toBe('Decryption failed: integrity check or authentication failed');
client.dispose();
});
test('decrypting without keys reports the missing key, not an integrity failure', async () => {
const client = newClient();
const other = newClient();
const pubPem = await withKeys(other);
const sealed = await sealTo(pubPem, new TextEncoder().encode('{"a":1}'));
const error = await client.decryptResponse(sealed, 'pid').catch((e: unknown) => e);
expect(error).toBeInstanceOf(SecurityError);
expect((error as Error).message).toMatch(/Private key not initialized/);
client.dispose();
other.dispose();
});
test('a malformed package is rejected before any crypto runs', async () => {
const client = newClient();
await withKeys(client);
await expect(
client.decryptResponse(stringToArrayBuffer('{"version":"1.0"}'), 'pid')
).rejects.toThrow('Missing required field');
client.dispose();
});
});
describe('fetchServerPublicKey error types', () => {
function clientWithResponse(response: { statusCode: number; body: ArrayBuffer }) {
const client = newClient();
(client as unknown as { httpClient: unknown }).httpClient = {
get: jest.fn(async () => ({ ...response, headers: {} })),
post: jest.fn(),
};
return client;
}
test('a non-200 is a server-response problem, not a connection problem', async () => {
const client = clientWithResponse({
statusCode: 500,
body: stringToArrayBuffer('boom'),
});
const error = await client.fetchServerPublicKey().catch((e: unknown) => e);
expect(error).toBeInstanceOf(Error);
expect(error).not.toBeInstanceOf(APIConnectionError);
expect((error as Error).message).toMatch(/HTTP 500/);
client.dispose();
});
test('an unparseable key is a format problem, not a connection problem', async () => {
const client = clientWithResponse({
statusCode: 200,
body: stringToArrayBuffer('-----BEGIN PUBLIC KEY-----\nnonsense\n-----END PUBLIC KEY-----'),
});
const error = await client.fetchServerPublicKey().catch((e: unknown) => e);
expect(error).toBeInstanceOf(Error);
expect(error).not.toBeInstanceOf(APIConnectionError);
expect((error as Error).message).toBe('Server returned invalid public key format');
client.dispose();
});
test('a transport failure is still a connection error', async () => {
const client = newClient();
(client as unknown as { httpClient: unknown }).httpClient = {
get: jest.fn(async () => { throw new Error('ECONNREFUSED'); }),
post: jest.fn(),
};
await expect(client.fetchServerPublicKey()).rejects.toBeInstanceOf(APIConnectionError);
client.dispose();
});
});
describe('defaults', () => {
test('request timeout matches the Python SDK (900 s)', () => {
const client = newClient();
expect((client as unknown as { requestTimeout: number }).requestTimeout).toBe(900000);
client.dispose();
});
test('an explicit timeout still wins', () => {
const client = new SecureCompletionClient({
routerUrl: 'https://router.test',
keyRotationInterval: 0,
timeout: 5000,
});
expect((client as unknown as { requestTimeout: number }).requestTimeout).toBe(5000);
client.dispose();
});
});