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

@ -228,7 +228,7 @@ new SecureChatCompletion(config?: ChatCompletionConfig)
| `allowHttp` | `boolean` | `false` | Allow HTTP connections. Local development only. |
| `apiKey` | `string` | `undefined` | Bearer token for `Authorization` header. |
| `secureMemory` | `boolean` | `true` | Zero sensitive buffers immediately after use. |
| `timeout` | `number` | `60000` | Request timeout in milliseconds. |
| `timeout` | `number` | `900000` | Request timeout in milliseconds (15 min, matching the Python SDK). |
| `debug` | `boolean` | `false` | Print verbose logging to the console. |
| `keyDir` | `string \| null` | `null` | Directory to load/save RSA keys on startup. Omit for ephemeral in-memory keys that are never written to disk. Node.js only. |
| `keyRotationInterval` | `number` | `86400000` | Auto-rotate keys every N ms. `0` disables rotation. |

View file

@ -27,9 +27,10 @@ console.log(response.choices[0].message.content);
3. [API Reference](api-reference.md) — complete constructor options, methods, and types
4. [Models](models.md) — available models and selection guidance
5. [Security Guide](security-guide.md) — encryption architecture, best practices, and compliance
6. [Rate Limits](rate-limits.md) — request limits, burst behaviour, and retry strategy
7. [Examples](examples.md) — real-world scenarios, browser usage, and advanced patterns
8. [Troubleshooting](troubleshooting.md) — common errors and their fixes
6. [Attestation](attestation.md) — SGX enclave verification for `high`/`maximum` tiers
7. [Rate Limits](rate-limits.md) — request limits, burst behaviour, and retry strategy
8. [Examples](examples.md) — real-world scenarios, browser usage, and advanced patterns
9. [Troubleshooting](troubleshooting.md) — common errors and their fixes
---
@ -38,7 +39,8 @@ console.log(response.choices[0].message.content);
- **End-to-end encryption** — AES-256-GCM + RSA-OAEP-4096. No plaintext ever leaves your process.
- **OpenAI-compatible API**`create()` / `acreate()` accept the same parameters as the OpenAI SDK.
- **Browser + Node.js** — single package, separate entry points for each runtime.
- **Automatic key management** — keys are generated on first use and optionally persisted to disk (Node.js).
- **Ephemeral key management** — a fresh key pair is generated in memory on first use and never written to disk unless you set `keyDir` (Node.js).
- **SGX attestation** — optionally prove the server key was generated inside a genuine enclave *before* sending plaintext ([details](attestation.md)).
- **Automatic key rotation** — RSA keys rotate on a configurable interval (default 24 h) to limit fingerprint lifetime.
- **Security tiers** — per-request routing to `standard`, `high`, or `maximum` isolation hardware.
- **Retry with exponential backoff** — automatic retries on 429 / 5xx / network errors (configurable).

View file

