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
|
|
@ -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. |
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
197
doc/attestation.md
Normal 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.
|
||||
|
|
@ -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
4
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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) */
|
||||
|
|
|
|||
213
tests/unit/error_fidelity.test.ts
Normal file
213
tests/unit/error_fidelity.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue