diff --git a/README.md b/README.md index 6c6eedc..0fe49c6 100644 --- a/README.md +++ b/README.md @@ -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. | diff --git a/doc/README.md b/doc/README.md index 8d2e4d9..97fe513 100644 --- a/doc/README.md +++ b/doc/README.md @@ -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). diff --git a/doc/api-reference.md b/doc/api-reference.md index e045544..4effc13 100644 --- a/doc/api-reference.md +++ b/doc/api-reference.md @@ -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`. diff --git a/doc/attestation.md b/doc/attestation.md new file mode 100644 index 0000000..5c37042 --- /dev/null +++ b/doc/attestation.md @@ -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: [''], + 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 { + // 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; +} +``` + +## 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: }` plus a JWKS endpoint. Veraison is an +open-source option. Run that one service and point `JwtQuoteVerifier` at it. diff --git a/doc/troubleshooting.md b/doc/troubleshooting.md index fee8eda..dc2422e 100644 --- a/doc/troubleshooting.md +++ b/doc/troubleshooting.md @@ -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 diff --git a/package-lock.json b/package-lock.json index 39e569a..3a7b6cc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 10da001..d3c8cf4 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/core/SecureCompletionClient.ts b/src/core/SecureCompletionClient.ts index 2c61c98..79cd5d3 100644 --- a/src/core/SecureCompletionClient.ts +++ b/src/core/SecureCompletionClient.ts @@ -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; + + let decoded: Record; + try { + decoded = JSON.parse(responseJson) as Record; + } 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'); } } diff --git a/src/types/client.ts b/src/types/client.ts index 1c78e61..649605a 100644 --- a/src/types/client.ts +++ b/src/types/client.ts @@ -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) */ diff --git a/tests/unit/error_fidelity.test.ts b/tests/unit/error_fidelity.test.ts new file mode 100644 index 0000000..0959553 --- /dev/null +++ b/tests/unit/error_fidelity.test.ts @@ -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 { + 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 { + await client.generateKeys(); + return (client as unknown as { + keyManager: { getPublicKeyPEM: () => Promise }; + }).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(); + }); +});