@ -19,13 +19,15 @@ new SecureChatCompletion(config?: ChatCompletionConfig)
| `allowHttp` | `boolean` | `false` | Allow HTTP connections.**Local development only.** |
| `apiKey` | `string` | `undefined` | Bearer token sent in`Authorization` header. |
| `secureMemory` | `boolean` | `true` | Enable immediate zeroing of sensitive buffers after use. |
| `timeout` | `number` | `60000` | Request timeout in milliseconds. |
| `timeout` | `number` | `900000` (15 min) | Request timeout in milliseconds. Encrypted inference cannot stream, so the whole completion arrives in one response; long generations legitimately take minutes. |
| `debug` | `boolean` | `false` | Print verbose logging to the console. |
| `keyDir` | `string \| null` | `null` | Directory to load/save RSA keys on startup. If the directory contains an existing key pair it is loaded; otherwise a new pair is generated and saved there. Omit (or pass `null`) for ephemeral keys generated in memory for this session only and never written to disk — this is the default, matching the Python SDK. Node.js only; browsers are always ephemeral. |
| `keyRotationInterval` | `number` | `86400000` (24 h) | Auto-rotate RSA keys every N milliseconds. Set to`0` to disable. |
| `keyRotationDir` | `string` | value of `keyDir` | Directory where rotated key files are saved. When neither this nor `keyDir` is set, rotated keys stay in memory. Node.js only. |
| `keyRotationPassword` | `string` | `undefined` | Password used to encrypt rotated key files. |
| `maxRetries` | `number` | `2` | Maximum extra attempts on retryable errors (429, 500, 502, 503, 504, network errors). Uses exponential backoff (1 s, 2 s, …). Set to`0` to disable retries. |
| `attestationPolicy` | `AttestationPolicy` | `undefined` | Enables SGX attestation verification before any plaintext is sent. Omit to disable. See [Attestation](attestation.md). |
| `quoteVerifier` | `QuoteVerifier` | `undefined` | Performs the DCAP quote verification (e.g.`JwtQuoteVerifier`). Required for a policy to actually verify; without one, a set policy treats attestation as unverifiable. |
### Methods
@ -251,6 +253,7 @@ import {
ServiceUnavailableError,
APIConnectionError,
SecurityError,
AttestationError,
DisposedError,
} from 'nomyo-js';
```
@ -267,6 +270,36 @@ import {
| `APIError` | varies | Other HTTP errors (404, 502, 504, etc.) |
| `APIConnectionError` | — | Network failure or timeout (after all retries exhausted) |
| `SecurityError` | — | HTTPS not used, header injection detected, or crypto failure |
| `AttestationError` | — | SGX attestation failed under`enforce` (extends `SecurityError`) |
| `DisposedError` | — | Method called after`dispose()` |
All errors that extend `APIError` expose `statusCode?: number` and `errorDetails?: object`.
### Malformed data vs security failures
A plain `Error` — not a `SecurityError` — is thrown when a response is
structurally wrong rather than cryptographically suspect: a missing package
field, an unsupported protocol version, a non-200 or unparseable
`/pki/public_key` response, or plaintext that decrypted and authenticated
successfully but is not valid JSON.
`SecurityError` is reserved for actual security conditions, and stays
deliberately vague about which stage failed so it cannot be used as a decryption
oracle:
```typescript
try {
await client.create({ model, messages });
} catch (error) {
if (error instanceof SecurityError) {
// integrity/authentication failure, HTTPS violation, or failed attestation
} else if (error instanceof APIError) {
// server responded with an HTTP error
} else {
// malformed response data
}
}
```
This mirrors the Python SDK, which raises `ValueError` where this client throws
a plain `Error`.

197
doc/attestation.md Normal file
View file

@ -0,0 +1,197 @@
# SGX enclave attestation (operator-blind verification)
For the `high` and `maximum` security tiers, the NOMYO server runs CPU inference
inside an Intel SGX enclave. SGX **attestation** lets the client prove — *before
sending any plaintext* — that the RSA public key it is about to encrypt to was
generated inside a genuine enclave running the expected (measured) code. This
upgrades the channel from "encrypted in transit, operator-trusted" to
"operator-blind": only the enclave can decrypt, not the host operator.
This is **opt-in** and **additive**. With no attestation configured the client
behaves exactly as before.
## How it works
On each secure request for a tier that requires attestation, the client:
1. Fetches the server public key (`GET /pki/public_key`).
2. Fetches a hardware-signed DCAP quote (`GET /pki/attestation`).
3. **Verifies the quote** (ECDSA signature + Intel PCK chain + TCB) via a
`QuoteVerifier` you provide (step 4).
4. Checks policy: `tcbStatus`, pinned `MRENCLAVE` (and optionally `MRSIGNER` /
`prodId` / minimum `isvSvn`).
5. Checks the **key binding**: `SHA-512(server_pubkey_der [|| challenge])` must
equal the `reportData` inside the verified quote (constant-time compare).
6. Only if all checks pass does it encrypt the payload **to that exact attested
key** and send it. In `enforce` mode any failure throws and nothing is sent.
The key fetched in step 1 is the key used in step 6 — it is not re-fetched
between verification and encryption, so there is no window in which an attested
key could be swapped for another.
## Quote verification (`QuoteVerifier`)
Verifying a DCAP quote must not be hand-rolled. The client delegates step 4 to a
`QuoteVerifier`. Three ways to supply one:
### `JwtQuoteVerifier` (recommended)
Talks to a remote verification service that returns a signed JWT of appraised
claims — Intel Trust Authority, [Veraison](https://github.com/veraison), or a
self-hosted wrapper around the Intel DCAP Quote Verification Library (QVL). The
native/PCCS dependency lives on that one service, not on every client.
Requires the optional peer dependency:
```bash
npm install jose
```
```typescript
import {
SecureChatCompletion,
AttestationPolicy,
JwtQuoteVerifier,
} from 'nomyo-js';
const verifier = new JwtQuoteVerifier(
'https://attest.example.com/appraisal/v1/attest', // verifyUrl
'https://attest.example.com/certs', // jwksUrl
{
issuer: 'https://attest.example.com', // optional
audience: 'nomyo-client', // optional
// claimMap: { ... } // override if your service uses different claim names
},
);
const policy = new AttestationPolicy({
pinnedMrenclave: ['<hex from gramine/build-enclave.sh>'],
allowedTcbStatuses: ['UpToDate'],
enforce: false, // rollout: warn and proceed. Set true to hard-fail.
});
const client = new SecureChatCompletion({
baseUrl: 'https://api.nomyo.ai',
apiKey: process.env.NOMYO_API_KEY,
attestationPolicy: policy,
quoteVerifier: verifier,
});
const response = await client.create({
model: 'Qwen/Qwen3-0.6B',
messages: [{ role: 'user', content: 'Hello!' }],
security_tier: 'high',
});
```
The default JWT claim names are in `DEFAULT_CLAIM_MAP`; override `claimMap` if
your service differs (e.g. Veraison vs ITA).
#### CommonJS and ESM-only `jose`
`jose` v5+ is ESM-only. Under CommonJS, `require('jose')` fails, and this client
deliberately does **not** work around that with a runtime-constructed dynamic
import: that requires `new Function`/`eval`, which any Content-Security-Policy
without `unsafe-eval` blocks — and the failure would land in the attestation
path in browsers. Import the module yourself and pass it in instead:
```typescript
import * as jose from 'jose';
const verifier = new JwtQuoteVerifier(verifyUrl, jwksUrl, { jose });
```
Unlike the Python client there is no `verify_ssl` escape hatch: TLS certificate
verification against the appraisal service is always enforced.
### `CallableQuoteVerifier` — bring your own
```typescript
import { CallableQuoteVerifier, VerifiedQuote } from 'nomyo-js';
async function myVerify(quote: Uint8Array): Promise<VerifiedQuote> {
// call your verifier (local QVL binding, another service, ...)
return {
mrenclave: '...',
mrsigner: '...',
isvProdId: 1,
isvSvn: 3,
tcbStatus: 'UpToDate',
reportData: new Uint8Array(64),
};
}
const client = new SecureChatCompletion({
attestationPolicy: policy,
quoteVerifier: new CallableQuoteVerifier(myVerify),
});
```
`myVerify` may be sync or async and must throw on any verification failure.
### Any `QuoteVerifier`
Implement the interface directly:
```typescript
interface QuoteVerifier {
verify(quote: Uint8Array): Promise<VerifiedQuote>;
}
```
## Policy (`AttestationPolicy`)
| Option | Meaning | Default |
|---|---|---|
| `pinnedMrenclave` | Allowed enclave measurements (a **set** so you can roll builds) | `[]` (not verified) |
| `pinnedMrsigner` | Allowed signer (optional) | `undefined` |
| `pinnedProdId` / `minIsvSvn` | Product id / minimum security version (optional) | `undefined` |
| `allowedTcbStatuses` | Accepted platform TCB levels | `['UpToDate']` |
| `enforce` | Hard-fail vs warn-and-proceed | `false` |
| `requireAttestationForTier` | Tiers that must be operator-blind | `['high', 'maximum']` |
| `liveness` | Use a fresh per-request challenge (see below) | `false` |
Hex values are compared case-insensitively; pins are lowercased on construction.
Pin MRENCLAVE values as **configuration**, not constants — they change whenever
the enclave build or its file paths change. Printed by `gramine/build-enclave.sh`
at sign time. With no `pinnedMrenclave` the client logs a warning on every
verification, because enclave identity is then not actually being checked. Do
**not** silently allow `OutOfDate` or `Revoked` TCB statuses.
## Rollout vs enforce
- **`standard` tier:** attestation not expected — proceeds normally, and no
attestation request is made at all.
- **`high`/`maximum`, `enforce: false` (rollout, default):** if attestation is
unavailable (server not yet under SGX) or fails, a loud warning is logged and
the request proceeds. Lets you deploy clients before SGX is live everywhere.
- **`high`/`maximum`, `enforce: true` (target):** unavailable or failed
attestation ⇒ the request is refused with `AttestationError`, **no plaintext
fallback**. The payload is never even constructed.
`AttestationError` extends `SecurityError`, so existing
`catch (e) { if (e instanceof SecurityError) ... }` blocks treat a failed
attestation as the security failure it is.
Fetching attestation over plain HTTP throws regardless of `enforce` — a
MITM-able channel defeats the purpose. Use `allowHttp: true` only for local
development.
## Liveness (optional)
The default quote is bound to the key only and may be cached server-side (proves
binding, not recency). Set `liveness: true` to send a random 32-byte challenge
per handshake; the server returns a fresh, uncached quote bound to
`SHA-512(pubkey_der || challenge)`, proving the enclave is alive *now*.
Liveness bypasses the client-side verification cache, so every request performs a
full round trip and quote verification. Without it, a successful verification is
cached per server public key for the session and re-verified if that key changes.
## Self-hosting the verifier
A self-hosted equivalent of Intel Trust Authority is a small service that runs
the Intel DCAP QVL (`libsgx_dcap_quoteverify`) with a reachable PCCS, and exposes
`POST /verify {quote} -> {token: <JWT>}` plus a JWKS endpoint. Veraison is an
open-source option. Run that one service and point `JwtQuoteVerifier` at it.

View file

@ -48,7 +48,7 @@ Never set `allowHttp: true` in production — the server public key fetch and al
### `APIConnectionError: Request timed out`
The default timeout is 60 seconds. Larger models or busy endpoints may need more:
The default timeout is 900 seconds (15 minutes), matching the Python SDK. Encrypted inference cannot stream, so the entire completion arrives in a single response. Lower it if you want to fail faster:
```javascript
const client = new SecureChatCompletion({
@ -205,6 +205,23 @@ const info = getMemoryProtectionInfo();
For environments where swap-file exposure is unacceptable (HIPAA PHI, classified data), install the optional `nomyo-native` addon or run on a system with swap disabled.
### `method: 'zero-only'` even with the native addon installed
`canLock` is probed, not assumed — the addon being loaded does not mean the OS
will grant locks. `mlock` is commonly refused by `RLIMIT_MEMLOCK`, which
defaults to a few megabytes and is `0` in some container runtimes. When that
happens `details` says so explicitly, and buffers are still zeroed.
```bash
ulimit -l # current memlock limit in KB; "unlimited" or a large value is what you want
```
Raise the limit (`ulimit -l`, systemd `LimitMEMLOCK=`, or Docker
`--ulimit memlock=-1`), or grant `CAP_IPC_LOCK`.
Note also that npm 11 gates package install scripts, so a fresh `npm ci` may skip
building the addon entirely until you run `npm approve-scripts`.
---
## Node.js-Specific Issues

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "nomyo-js",
"version": "0.1.0",
"version": "0.3.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "nomyo-js",
"version": "0.1.0",
"version": "0.3.0",
"license": "Apache-2.0",
"devDependencies": {
"@rollup/plugin-commonjs": "^29.0.0",

View file

@ -1,6 +1,6 @@
{
"name": "nomyo-js",
"version": "0.1.0",
"version": "0.3.0",
"description": "OpenAI-compatible secure chat client with end-to-end encryption",
"main": "dist/node/index.js",
"browser": "dist/browser/index.js",

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) */

View file

@ -0,0 +1,213 @@
/**
* 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();
});
});