Compare commits

...
Sign in to create a new pull request.

10 commits

Author SHA1 Message Date
0fe5040a5f
doc: correcting dead links 2026-04-17 18:31:28 +02:00
c7af0d7726
doc: fixing SECURITY.md in correct folder 2026-04-17 18:29:14 +02:00
e965b82e97 LICENSE aktualisiert 2026-04-16 16:47:13 +02:00
30e33e4fa1 docs/SECURITY.md gelöscht 2026-04-16 16:46:10 +02:00
43165f86f2
fix: base_url
doc: created
2026-04-16 16:44:26 +02:00
6e02559f4e
fix: imports and SecureByteContext in node 2026-04-16 15:40:41 +02:00
76b2a284d5
feat:
- added retry logic with exponential backoff
- per request base_url setting
- configurable key_dir
- protocol downgrade protection
- public secure memory API
2026-04-16 15:36:20 +02:00
3b1792e613 doc: update for latest changes 2026-04-01 15:15:18 +02:00
d9d2ec98db fix:
Added DisposedError
Wrapped zeroMemory() in its own try/catch in finally
Generic error message; fixed ArrayBufferLike TypeScript type issue
Generic error message; password/salt/IV wrapped in SecureByteContext
Password ≥8 chars enforced; zeroKeys(); rotateKeys(); debug-gated logs; TS type fix
Zero source ArrayBuffer after req.write()
Added timeout, debug, keyRotationInterval, keyRotationDir, keyRotationPassword
dispose(), assertNotDisposed(), startKeyRotationTimer(), rotateKeys(); Promise-mutex on ensureKeys(); new URL() validation; CR/LF API key check; server error detail truncation; response schema validation; all console.log behind debugMode
Propagates new config fields; dispose()
Tests for dispose, timer, header injection, URL validation, error sanitization, debug flag
Tests for generic error messages, password validation, zeroKeys()
2026-04-01 14:28:05 +02:00
76703e2e3e fix: base_url port
feat: add types for reasoning_content and _metadata

fix: key format incompatibility
2026-04-01 13:38:45 +02:00
29 changed files with 3036 additions and 433 deletions

3
.gitignore vendored
View file

@ -7,3 +7,6 @@ coverage/
.nyc_output/ .nyc_output/
build/ build/
*.node *.node
settings.json
*.pem
client_keys/

11
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,11 @@
# Contributing
## Development Setup
```bash
npm install
npm run build
npm test
```
Node.js 18 LTS or later is required for tests and the TypeScript compiler.

View file

@ -186,7 +186,7 @@
same "printed page" as the copyright notice for easier same "printed page" as the copyright notice for easier
identification within third-party archives. identification within third-party archives.
Copyright 2025 OpenAI Copyright 2025 NOMYO LLC
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.

497
README.md
View file

@ -1,13 +1,13 @@
# NOMYO.js - Secure JavaScript Chat Client # NOMYO.js Secure JavaScript Chat Client
**OpenAI-compatible secure chat client with end-to-end encryption for NOMYO Inference Endpoints** **OpenAI-compatible secure chat client with end-to-end encryption for NOMYO Inference Endpoints**
🔒 **All prompts and responses are automatically encrypted and decrypted** - All prompts and responses are automatically encrypted and decrypted
🔑 **Uses hybrid encryption (AES-256-GCM + RSA-OAEP with 4096-bit keys)** - Hybrid encryption: AES-256-GCM payload + RSA-OAEP-SHA256 key exchange, 4096-bit keys
🔄 **Drop-in replacement for OpenAI's ChatCompletion API** - Drop-in replacement for OpenAI's ChatCompletion API
🌐 **Works in both Node.js and browsers** - Works in both Node.js and browsers
## 🚀 Quick Start ## Quick Start
### Installation ### Installation
@ -20,330 +20,323 @@ npm install nomyo-js
```javascript ```javascript
import { SecureChatCompletion } from 'nomyo-js'; import { SecureChatCompletion } from 'nomyo-js';
// Initialize client (defaults to https://api.nomyo.ai:12434)
const client = new SecureChatCompletion({ const client = new SecureChatCompletion({
baseUrl: 'https://api.nomyo.ai:12434' apiKey: process.env.NOMYO_API_KEY,
}); });
// Simple chat completion
const response = await client.create({ const response = await client.create({
model: 'Qwen/Qwen3-0.6B', model: 'Qwen/Qwen3-0.6B',
messages: [ messages: [{ role: 'user', content: 'Hello!' }],
{ role: 'user', content: 'Hello! How are you today?' } temperature: 0.7,
],
temperature: 0.7
}); });
console.log(response.choices[0].message.content); console.log(response.choices[0].message.content);
client.dispose();
``` ```
### Basic Usage (Browser) ### Basic Usage (Browser)
```html ```html
<!DOCTYPE html> <script type="module">
<html> import { SecureChatCompletion } from 'https://unpkg.com/nomyo-js/dist/browser/index.js';
<head>
<script type="module">
import { SecureChatCompletion } from 'https://unpkg.com/nomyo-js/dist/browser/index.js';
const client = new SecureChatCompletion({ const client = new SecureChatCompletion({
baseUrl: 'https://api.nomyo.ai:12434' baseUrl: 'https://api.nomyo.ai',
}); apiKey: 'your-api-key',
});
const response = await client.create({ const response = await client.create({
model: 'Qwen/Qwen3-0.6B', model: 'Qwen/Qwen3-0.6B',
messages: [ messages: [{ role: 'user', content: 'What is 2+2?' }],
{ role: 'user', content: 'What is 2+2?' } });
]
});
console.log(response.choices[0].message.content); console.log(response.choices[0].message.content);
</script> </script>
</head>
<body>
<h1>NOMYO Secure Chat</h1>
</body>
</html>
``` ```
## 🔐 Security Features ## Documentation
Full documentation is in the [`doc/`](doc/) directory:
- [Getting Started](doc/getting-started.md) — walkthrough for new users
- [API Reference](doc/api-reference.md) — complete constructor options, methods, types, and error classes
- [Models](doc/models.md) — available models and selection guide
- [Security Guide](doc/security-guide.md) — encryption, memory protection, key management, compliance
- [Rate Limits](doc/rate-limits.md) — limits, automatic retry behaviour, batch throttling
- [Examples](doc/examples.md) — 12+ code examples for common scenarios
- [Troubleshooting](doc/troubleshooting.md) — error reference and debugging tips
## Security Features
### Hybrid Encryption ### Hybrid Encryption
-**Payload encryption**: AES-256-GCM (authenticated encryption) - **Payload encryption**: AES-256-GCM (authenticated encryption)
- **Key exchange**: RSA-OAEP with SHA-256 - **Key exchange**: RSA-OAEP-SHA256
- **Key size**: 4096-bit RSA keys - **Key size**: 4096-bit RSA keys
- **All communication**: End-to-end encrypted - **Scope**: All communication is end-to-end encrypted
### Key Management ### Key Management
- **Automatic key generation**: Keys are automatically generated on first use - **Automatic**: Keys are generated on first use and saved to `keyDir` (default: `client_keys/`). Existing keys are reloaded on subsequent runs. Node.js only.
- **Automatic key loading**: Existing keys are loaded automatically from `client_keys/` directory (Node.js only) - **Password protection**: Optional AES-encrypted private key files (minimum 8 characters).
- **No manual intervention required**: The library handles key management automatically - **Secure permissions**: Private key files saved at `0600` (owner-only).
- **Optional persistence**: Keys can be saved to `client_keys/` directory for reuse across sessions (Node.js only) - **Auto-rotation**: Keys rotate every 24 hours by default (configurable via `keyRotationInterval`).
- **Password protection**: Optional password encryption for private keys (recommended for production) - **Explicit lifecycle**: Call `dispose()` to zero in-memory key material and stop the rotation timer.
- **Secure permissions**: Private keys stored with restricted permissions (600 - owner-only access)
### Secure Memory Protection ### Secure Memory
> [!NOTE] The library wraps all intermediate sensitive buffers (AES keys, plaintext payload, decrypted bytes) in `SecureByteContext`, which zeroes them in a `finally` block immediately after use.
> **Pure JavaScript Implementation**: This version uses pure JavaScript with immediate memory zeroing.
> OS-level memory locking (`mlock`) is NOT available without a native addon.
> For enhanced security in production, consider implementing the optional native addon (see `native/` directory).
- **Automatic cleanup**: Sensitive data is zeroed from memory immediately after use Pure JavaScript cannot lock pages to prevent OS swapping (`mlock`). For environments where swap-file exposure is unacceptable, install the optional `nomyo-native` addon. Check the current protection level:
- **Best-effort protection**: Minimizes exposure time of sensitive data
- **Fallback mechanism**: Graceful degradation if enhanced security is unavailable
## 🔄 OpenAI Compatibility
The `SecureChatCompletion` class provides **exact API compatibility** with OpenAI's `ChatCompletion.create()` method.
### Supported Parameters
All standard OpenAI parameters are supported:
- `model`: Model identifier
- `messages`: List of message objects
- `temperature`: Sampling temperature (0-2)
- `max_tokens`: Maximum tokens to generate
- `top_p`: Nucleus sampling
- `frequency_penalty`: Frequency penalty
- `presence_penalty`: Presence penalty
- `stop`: Stop sequences
- `n`: Number of completions
- `tools`: Tool definitions
- `tool_choice`: Tool selection strategy
- `user`: User identifier
### Response Format
Responses follow the OpenAI format exactly, with an additional `_metadata` field for debugging and security information:
```javascript ```javascript
{ import { getMemoryProtectionInfo } from 'nomyo-js';
"id": "chatcmpl-123",
"object": "chat.completion", const info = getMemoryProtectionInfo();
"created": 1234567890, // Without addon: { method: 'zero-only', canLock: false }
"model": "Qwen/Qwen3-0.6B", // With addon: { method: 'mlock', canLock: true }
"choices": [ ```
{
"index": 0, ### Security Tiers
"message": {
"role": "assistant", Pass `security_tier` per request to route inference to increasingly isolated hardware:
"content": "Hello! I'm doing well, thank you for asking."
}, | Tier | Hardware | Use case |
"finish_reason": "stop" |------|----------|----------|
} | `"standard"` | GPU | General secure inference |
], | `"high"` | CPU/GPU balanced | Sensitive business data |
"usage": { | `"maximum"` | CPU only | HIPAA PHI, classified data |
"prompt_tokens": 10,
"completion_tokens": 20, ```javascript
"total_tokens": 30 const response = await client.create({
}, model: 'Qwen/Qwen3-0.6B',
"_metadata": { messages: [{ role: 'user', content: 'Patient record summary...' }],
"payload_id": "openai-compat-abc123", security_tier: 'maximum',
"processed_at": 1765250382, });
"is_encrypted": true, ```
"encryption_algorithm": "hybrid-aes256-rsa4096",
"response_status": "success" ## Usage Examples
}
### With API Key
```javascript
const client = new SecureChatCompletion({ apiKey: process.env.NOMYO_API_KEY });
```
### Error Handling
```javascript
import {
SecureChatCompletion,
AuthenticationError,
RateLimitError,
ForbiddenError,
} from 'nomyo-js';
try {
const response = await client.create({ model: 'Qwen/Qwen3-0.6B', messages: [...] });
} catch (err) {
if (err instanceof AuthenticationError) console.error('Check API key:', err.message);
else if (err instanceof RateLimitError) console.error('Rate limit hit:', err.message);
else if (err instanceof ForbiddenError) console.error('Model/tier mismatch:', err.message);
else throw err;
} }
``` ```
## 🛠️ Usage Examples ### Per-Request Router Override
### Basic Chat Send a single request to a different router without changing the main client:
```javascript ```javascript
import { SecureChatCompletion } from 'nomyo-js';
const client = new SecureChatCompletion({
baseUrl: 'https://api.nomyo.ai:12434'
});
const response = await client.create({ const response = await client.create({
model: 'Qwen/Qwen3-0.6B', model: 'Qwen/Qwen3-0.6B',
messages: [ messages: [{ role: 'user', content: 'Hello from secondary router' }],
{ role: 'system', content: 'You are a helpful assistant.' }, base_url: 'https://secondary.nomyo.ai:12435', // temporary — main client unchanged
{ role: 'user', content: 'What is the capital of France?' }
],
temperature: 0.7
}); });
console.log(response.choices[0].message.content);
``` ```
### With Tools ### Tool / Function Calling
```javascript ```javascript
const response = await client.create({ const response = await client.create({
model: 'Qwen/Qwen3-0.6B', model: 'Qwen/Qwen3-0.6B',
messages: [ messages: [{ role: 'user', content: "What's the weather in Paris?" }],
{ role: 'user', content: "What's the weather in Paris?" }
],
tools: [ tools: [
{ {
type: 'function', type: 'function',
function: { function: {
name: 'get_weather', name: 'get_weather',
description: 'Get weather information', description: 'Get weather information for a location',
parameters: { parameters: {
type: 'object', type: 'object',
properties: { properties: { location: { type: 'string' } },
location: { type: 'string' } required: ['location'],
}, },
required: ['location'] },
} },
} ],
} tool_choice: 'auto',
]
}); });
``` ```
### With API Key Authentication ### Thinking Models
```javascript ```javascript
const client = new SecureChatCompletion({
baseUrl: 'https://api.nomyo.ai:12434',
apiKey: 'your-api-key-here'
});
// API key will be automatically included in all requests
const response = await client.create({ const response = await client.create({
model: 'Qwen/Qwen3-0.6B', model: 'LiquidAI/LFM2.5-1.2B-Thinking',
messages: [ messages: [{ role: 'user', content: 'Is 9.9 larger than 9.11?' }],
{ role: 'user', content: 'Hello!' }
]
}); });
const { content, reasoning_content } = response.choices[0].message;
console.log('Reasoning:', reasoning_content);
console.log('Answer:', content);
``` ```
### Custom Key Management (Node.js) ### Resource Management
```javascript ```javascript
import { SecureCompletionClient } from 'nomyo-js'; const client = new SecureChatCompletion({ apiKey: process.env.NOMYO_API_KEY });
const client = new SecureCompletionClient({ try {
routerUrl: 'https://api.nomyo.ai:12434' const response = await client.create({ model: 'Qwen/Qwen3-0.6B', messages: [...] });
}); console.log(response.choices[0].message.content);
} finally {
// Generate keys with password protection client.dispose(); // zeros key material, stops rotation timer
await client.generateKeys({ }
saveToFile: true,
keyDir: 'client_keys',
password: 'your-secure-password'
});
// Or load existing keys
await client.loadKeys(
'client_keys/private_key.pem',
'client_keys/public_key.pem',
'your-secure-password'
);
``` ```
## 🧪 Platform Support
### Node.js
- **Minimum version**: Node.js 15+ (for `crypto.webcrypto`)
- **Recommended**: Node.js 18 LTS or later
- **Key storage**: File system (`client_keys/` directory)
- **Security**: Full implementation with automatic key persistence
### Browsers
- **Supported browsers**: Modern browsers with Web Crypto API support
- Chrome 37+
- Firefox 34+
- Safari 11+
- Edge 79+
- **Key storage**: In-memory only (keys not persisted for security)
- **Security**: Best-effort memory protection (no OS-level locking)
## 📚 API Reference
### SecureChatCompletion
#### Constructor
```typescript
new SecureChatCompletion(config?: {
baseUrl?: string; // Default: 'https://api.nomyo.ai:12434'
allowHttp?: boolean; // Default: false
apiKey?: string; // Default: undefined
secureMemory?: boolean; // Default: true
})
```
#### Methods
- `create(request: ChatCompletionRequest): Promise<ChatCompletionResponse>`
- `acreate(request: ChatCompletionRequest): Promise<ChatCompletionResponse>` (alias)
### SecureCompletionClient
Lower-level API for advanced use cases.
#### Constructor
```typescript
new SecureCompletionClient(config?: {
routerUrl?: string; // Default: 'https://api.nomyo.ai:12434'
allowHttp?: boolean; // Default: false
secureMemory?: boolean; // Default: true
keySize?: 2048 | 4096; // Default: 4096
})
```
#### Methods
- `generateKeys(options?: KeyGenOptions): Promise<void>`
- `loadKeys(privateKeyPath: string, publicKeyPath?: string, password?: string): Promise<void>`
- `fetchServerPublicKey(): Promise<string>`
- `encryptPayload(payload: object): Promise<ArrayBuffer>`
- `decryptResponse(encrypted: ArrayBuffer, payloadId: string): Promise<object>`
- `sendSecureRequest(payload: object, payloadId: string, apiKey?: string): Promise<object>`
## 🔧 Configuration
### Local Development (HTTP) ### Local Development (HTTP)
```javascript ```javascript
const client = new SecureChatCompletion({ const client = new SecureChatCompletion({
baseUrl: 'http://localhost:12434', baseUrl: 'http://localhost:12435',
allowHttp: true // Required for HTTP connections allowHttp: true, // required — also prints a visible warning
}); });
``` ```
⚠️ **Warning**: Only use HTTP for local development. Never use in production! ## API Reference
### Disable Secure Memory ### `SecureChatCompletion` — Constructor Options
```javascript ```typescript
const client = new SecureChatCompletion({ new SecureChatCompletion(config?: ChatCompletionConfig)
baseUrl: 'https://api.nomyo.ai:12434',
secureMemory: false // Disable memory protection (not recommended)
});
``` ```
## 📝 Security Best Practices | Option | Type | Default | Description |
|--------|------|---------|-------------|
| `baseUrl` | `string` | `'https://api.nomyo.ai'` | NOMYO router URL. Must be HTTPS in production. |
| `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. |
| `debug` | `boolean` | `false` | Print verbose logging to the console. |
| `keyDir` | `string` | `'client_keys'` | Directory to load/save RSA keys on startup. |
| `keyRotationInterval` | `number` | `86400000` | Auto-rotate keys every N ms. `0` disables rotation. |
| `keyRotationDir` | `string` | `'client_keys'` | Directory for rotated key files. Node.js only. |
| `keyRotationPassword` | `string` | `undefined` | Password for encrypted rotated key files. |
| `maxRetries` | `number` | `2` | Extra retry attempts on 429/5xx/network errors. Exponential backoff (1 s, 2 s, …). |
- ✅ Always use HTTPS in production #### Methods
- ✅ Use password protection for private keys (Node.js)
- ✅ Keep private keys secure (permissions set to 600)
- ✅ Never share your private key
- ✅ Verify server's public key fingerprint before first use
- ✅ Enable secure memory protection (default)
## 🤝 Contributing - `create(request): Promise<ChatCompletionResponse>` — send an encrypted chat completion
- `acreate(request): Promise<ChatCompletionResponse>` — alias for `create()`
- `dispose(): void` — zero key material and stop the rotation timer
Contributions are welcome! Please open issues or pull requests on the project repository. #### `create()` Request Fields
## 📄 License All standard OpenAI fields (`model`, `messages`, `temperature`, `top_p`, `max_tokens`, `stop`, `n`, `tools`, `tool_choice`, `user`, `frequency_penalty`, `presence_penalty`, `logit_bias`) plus:
See LICENSE file for licensing information. | Field | Description |
|-------|-------------|
| `security_tier` | `"standard"` \| `"high"` \| `"maximum"` — hardware isolation level |
| `api_key` | Per-request API key override |
| `base_url` | Per-request router URL override — creates a temporary client, used once, then disposed |
## 📞 Support ### `SecureCompletionClient` — Constructor Options
For questions or issues, please refer to the project documentation or open an issue. Lower-level client. All options above apply, with these differences:
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `routerUrl` | `string` | `'https://api.nomyo.ai'` | Base URL (`baseUrl` is renamed here) |
| `keySize` | `2048 \| 4096` | `4096` | RSA modulus length |
#### Methods
- `generateKeys(options?)` — generate a new RSA key pair
- `loadKeys(privateKeyPath, publicKeyPath?, password?)` — load existing PEM files
- `fetchServerPublicKey()` — fetch the server's RSA public key
- `encryptPayload(payload)` — encrypt a request payload
- `decryptResponse(encrypted, payloadId)` — decrypt a response body
- `sendSecureRequest(payload, payloadId, apiKey?, securityTier?)` — full encrypt → POST → decrypt cycle
- `dispose()` — zero key material and stop rotation timer
### Secure Memory Public API
```typescript
import {
getMemoryProtectionInfo,
disableSecureMemory,
enableSecureMemory,
SecureByteContext,
} from 'nomyo-js';
```
| Export | Description |
|--------|-------------|
| `getMemoryProtectionInfo()` | Returns `{ method, canLock, isPlatformSecure, details? }` |
| `disableSecureMemory()` | Disable global secure-memory zeroing |
| `enableSecureMemory()` | Re-enable global secure-memory zeroing |
| `SecureByteContext` | Low-level buffer wrapper — zeros in `finally` block |
### Error Classes
```typescript
import {
AuthenticationError, InvalidRequestError, RateLimitError,
ForbiddenError, ServerError, ServiceUnavailableError,
APIConnectionError, SecurityError, DisposedError, APIError,
} from 'nomyo-js';
```
| Class | HTTP | Thrown when |
|-------|------|-------------|
| `AuthenticationError` | 401 | Invalid or missing API key |
| `InvalidRequestError` | 400 | Malformed request |
| `ForbiddenError` | 403 | Model not allowed for the security tier |
| `RateLimitError` | 429 | Rate limit exceeded (after all retries) |
| `ServerError` | 500 | Internal server error (after all retries) |
| `ServiceUnavailableError` | 503 | Backend unavailable (after all retries) |
| `APIError` | varies | Other HTTP errors |
| `APIConnectionError` | — | Network failure or timeout (after all retries) |
| `SecurityError` | — | HTTPS not used, header injection, or crypto failure |
| `DisposedError` | — | Method called after `dispose()` |
## Platform Support
### Node.js
- **Minimum**: Node.js 14.17+
- **Recommended**: Node.js 18 LTS or later
- **Key storage**: File system (`keyDir` directory, default `client_keys/`)
### Browsers
- **Supported**: Chrome 37+, Firefox 34+, Safari 11+, Edge 79+
- **Key storage**: In-memory only (not persisted)
- **Limitation**: File-based key operations (`keyDir`, `loadKeys`) are not available
## Security Best Practices
- Always use HTTPS (`allowHttp` is `false` by default)
- Load API key from an environment variable, never hardcode it
- Use password-protected key files (`keyRotationPassword`)
- Store keys outside the project directory and outside version control
- Add `client_keys/` and `*.pem` to `.gitignore`
- Call `dispose()` when the client is no longer needed
- Use `security_tier: 'maximum'` for HIPAA PHI or classified data
- Consider the `nomyo-native` addon if swap-file exposure is unacceptable
## License
See LICENSE file.

49
doc/README.md Normal file
View file

@ -0,0 +1,49 @@
# NOMYO.js Documentation
Comprehensive documentation for the NOMYO secure JavaScript/TypeScript chat client — a drop-in replacement for OpenAI's `ChatCompletion` API with end-to-end encryption.
To use this library you need an active subscription on [NOMYO Inference](https://chat.nomyo.ai/).
## Quick Start
```javascript
import { SecureChatCompletion } from 'nomyo-js';
const client = new SecureChatCompletion({ apiKey: process.env.NOMYO_API_KEY });
const response = await client.create({
model: 'Qwen/Qwen3-0.6B',
messages: [{ role: 'user', content: 'Hello!' }],
security_tier: 'standard',
});
console.log(response.choices[0].message.content);
```
## Documentation
1. [Installation](installation.md) — npm, CDN, and native addon setup
2. [Getting Started](getting-started.md) — first request, auth, security tiers, error handling
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
---
## Key Features
- **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).
- **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).
- **Resource lifecycle**`dispose()` immediately zeros in-memory key material and stops the rotation timer.
## Technical Security Docs
For cryptographic architecture, threat model, and implementation status see [SECURITY.md](SECURITY.md).

View file

@ -53,18 +53,28 @@ NOMYO.js implements end-to-end encryption for OpenAI-compatible chat completions
- Automatic key generation on first use - Automatic key generation on first use
- File-based persistence (Node.js) - File-based persistence (Node.js)
- In-memory keys (browsers) - In-memory keys (browsers)
- Password protection via PBKDF2 + AES-256-CBC - Password protection via PBKDF2 + AES-256-CBC (minimum 8-character password enforced)
- Automatic periodic key rotation (default: 24 hours, configurable, or disabled with `keyRotationInterval: 0`)
- `dispose()` method severs in-memory key references and cancels the rotation timer
4. **Transport Security** 4. **Transport Security**
- HTTPS enforcement (with warnings for HTTP) - HTTPS enforcement using proper URL parsing (`new URL()`) — not string prefix matching
- Certificate validation (browsers/Node.js) - Certificate validation (browsers/Node.js)
- Optional HTTP for local development (explicit opt-in) - Optional HTTP for local development (explicit opt-in)
- API key validated to reject CR/LF characters (prevents HTTP header injection)
- Server error detail truncated to 100 printable characters (prevents log injection)
5. **Memory Protection (Pure JavaScript)** 5. **Memory Protection (Pure JavaScript)**
- Immediate zeroing of sensitive buffers - Immediate zeroing of sensitive buffers
- Context managers for automatic cleanup - Context managers for automatic cleanup (`SecureByteContext`) with guarded `finally` blocks
- Intermediate crypto buffers (password bytes, salt, IV) wrapped in `SecureByteContext` during key encryption
- HTTP request body (`ArrayBuffer`) zeroed after data is handed to the socket
- Best-effort memory management - Best-effort memory management
6. **Response Integrity**
- Decrypted response validated against required `ChatCompletionResponse` schema fields before use
- Generic error messages from all crypto operations (no internal engine details leaked)
### ⚠️ Limitations (Pure JavaScript) ### ⚠️ Limitations (Pure JavaScript)
1. **No OS-Level Memory Locking** 1. **No OS-Level Memory Locking**
@ -94,9 +104,10 @@ NOMYO.js implements end-to-end encryption for OpenAI-compatible chat completions
✅ **DO:** ✅ **DO:**
- Use HTTPS in production (enforced by default) - Use HTTPS in production (enforced by default)
- Enable secure memory protection (default: `secureMemory: true`) - Enable secure memory protection (default: `secureMemory: true`)
- Use password-protected private keys in Node.js - Use password-protected private keys in Node.js (minimum 8 characters)
- Set private key file permissions to 600 (owner-only) - Set private key file permissions to 600 (owner-only)
- Rotate keys periodically - Rely on automatic key rotation (`keyRotationInterval`, default 24h) to limit fingerprint lifetime
- Call `dispose()` when the client is no longer needed
- Validate server public key fingerprint on first use - Validate server public key fingerprint on first use
❌ **DON'T:** ❌ **DON'T:**
@ -132,7 +143,7 @@ const client = new SecureChatCompletion({ baseUrl: 'https://...' });
# .env file (never commit to git) # .env file (never commit to git)
NOMYO_API_KEY=your-api-key NOMYO_API_KEY=your-api-key
NOMYO_KEY_PASSWORD=your-key-password NOMYO_KEY_PASSWORD=your-key-password
NOMYO_SERVER_URL=https://api.nomyo.ai:12434 NOMYO_SERVER_URL=https://api.nomyo.ai
``` ```
--- ---
@ -248,9 +259,12 @@ class SecureByteContext {
try { try {
return await fn(this.data); return await fn(this.data);
} finally { } finally {
// Always zero, even if exception occurs // Always zero, even if exception occurs.
// zeroMemory failure is swallowed so it cannot mask the original error.
if (this.useSecure) { if (this.useSecure) {
new Uint8Array(this.data).fill(0); try {
this.secureMemory.zeroMemory(this.data);
} catch (_zeroErr) { /* intentional */ }
} }
} }
} }
@ -328,6 +342,11 @@ npm install nomyo-native
✅ **Timing Attacks (Partial)** ✅ **Timing Attacks (Partial)**
- Web Crypto API uses constant-time operations - Web Crypto API uses constant-time operations
- No length leakage in comparisons - No length leakage in comparisons
- Generic error messages from all crypto operations (RSA, AES) — internal engine errors not forwarded
✅ **Concurrent Key Generation Race**
- Promise-chain mutex serialises all `ensureKeys()` callers
- No risk of multiple simultaneous key generations overwriting each other
✅ **Key Compromise (Forward Secrecy)** ✅ **Key Compromise (Forward Secrecy)**
- Ephemeral AES keys - Ephemeral AES keys

272
doc/api-reference.md Normal file
View file

@ -0,0 +1,272 @@
# API Reference
## `SecureChatCompletion`
High-level OpenAI-compatible client. The recommended entry point for most use cases.
### Constructor
```typescript
new SecureChatCompletion(config?: ChatCompletionConfig)
```
#### `ChatCompletionConfig`
| Option | Type | Default | Description |
| ----------------------- | ----------- | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `baseUrl` | `string` | `'https://api.nomyo.ai'` | NOMYO router URL. Must be HTTPS in production. |
| `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. |
| `debug` | `boolean` | `false` | Print verbose logging to the console. |
| `keyDir` | `string` | `'client_keys'` | 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. Node.js only. |
| `keyRotationInterval` | `number` | `86400000` (24 h) | Auto-rotate RSA keys every N milliseconds. Set to`0` to disable. |
| `keyRotationDir` | `string` | `'client_keys'` | Directory where rotated key files are saved. 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. |
### Methods
#### `create(request): Promise<ChatCompletionResponse>`
Send an encrypted chat completion request. Returns the decrypted response.
```typescript
async create(request: ChatCompletionRequest): Promise<ChatCompletionResponse>
```
**`ChatCompletionRequest` fields:**
| Field | Type | Description |
| --------------------- | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `model` | `string` | **Required.** Model ID (see [Models](models.md)). |
| `messages` | `Message[]` | **Required.** Conversation history. |
| `temperature` | `number` | Sampling temperature (02). |
| `top_p` | `number` | Nucleus sampling. |
| `max_tokens` | `number` | Maximum tokens to generate. |
| `stop` | `string | string[]` | Stop sequences. |
| `n` | `number` | Number of completions to generate. |
| `stream` | `boolean` | Ignored server-side (encryption requires full response). |
| `presence_penalty` | `number` | Presence penalty (2.02.0). |
| `frequency_penalty` | `number` | Frequency penalty (2.02.0). |
| `logit_bias` | `Record<string, number>` | Token bias map. |
| `user` | `string` | End-user identifier (passed through). |
| `tools` | `Tool[]` | Tool/function definitions. |
| `tool_choice` | `ToolChoice` | Tool selection strategy (`"auto"`, `"none"`, `"required"`, or specific tool). |
| `security_tier` | `string` | NOMYO-specific.`"standard"` \| `"high"` \| `"maximum"`. Not encrypted into the payload. |
| `api_key` | `string` | NOMYO-specific. Per-request API key override. Not encrypted into the payload. |
| `base_url` | `string` | NOMYO-specific. Per-request router URL override. Creates a temporary client for this one call. Not encrypted into the payload. |
**Response shape (`ChatCompletionResponse`):**
```typescript
{
id: string;
object: 'chat.completion';
created: number;
model: string;
choices: Array<{
index: number;
message: {
role: string;
content: string;
tool_calls?: ToolCall[]; // present if tools were invoked
reasoning_content?: string; // chain-of-thought (Qwen3, DeepSeek-R1, etc.)
};
finish_reason: 'stop' | 'length' | 'tool_calls' | 'content_filter' | null;
}>;
usage?: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
_metadata?: {
payload_id: string; // echoes the X-Payload-ID sent with the request
processed_at: number; // Unix timestamp of server-side processing
is_encrypted: boolean; // always true for this endpoint
encryption_algorithm: string; // e.g. "hybrid-aes256-rsa4096"
response_status: string; // "success" on success
security_tier?: string; // active tier used by the server
memory_protection?: {
platform: string;
memory_locking: boolean;
secure_zeroing: boolean;
core_dump_prevention: boolean;
};
cuda_device?: {
available: boolean;
device_hash: string; // SHA-256 of device name (not the raw name)
};
};
}
```
#### `acreate(request): Promise<ChatCompletionResponse>`
Alias for `create()`. Provided for code that follows the OpenAI SDK naming convention.
#### `dispose(): void`
Stop the key-rotation timer and sever in-memory RSA key references so they can be garbage-collected. After calling `dispose()`, all methods throw `DisposedError`.
```javascript
client.dispose();
```
---
## `SecureCompletionClient`
Lower-level client that exposes key management and individual encryption/decryption operations.
Use this when you need fine-grained control; for most use cases prefer `SecureChatCompletion`.
### Constructor
```typescript
new SecureCompletionClient(config?: ClientConfig)
```
#### `ClientConfig`
All options from `ChatCompletionConfig`, plus:
| Option | Type | Default | Description |
| ------------- | --------------- | -------------------------------- | --------------------------------------------------------------- |
| `routerUrl` | `string` | `'https://api.nomyo.ai'` | NOMYO router base URL. |
| `keySize` | `2048 | 4096` | `4096` | RSA modulus length. 2048 is accepted but 4096 is recommended. |
(`baseUrl` is renamed to `routerUrl` at this level; all other options are identical.)
### Methods
#### `generateKeys(options?): Promise<void>`
Generate a fresh RSA key pair.
```typescript
await client.generateKeys({
keySize?: 2048 | 4096, // default: 4096
saveToFile?: boolean, // default: false
keyDir?: string, // default: 'client_keys'
password?: string, // minimum 8 characters if provided
});
```
#### `loadKeys(privateKeyPath, publicKeyPath?, password?): Promise<void>`
Load an existing key pair from PEM files. Node.js only.
```typescript
await client.loadKeys(
'client_keys/private_key.pem',
'client_keys/public_key.pem', // optional; derived from private key path if omitted
'your-password' // required if private key is encrypted
);
```
#### `fetchServerPublicKey(): Promise<string>`
Fetch the server's RSA public key from `/pki/public_key` over HTTPS. Called automatically on every encryption; exposed for diagnostics.
#### `encryptPayload(payload): Promise<ArrayBuffer>`
Encrypt a request payload. Returns the encrypted binary package ready to POST.
#### `decryptResponse(encrypted, payloadId): Promise<object>`
Decrypt a response body received from the secure endpoint.
#### `sendSecureRequest(payload, payloadId, apiKey?, securityTier?): Promise<object>`
Full encrypt → POST → decrypt cycle with retry logic. Called internally by `SecureChatCompletion.create()`.
#### `dispose(): void`
Same as `SecureChatCompletion.dispose()`.
---
## Secure Memory API
```typescript
import {
getMemoryProtectionInfo,
disableSecureMemory,
enableSecureMemory,
SecureByteContext,
} from 'nomyo-js';
```
### `getMemoryProtectionInfo(): ProtectionInfo`
Returns information about the memory protection available on the current platform:
```typescript
interface ProtectionInfo {
canLock: boolean; // true if mlock is available (requires native addon)
isPlatformSecure: boolean;
method: 'mlock' | 'zero-only' | 'none';
details?: string;
}
```
### `disableSecureMemory(): void`
Disable secure-memory zeroing globally. Affects new `SecureByteContext` instances that do not pass an explicit `useSecure` argument. Existing client instances are unaffected (they pass `useSecure` explicitly).
### `enableSecureMemory(): void`
Re-enable secure memory operations globally.
### `SecureByteContext`
Low-level context manager that zeros an `ArrayBuffer` in a `finally` block even if an exception occurs. Analogous to Python's `secure_bytearray()` context manager.
```typescript
const context = new SecureByteContext(sensitiveBuffer);
const result = await context.use(async (data) => {
return doSomethingWith(data);
});
// sensitiveBuffer is zeroed here regardless of whether doSomethingWith threw
```
---
## Error Classes
All errors are exported from the package root.
```typescript
import {
APIError,
AuthenticationError,
InvalidRequestError,
RateLimitError,
ForbiddenError,
ServerError,
ServiceUnavailableError,
APIConnectionError,
SecurityError,
DisposedError,
} from 'nomyo-js';
```
| Class | HTTP status | Thrown when |
| --------------------------- | ------------- | -------------------------------------------------------------- |
| `AuthenticationError` | 401 | Invalid or missing API key |
| `InvalidRequestError` | 400 | Malformed request (e.g. streaming requested) |
| `ForbiddenError` | 403 | Model not allowed for the requested security tier |
| `RateLimitError` | 429 | Rate limit exceeded (after all retries exhausted) |
| `ServerError` | 500 | Internal server error (after all retries exhausted) |
| `ServiceUnavailableError` | 503 | Inference backend unavailable (after all retries exhausted) |
| `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 |
| `DisposedError` | — | Method called after`dispose()` |
All errors that extend `APIError` expose `statusCode?: number` and `errorDetails?: object`.

437
doc/examples.md Normal file
View file

@ -0,0 +1,437 @@
# Examples
## Basic Usage
### Simple Chat
```javascript
import { SecureChatCompletion } from 'nomyo-js';
const client = new SecureChatCompletion({ apiKey: process.env.NOMYO_API_KEY });
const response = await client.create({
model: 'Qwen/Qwen3-0.6B',
messages: [{ role: 'user', content: 'Hello, how are you?' }],
temperature: 0.7,
});
console.log(response.choices[0].message.content);
client.dispose();
```
### Chat with System Message
```javascript
const response = await client.create({
model: 'Qwen/Qwen3-0.6B',
messages: [
{ role: 'system', content: 'You are a concise technical assistant.' },
{ role: 'user', content: 'What is the capital of France?' },
],
temperature: 0.7,
});
console.log(response.choices[0].message.content);
```
---
## Security Tiers
```javascript
// Standard — general use (GPU)
const r1 = await client.create({
model: 'Qwen/Qwen3-0.6B',
messages: [{ role: 'user', content: 'General query' }],
security_tier: 'standard',
});
// High — sensitive business data
const r2 = await client.create({
model: 'Qwen/Qwen3-0.6B',
messages: [{ role: 'user', content: 'Review this contract clause...' }],
security_tier: 'high',
});
// Maximum — HIPAA PHI / classified data (CPU-only)
const r3 = await client.create({
model: 'Qwen/Qwen3-0.6B',
messages: [{ role: 'user', content: 'Patient record summary...' }],
security_tier: 'maximum',
});
```
---
## Tool / Function Calling
```javascript
const response = await client.create({
model: 'Qwen/Qwen3-0.6B',
messages: [{ role: 'user', content: "What's the weather in Paris?" }],
tools: [
{
type: 'function',
function: {
name: 'get_weather',
description: 'Get weather information for a location',
parameters: {
type: 'object',
properties: {
location: { type: 'string', description: 'City name' },
},
required: ['location'],
},
},
},
],
tool_choice: 'auto',
});
const message = response.choices[0].message;
if (message.tool_calls?.length) {
const call = message.tool_calls[0];
const args = JSON.parse(call.function.arguments);
console.log(`Call ${call.function.name}(location="${args.location}")`);
// → Call get_weather(location="Paris")
}
```
---
## Error Handling
```javascript
import {
SecureChatCompletion,
AuthenticationError,
RateLimitError,
ForbiddenError,
InvalidRequestError,
ServerError,
ServiceUnavailableError,
APIConnectionError,
SecurityError,
} from 'nomyo-js';
const client = new SecureChatCompletion({ apiKey: process.env.NOMYO_API_KEY });
try {
const response = await client.create({
model: 'Qwen/Qwen3-0.6B',
messages: [{ role: 'user', content: 'Hello' }],
});
console.log(response.choices[0].message.content);
} catch (err) {
if (err instanceof AuthenticationError) {
console.error('Check your API key:', err.message);
} else if (err instanceof RateLimitError) {
console.error('Rate limit hit after all retries:', err.message);
} else if (err instanceof ForbiddenError) {
console.error('Model not allowed for this security tier:', err.message);
} else if (err instanceof InvalidRequestError) {
console.error('Bad request:', err.message, err.errorDetails);
} else if (err instanceof ServerError || err instanceof ServiceUnavailableError) {
console.error('Server error after retries:', err.message);
} else if (err instanceof APIConnectionError) {
console.error('Network error after retries:', err.message);
} else if (err instanceof SecurityError) {
console.error('Security/crypto failure:', err.message);
} else {
throw err;
}
}
```
---
## Real-World Scenarios
### Chat Application with History
```javascript
import { SecureChatCompletion } from 'nomyo-js';
class SecureChatApp {
constructor(apiKey) {
this.client = new SecureChatCompletion({ apiKey });
this.history = [];
}
async chat(userMessage) {
this.history.push({ role: 'user', content: userMessage });
const response = await this.client.create({
model: 'Qwen/Qwen3-0.6B',
messages: this.history,
temperature: 0.7,
});
const assistantMessage = response.choices[0].message;
this.history.push({ role: assistantMessage.role, content: assistantMessage.content });
return assistantMessage.content;
}
dispose() {
this.client.dispose();
}
}
const app = new SecureChatApp(process.env.NOMYO_API_KEY);
const r1 = await app.chat("What's your name?");
console.log('Assistant:', r1);
const r2 = await app.chat('What did I just ask you?');
console.log('Assistant:', r2);
app.dispose();
```
### Per-Request Base URL Override
For multi-tenant setups or testing against different router instances from a single client:
```javascript
const client = new SecureChatCompletion({
baseUrl: 'https://primary.nomyo.ai:12435',
apiKey: process.env.NOMYO_API_KEY,
});
// This single request goes to a different router; a temporary client is
// created, used, and disposed automatically — the main client is unchanged
const response = await client.create({
model: 'Qwen/Qwen3-0.6B',
messages: [{ role: 'user', content: 'Hello from secondary router' }],
base_url: 'https://secondary.nomyo.ai:12435',
});
```
### Environment-Based Configuration
```javascript
import 'dotenv/config';
import { SecureChatCompletion } from 'nomyo-js';
const client = new SecureChatCompletion({
baseUrl: process.env.NOMYO_SERVER_URL ?? 'https://api.nomyo.ai',
apiKey: process.env.NOMYO_API_KEY,
keyDir: process.env.NOMYO_KEY_DIR ?? 'client_keys',
maxRetries: Number(process.env.NOMYO_MAX_RETRIES ?? '2'),
debug: process.env.NODE_ENV === 'development',
});
```
---
## Batch Processing
### Sequential (Rate-Limit-Safe)
```javascript
const queries = [
'Summarise document A',
'Summarise document B',
'Summarise document C',
];
const summaries = [];
for (const query of queries) {
const response = await client.create({
model: 'Qwen/Qwen3-0.6B',
messages: [{ role: 'user', content: query }],
});
summaries.push(response.choices[0].message.content);
// Optional: add a small delay to stay within rate limits
await new Promise(r => setTimeout(r, 600));
}
```
### Concurrent (With Throttling)
```javascript
// Process in batches of 2 (the default rate limit)
async function batchN(items, batchSize, fn) {
const results = [];
for (let i = 0; i < items.length; i += batchSize) {
const batch = items.slice(i, i + batchSize);
const batchResults = await Promise.all(batch.map(fn));
results.push(...batchResults);
if (i + batchSize < items.length) {
await new Promise(r => setTimeout(r, 1100)); // wait >1 s between batches
}
}
return results;
}
const summaries = await batchN(documents, 2, async (doc) => {
const response = await client.create({
model: 'Qwen/Qwen3-0.6B',
messages: [{ role: 'user', content: `Summarise: ${doc}` }],
});
return response.choices[0].message.content;
});
```
---
## Thinking Models
```javascript
const response = await client.create({
model: 'LiquidAI/LFM2.5-1.2B-Thinking',
messages: [{ role: 'user', content: 'Is 9.9 larger than 9.11?' }],
});
const { content, reasoning_content } = response.choices[0].message;
console.log('Reasoning:', reasoning_content); // internal chain-of-thought
console.log('Answer:', content); // final answer to the user
```
---
## Browser Usage
```html
<!DOCTYPE html>
<html>
<head>
<title>NOMYO Secure Chat</title>
</head>
<body>
<textarea id="input" placeholder="Ask something..."></textarea>
<button id="send">Send</button>
<div id="output"></div>
<script type="module">
import { SecureChatCompletion } from 'https://unpkg.com/nomyo-js/dist/browser/index.js';
// In production, proxy through your backend instead of exposing the API key
const client = new SecureChatCompletion({
baseUrl: 'https://api.nomyo.ai',
apiKey: 'your-api-key', // see note above
});
document.getElementById('send').addEventListener('click', async () => {
const text = document.getElementById('input').value.trim();
if (!text) return;
document.getElementById('output').textContent = 'Thinking...';
try {
const response = await client.create({
model: 'Qwen/Qwen3-0.6B',
messages: [{ role: 'user', content: text }],
});
document.getElementById('output').textContent =
response.choices[0].message.content;
} catch (err) {
document.getElementById('output').textContent = `Error: ${err.message}`;
}
});
</script>
</body>
</html>
```
---
## Advanced Key Management
### Custom Key Directory
```javascript
const client = new SecureChatCompletion({
apiKey: process.env.NOMYO_API_KEY,
keyDir: '/var/lib/myapp/nomyo-keys', // outside project directory
keyRotationDir: '/var/lib/myapp/nomyo-keys',
keyRotationPassword: process.env.NOMYO_KEY_PASSWORD,
});
```
### Generating Keys Manually
```javascript
import { SecureCompletionClient } from 'nomyo-js';
const client = new SecureCompletionClient({
routerUrl: 'https://api.nomyo.ai',
});
// Generate a new 4096-bit key pair and save it with password protection
await client.generateKeys({
saveToFile: true,
keyDir: 'client_keys',
password: process.env.NOMYO_KEY_PASSWORD,
});
console.log('Keys generated and saved to client_keys/');
client.dispose();
```
### Loading Keys Explicitly
```javascript
import { SecureCompletionClient } from 'nomyo-js';
const client = new SecureCompletionClient({ routerUrl: 'https://api.nomyo.ai' });
await client.loadKeys(
'client_keys/private_key.pem',
'client_keys/public_key.pem',
process.env.NOMYO_KEY_PASSWORD
);
// Now send requests using the loaded keys
const result = await client.sendSecureRequest(
{ model: 'Qwen/Qwen3-0.6B', messages: [{ role: 'user', content: 'Hello' }] },
crypto.randomUUID()
);
client.dispose();
```
---
## Inspecting Memory Protection
```javascript
import { getMemoryProtectionInfo } from 'nomyo-js';
const info = getMemoryProtectionInfo();
console.log(`Memory method: ${info.method}`); // 'zero-only' or 'mlock'
console.log(`Can lock: ${info.canLock}`); // true if native addon present
console.log(`Details: ${info.details}`);
```
---
## TypeScript
Full type safety out of the box:
```typescript
import {
SecureChatCompletion,
ChatCompletionRequest,
ChatCompletionResponse,
Message,
} from 'nomyo-js';
const client = new SecureChatCompletion({ apiKey: process.env.NOMYO_API_KEY! });
const messages: Message[] = [
{ role: 'user', content: 'Hello!' },
];
const request: ChatCompletionRequest = {
model: 'Qwen/Qwen3-0.6B',
messages,
temperature: 0.7,
};
const response: ChatCompletionResponse = await client.create(request);
const content = response.choices[0].message.content;
client.dispose();
```

279
doc/getting-started.md Normal file
View file

@ -0,0 +1,279 @@
# Getting Started
## Overview
NOMYO.js provides end-to-end encryption for all communication between your application and NOMYO inference endpoints. Your prompts and responses are encrypted before leaving your process and decrypted only after they arrive back — the server never sees plaintext.
The API mirrors OpenAI's `ChatCompletion`, making it easy to integrate into existing code.
> **Note on streaming:** The API is non-streaming. Setting `stream: true` in a request is ignored server-side to maintain full response encryption.
---
## Simple Chat Completion
```javascript
import { SecureChatCompletion } from 'nomyo-js';
const client = new SecureChatCompletion({
apiKey: process.env.NOMYO_API_KEY,
});
const response = await client.create({
model: 'Qwen/Qwen3-0.6B',
messages: [{ role: 'user', content: 'Hello! How are you today?' }],
temperature: 0.7,
});
// Extract what you need, then let the response go out of scope promptly.
// This minimises the time decrypted data lives in process memory
// (reduces exposure from swap files, core dumps, or memory inspection).
const reply = response.choices[0].message.content;
console.log(reply);
```
### With a System Message
```javascript
const response = await client.create({
model: 'Qwen/Qwen3-0.6B',
messages: [
{ role: 'system', content: 'You are a helpful assistant.' },
{ role: 'user', content: 'What is the capital of France?' },
],
temperature: 0.7,
});
console.log(response.choices[0].message.content);
```
---
## API Key Authentication
```javascript
// Constructor-level key (used for all requests from this instance)
const client = new SecureChatCompletion({
apiKey: process.env.NOMYO_API_KEY,
});
// Per-request key override (takes precedence over constructor key)
const response = await client.create({
model: 'Qwen/Qwen3-0.6B',
messages: [{ role: 'user', content: 'Hello!' }],
api_key: 'override-key-for-this-request',
});
```
---
## Security Tiers
Pass `security_tier` in the request to control hardware routing and isolation level:
| Tier | Use case |
| -------------- | ------------------------------------------------------- |
| `"standard"` | General secure inference (GPU) |
| `"high"` | Sensitive business data — enforces secure tokenizer |
| `"maximum"` | HIPAA PHI, classified data — E2EE, maximum isolation |
```javascript
// Standard — general use
const response = await client.create({
model: 'Qwen/Qwen3-0.6B',
messages: [{ role: 'user', content: 'General query' }],
security_tier: 'standard',
});
// High — sensitive business data
const response2 = await client.create({
model: 'Qwen/Qwen3-0.6B',
messages: [{ role: 'user', content: 'Summarise this contract clause...' }],
security_tier: 'high',
});
// Maximum — PHI / classified data
const response3 = await client.create({
model: 'Qwen/Qwen3-0.6B',
messages: [{ role: 'user', content: 'Patient record summary...' }],
security_tier: 'maximum',
});
```
> Using `"high"` or `"maximum"` adds latency vs `"standard"` due to additional isolation measures.
---
## Using Tools (Function Calling)
```javascript
const response = await client.create({
model: 'Qwen/Qwen3-0.6B',
messages: [{ role: 'user', content: "What's the weather in Paris?" }],
tools: [
{
type: 'function',
function: {
name: 'get_weather',
description: 'Get weather information for a location',
parameters: {
type: 'object',
properties: {
location: { type: 'string', description: 'City name' },
},
required: ['location'],
},
},
},
],
tool_choice: 'auto',
temperature: 0.7,
});
const message = response.choices[0].message;
if (message.tool_calls) {
const call = message.tool_calls[0];
console.log('Tool called:', call.function.name);
console.log('Arguments:', call.function.arguments);
}
```
---
## Error Handling
Import typed error classes to distinguish failure modes:
```javascript
import {
SecureChatCompletion,
AuthenticationError,
RateLimitError,
InvalidRequestError,
ForbiddenError,
ServerError,
ServiceUnavailableError,
APIConnectionError,
SecurityError,
} from 'nomyo-js';
const client = new SecureChatCompletion({ apiKey: process.env.NOMYO_API_KEY });
try {
const response = await client.create({
model: 'Qwen/Qwen3-0.6B',
messages: [{ role: 'user', content: 'Hello!' }],
});
console.log(response.choices[0].message.content);
} catch (err) {
if (err instanceof AuthenticationError) {
console.error('Bad API key:', err.message);
} else if (err instanceof RateLimitError) {
// The client already retried automatically (default: 2 retries).
// If you reach here, all retries were exhausted.
console.error('Rate limit exceeded after retries:', err.message);
} else if (err instanceof ForbiddenError) {
// Model not allowed for the requested security_tier
console.error('Forbidden:', err.message);
} else if (err instanceof InvalidRequestError) {
console.error('Bad request:', err.message);
} else if (err instanceof ServerError || err instanceof ServiceUnavailableError) {
console.error('Server error (retries exhausted):', err.message);
} else if (err instanceof APIConnectionError) {
console.error('Network error (retries exhausted):', err.message);
} else if (err instanceof SecurityError) {
console.error('Encryption/decryption failure:', err.message);
} else {
throw err; // re-throw unexpected errors
}
}
```
All typed errors expose:
- `message: string` — human-readable description
- `statusCode?: number` — HTTP status (where applicable)
- `errorDetails?: object` — raw response body (where applicable)
---
## Resource Management
Always call `dispose()` when you're done with a client to stop the background key-rotation timer and zero in-memory key material:
```javascript
const client = new SecureChatCompletion({ apiKey: process.env.NOMYO_API_KEY });
try {
const response = await client.create({ ... });
console.log(response.choices[0].message.content);
} finally {
client.dispose();
}
```
For long-running servers (HTTP handlers, daemons), create one client instance and reuse it — don't create a new one per request.
---
## `acreate()` Alias
`acreate()` is a direct alias for `create()` provided for code that follows the OpenAI naming convention:
```javascript
const response = await client.acreate({
model: 'Qwen/Qwen3-0.6B',
messages: [{ role: 'user', content: 'Hello!' }],
});
```
---
## Browser Usage
In browsers, keys are kept in memory only (no file system). Everything else is identical to Node.js.
```html
<script type="module">
import { SecureChatCompletion } from 'https://unpkg.com/nomyo-js/dist/browser/index.js';
const client = new SecureChatCompletion({
baseUrl: 'https://api.nomyo.ai',
apiKey: 'your-api-key',
});
const response = await client.create({
model: 'Qwen/Qwen3-0.6B',
messages: [{ role: 'user', content: 'Hello from the browser!' }],
});
console.log(response.choices[0].message.content);
client.dispose();
</script>
```
> **Security note:** Embedding API keys in browser-side code exposes them to end users. In a real application, proxy requests through your backend or use short-lived tokens.
---
## Local Development (HTTP)
For a local NOMYO router running over plain HTTP:
```javascript
const client = new SecureChatCompletion({
baseUrl: 'http://localhost:12435',
allowHttp: true, // required; prints a security warning
});
```
Never use `allowHttp: true` in production.

107
doc/installation.md Normal file
View file

@ -0,0 +1,107 @@
# Installation
## Prerequisites
- **Node.js**: 14.17 or higher (18 LTS recommended)
- **npm** / **yarn** / **pnpm**
- For TypeScript projects: TypeScript 4.7+
## Install from npm
```bash
npm install nomyo-js
```
```bash
yarn add nomyo-js
```
```bash
pnpm add nomyo-js
```
## Browser (CDN)
```html
<script type="module">
import { SecureChatCompletion } from 'https://unpkg.com/nomyo-js/dist/browser/index.js';
// ...
</script>
```
---
## Verify Installation
### Node.js
```javascript
import { SecureChatCompletion, getMemoryProtectionInfo } from 'nomyo-js';
const info = getMemoryProtectionInfo();
console.log('Memory protection:', info.method); // e.g. "zero-only"
console.log('Can lock:', info.canLock); // true if native addon present
const client = new SecureChatCompletion({ apiKey: 'test' });
console.log('nomyo-js installed successfully');
client.dispose();
```
### CommonJS
```javascript
const { SecureChatCompletion } = require('nomyo-js');
```
## Optional: Native Memory Addon
The pure-JS implementation zeroes buffers immediately after use but cannot prevent the OS from paging sensitive data to swap.
The optional native addon adds `mlock`/`VirtualLock` support for true OS-level memory locking.
```bash
cd node_modules/nomyo-js/native
npm install
npm run build
```
Or if you installed `nomyo-native` separately:
```bash
npm install nomyo-native
```
When the addon is present `getMemoryProtectionInfo()` will report `method: 'mlock'` and `canLock: true`.
## TypeScript
All public APIs ship with bundled type declarations — no `@types/` package required.
```typescript
import {
SecureChatCompletion,
ChatCompletionRequest,
ChatCompletionResponse,
getMemoryProtectionInfo,
} from 'nomyo-js';
```
## Environment Variables
Store secrets outside source code:
```bash
# .env (never commit this file)
NOMYO_API_KEY=your-api-key
NOMYO_SERVER_URL=https://api.nomyo.ai
NOMYO_KEY_PASSWORD=your-key-password
```
```javascript
import 'dotenv/config'; // or use process.env directly
import { SecureChatCompletion } from 'nomyo-js';
const client = new SecureChatCompletion({
baseUrl: process.env.NOMYO_SERVER_URL,
apiKey: process.env.NOMYO_API_KEY,
});
```

85
doc/models.md Normal file
View file

@ -0,0 +1,85 @@
# Available Models
All models are available via `api.nomyo.ai`. Pass the model ID string directly to the `model` field of `create()`.
## Model List
| Model ID | Parameters | Type | Notes |
|---|---|---|---|
| `Qwen/Qwen3-0.6B` | 0.6B | General | Lightweight, fast inference |
| `Qwen/Qwen3.5-0.8B` | 0.8B | General | Lightweight, fast inference |
| `LiquidAI/LFM2.5-1.2B-Thinking` | 1.2B | Thinking | Reasoning model |
| `ibm-granite/granite-4.0-h-small` | Small | General | IBM Granite 4.0, enterprise-focused |
| `Qwen/Qwen3.5-9B` | 9B | General | Balanced quality and speed |
| `utter-project/EuroLLM-9B-Instruct-2512` | 9B | General | Multilingual, strong European language support |
| `zai-org/GLM-4.7-Flash` | — | General | Fast GLM variant |
| `mistralai/Ministral-3-14B-Instruct-2512-GGUF` | 14B | General | Mistral instruction-tuned |
| `ServiceNow-AI/Apriel-1.6-15b-Thinker` | 15B | Thinking | Reasoning model |
| `openai/gpt-oss-20b` | 20B | General | OpenAI open-weight release |
| `LiquidAI/LFM2-24B-A2B` | 24B (2B active) | General | MoE — efficient inference |
| `Qwen/Qwen3.5-27B` | 27B | General | High quality, large context |
| `google/medgemma-27b-it` | 27B | Specialized | Medical domain, instruction-tuned |
| `nvidia/NVIDIA-Nemotron-3-Nano-30B-A3B-NVFP4` | 30B (3B active) | General | MoE — efficient inference |
| `Qwen/Qwen3.5-35B-A3B` | 35B (3B active) | General | MoE — efficient inference |
| `moonshotai/Kimi-Linear-48B-A3B-Instruct` | 48B (3B active) | General | MoE — large capacity, efficient inference |
> **MoE** (Mixture of Experts) models show total/active parameter counts. Only active parameters are used per token, keeping inference cost low relative to total model size.
## Usage
```javascript
import { SecureChatCompletion } from 'nomyo-js';
const client = new SecureChatCompletion({ apiKey: process.env.NOMYO_API_KEY });
const response = await client.create({
model: 'Qwen/Qwen3.5-9B',
messages: [{ role: 'user', content: 'Hello!' }],
});
```
## Choosing a Model
| Goal | Recommended models |
|------|--------------------|
| **Low latency / edge** | `Qwen/Qwen3-0.6B`, `Qwen/Qwen3.5-0.8B`, `LiquidAI/LFM2.5-1.2B-Thinking` |
| **Balanced quality + speed** | `Qwen/Qwen3.5-9B`, `mistralai/Ministral-3-14B-Instruct-2512-GGUF` |
| **Reasoning / chain-of-thought** | `LiquidAI/LFM2.5-1.2B-Thinking`, `ServiceNow-AI/Apriel-1.6-15b-Thinker` |
| **Multilingual** | `utter-project/EuroLLM-9B-Instruct-2512` |
| **Medical** | `google/medgemma-27b-it` |
| **Highest quality** | `moonshotai/Kimi-Linear-48B-A3B-Instruct`, `Qwen/Qwen3.5-35B-A3B` |
## Thinking Models
Models marked **Thinking** return an additional `reasoning_content` field in the response message alongside the normal `content`. This contains the model's internal chain-of-thought:
```javascript
const response = await client.create({
model: 'LiquidAI/LFM2.5-1.2B-Thinking',
messages: [{ role: 'user', content: 'Is 9.9 or 9.11 larger?' }],
});
const { content, reasoning_content } = response.choices[0].message;
console.log('Reasoning:', reasoning_content); // internal chain-of-thought
console.log('Answer:', content); // final answer
```
## Security Tier Compatibility
Not all models are available on all security tiers. If a model is not permitted for the requested tier, the server returns HTTP 403 and the client throws `ForbiddenError`.
```javascript
import { ForbiddenError } from 'nomyo-js';
try {
const response = await client.create({
model: 'Qwen/Qwen3.5-27B',
messages: [{ role: 'user', content: '...' }],
security_tier: 'maximum',
});
} catch (err) {
if (err instanceof ForbiddenError) {
// Model not available at this security tier — retry with a different tier or model
}
}
```

115
doc/rate-limits.md Normal file
View file

@ -0,0 +1,115 @@
# Rate Limits
The NOMYO API (`api.nomyo.ai`) enforces rate limits to ensure fair usage and service stability for all users.
## Default Rate Limit
By default, each API key is limited to **2 requests per second**.
## Burst Allowance
Short bursts above the default limit are permitted. You may send up to **4 requests per second** in burst mode, provided you have not exceeded burst usage within the current **10-second window**.
Burst capacity is granted once per 10-second window. If you consume the burst allowance, you must wait for the window to reset before burst is available again.
## Rate Limit Summary
| Mode | Limit | Condition |
|------|-------|-----------|
| Default | 2 requests/second | Always active |
| Burst | 4 requests/second | Once per 10-second window |
## Error Responses
### 429 Too Many Requests
Returned when your request rate exceeds the allowed limit.
The client retries automatically (see below). If all retries are exhausted, `RateLimitError` is thrown:
```javascript
import { SecureChatCompletion, RateLimitError } from 'nomyo-js';
try {
const response = await client.create({ ... });
} catch (err) {
if (err instanceof RateLimitError) {
// All retries exhausted — back off manually before trying again
console.error('Rate limit exceeded:', err.message);
}
}
```
### 503 Service Unavailable (Cool-down)
Returned when burst limits are abused repeatedly. A **30-minute cool-down** is applied to the offending API key.
**What to do:** Wait 30 minutes before retrying. Review your request patterns to ensure you stay within the permitted limits.
## Automatic Retry Behaviour
The client retries automatically on `429`, `500`, `502`, `503`, `504`, and network errors using exponential backoff:
| Attempt | Delay before attempt |
|---------|----------------------|
| 1st (initial) | — |
| 2nd | 1 second |
| 3rd | 2 seconds |
The default is **2 retries** (3 total attempts). Adjust per client:
```javascript
// More retries for high-throughput workloads
const client = new SecureChatCompletion({
apiKey: process.env.NOMYO_API_KEY,
maxRetries: 5,
});
// Disable retries entirely (fail fast)
const client2 = new SecureChatCompletion({
apiKey: process.env.NOMYO_API_KEY,
maxRetries: 0,
});
```
## Best Practices
- **Throttle requests client-side** to stay at or below 2 requests/second under normal load.
- **Use burst sparingly** — it is intended for occasional spikes, not sustained high-throughput usage.
- **Increase `maxRetries`** for background jobs that can tolerate extra latency.
- **Monitor for `503` responses** — repeated occurrences indicate your usage pattern is triggering the abuse threshold.
- **Parallel requests** (e.g. `Promise.all`) count against the same rate limit — be careful with large batches.
## Batch Processing Example
Throttle parallel requests to stay within the rate limit:
```javascript
import { SecureChatCompletion } from 'nomyo-js';
const client = new SecureChatCompletion({ apiKey: process.env.NOMYO_API_KEY });
async function throttledBatch(queries, requestsPerSecond = 2) {
const results = [];
const delayMs = 1000 / requestsPerSecond;
for (const query of queries) {
const start = Date.now();
const response = await client.create({
model: 'Qwen/Qwen3-0.6B',
messages: [{ role: 'user', content: query }],
});
results.push(response.choices[0].message.content);
// Throttle: wait for the remainder of the time slot
const elapsed = Date.now() - start;
if (elapsed < delayMs) {
await new Promise(resolve => setTimeout(resolve, delayMs - elapsed));
}
}
client.dispose();
return results;
}
```

237
doc/security-guide.md Normal file
View file

@ -0,0 +1,237 @@
# Security Guide
## Overview
NOMYO.js provides end-to-end encryption for all communication between your application and NOMYO inference endpoints. Your prompts and responses are encrypted before leaving your process — the inference server never processes plaintext.
For the full cryptographic architecture and threat model see [SECURITY.md](SECURITY.md).
---
## Encryption Mechanism
### Hybrid Encryption
Each request uses a two-layer scheme:
1. **AES-256-GCM** encrypts the payload (authenticated encryption — prevents tampering).
2. **RSA-OAEP-SHA256** wraps the AES key for secure key exchange.
The server holds the RSA private key; your client generates the AES key fresh for every request.
### Per-Request Ephemeral AES Keys
- A new 256-bit AES key is generated for every `create()` call using the Web Crypto API.
- The key is never reused — forward secrecy is ensured per request.
- The key is zeroed from memory immediately after encryption.
### Key Exchange
Your client's RSA public key is sent in the `X-Public-Key` request header. The server encrypts the response with it so only your client can decrypt the reply.
---
## Memory Protection
### What the Library Does
All intermediate sensitive buffers (AES key, plaintext payload, decrypted response bytes) are wrapped in `SecureByteContext`. This guarantees they are zeroed in a `finally` block immediately after use, even if an exception occurs.
The encrypted request body (`ArrayBuffer`) is also zeroed by the Node.js HTTP client after the data is handed to the socket.
### Limitations (Pure JavaScript)
JavaScript has no direct access to OS memory management. The library cannot:
- Lock pages to prevent swapping (`mlock` / `VirtualLock`)
- Prevent the garbage collector from copying data internally
- Guarantee memory won't appear in core dumps
**Impact:** On a system under memory pressure, sensitive data could briefly be written to swap. For environments where this is unacceptable (PHI, classified), install the optional native addon or run on a system with swap disabled.
### Native Addon (Optional)
The `nomyo-native` addon adds true `mlock` support. When installed, `getMemoryProtectionInfo()` reports `method: 'mlock'` and `canLock: true`:
```javascript
import { getMemoryProtectionInfo } from 'nomyo-js';
const info = getMemoryProtectionInfo();
// Without addon: { method: 'zero-only', canLock: false }
// With addon: { method: 'mlock', canLock: true }
```
---
## Minimise Response Lifetime
The library protects all intermediate crypto material in secure memory. However, the **final parsed response object** is returned to your code, and you are responsible for how long it lives.
```javascript
// GOOD — extract what you need, then drop the response immediately
const response = await client.create({
model: 'Qwen/Qwen3.5-9B',
messages: [{ role: 'user', content: 'Summarise patient record #1234' }],
security_tier: 'maximum',
});
const reply = response.choices[0].message.content;
// Let response go out of scope here — don't hold it in a variable
// longer than necessary
// BAD — holding the full response object in a long-lived scope
this.lastResponse = response; // stored for minutes / hours
```
JavaScript's `delete` and variable reassignment do not zero the underlying memory. For sensitive data (PHI, classified), process and discard as quickly as possible — do not store in class attributes, global caches, or log files.
---
## Key Management
### Default Behaviour
Keys are automatically generated on first use and saved to `client_keys/` (Node.js). On subsequent runs the saved keys are reloaded automatically.
```
client_keys/
private_key.pem # permissions 0600 (owner-only)
public_key.pem # permissions 0644
```
### Configure the Key Directory
```javascript
const client = new SecureChatCompletion({
apiKey: process.env.NOMYO_API_KEY,
keyDir: '/etc/myapp/nomyo-keys', // custom path, outside project directory
});
```
### Password-Protected Keys (Recommended for Production)
Protect key files with a password so they cannot be used even if the file is leaked:
```javascript
import { SecureCompletionClient } from 'nomyo-js';
const client = new SecureCompletionClient({ routerUrl: 'https://api.nomyo.ai' });
await client.generateKeys({
saveToFile: true,
keyDir: 'client_keys',
password: process.env.NOMYO_KEY_PASSWORD, // minimum 8 characters
});
```
To load password-protected keys manually:
```javascript
await client.loadKeys(
'client_keys/private_key.pem',
'client_keys/public_key.pem',
process.env.NOMYO_KEY_PASSWORD
);
```
### Key Rotation
Keys rotate automatically every 24 hours by default. Configure or disable:
```javascript
const client = new SecureChatCompletion({
apiKey: process.env.NOMYO_API_KEY,
keyRotationInterval: 3600000, // rotate every hour
keyRotationDir: '/var/lib/myapp/keys',
keyRotationPassword: process.env.KEY_PWD,
});
// Or disable entirely for short-lived processes
const client2 = new SecureChatCompletion({
apiKey: process.env.NOMYO_API_KEY,
keyRotationInterval: 0,
});
```
### File Permissions
Private key files are saved with `0600` permissions (owner read/write only) on Unix-like systems. Add `client_keys/` and `*.pem` to your `.gitignore` — both are already included if you use this package's default `.gitignore`.
---
## Security Tiers
| Tier | Hardware | Use case |
|------|----------|----------|
| `"standard"` | GPU | General secure inference |
| `"high"` | CPU/GPU balanced | Sensitive business data, enforces secure tokenizer |
| `"maximum"` | CPU only | HIPAA PHI, classified data — maximum isolation |
Higher tiers add round-trip latency but increase hardware-level isolation.
---
## HTTPS Enforcement
The client enforces HTTPS by default. HTTP connections require explicit opt-in and print a visible warning:
```javascript
// Production — HTTPS only (default)
const client = new SecureChatCompletion({ baseUrl: 'https://api.nomyo.ai' });
// Local development — HTTP allowed with explicit flag
const devClient = new SecureChatCompletion({
baseUrl: 'http://localhost:12435',
allowHttp: true, // prints: "WARNING: Using HTTP instead of HTTPS..."
});
```
Without `allowHttp: true`, connecting over HTTP throws `SecurityError`.
The server's public key is fetched over HTTPS with TLS certificate verification to prevent man-in-the-middle attacks.
---
## API Key Security
API keys are sent as `Bearer` tokens in the `Authorization` header. The client validates that the key does not contain CR or LF characters to prevent HTTP header injection.
Never hardcode API keys in source code — use environment variables:
```javascript
const client = new SecureChatCompletion({
apiKey: process.env.NOMYO_API_KEY,
});
```
---
## Production Checklist
- [ ] Always use HTTPS (`allowHttp` is `false` by default)
- [ ] Load API key from environment variable, not hardcoded
- [ ] Enable `secureMemory: true` (default)
- [ ] Use password-protected key files (`keyRotationPassword`)
- [ ] Store keys outside the project directory and outside version control
- [ ] Add `client_keys/` and `*.pem` to `.gitignore`
- [ ] Call `client.dispose()` when the client is no longer needed
- [ ] Consider the native addon if swap-file exposure is unacceptable
---
## Compliance Considerations
### HIPAA
For Protected Health Information (PHI):
- Use `security_tier: 'maximum'` on requests containing PHI
- Enable password-protected key files
- Ensure HTTPS is enforced (the default)
- Minimise response lifetime in memory (extract, use, discard)
### Data Classification
| Classification | Recommended tier |
|---------------|-----------------|
| Public / internal | `"standard"` |
| Confidential business data | `"high"` |
| PHI, PII, classified | `"maximum"` |

314
doc/troubleshooting.md Normal file
View file

@ -0,0 +1,314 @@
# Troubleshooting
## Authentication Errors
### `AuthenticationError: Invalid or missing API key`
The server rejected your API key.
**Causes and fixes:**
- Key not set — pass `apiKey` to the constructor or use `process.env.NOMYO_API_KEY`.
- Key has leading/trailing whitespace — check the value with `console.log(JSON.stringify(process.env.NOMYO_API_KEY))`.
- Key contains CR or LF characters — the client rejects keys with `\r` or `\n` and throws `SecurityError` before the request is sent. Regenerate the key.
```javascript
const client = new SecureChatCompletion({
apiKey: process.env.NOMYO_API_KEY, // never hardcode
});
```
---
## Connection Errors
### `APIConnectionError: Network error` / `connect ECONNREFUSED`
The client could not reach the router.
**Check:**
1. `baseUrl` is correct — the default is `https://api.nomyo.ai` (port **12435**).
2. You have network access to the host.
3. TLS is not being blocked by a proxy or firewall.
### `SecurityError: HTTPS is required`
You passed an `http://` URL without setting `allowHttp: true`.
```javascript
// Local dev only
const client = new SecureChatCompletion({
baseUrl: 'http://localhost:12435',
allowHttp: true,
});
```
Never set `allowHttp: true` in production — the server public key fetch and all request data would travel unencrypted.
### `APIConnectionError: Request timed out`
The default timeout is 60 seconds. Larger models or busy endpoints may need more:
```javascript
const client = new SecureChatCompletion({
apiKey: process.env.NOMYO_API_KEY,
timeout: 120000, // 2 minutes
});
```
---
## Key Loading Failures
### `Error: Failed to load keys: no such file or directory`
The `keyDir` directory or the PEM files inside it don't exist. On first run the library generates and saves a new key pair automatically. If you specified a custom `keyDir`, make sure the directory is writable:
```javascript
const client = new SecureChatCompletion({
apiKey: process.env.NOMYO_API_KEY,
keyDir: '/var/lib/myapp/nomyo-keys', // directory must exist and be writable
});
```
### `Error: Invalid passphrase` / `Error: Failed to decrypt private key`
The password you passed to `loadKeys()` or `keyRotationPassword` doesn't match what was used to encrypt the file.
```javascript
await client.loadKeys(
'client_keys/private_key.pem',
'client_keys/public_key.pem',
process.env.NOMYO_KEY_PASSWORD, // must match the password used on generateKeys()
);
```
### `Error: RSA key too small`
The library enforces a minimum key size of 2048 bits. If you have old 1024-bit keys, regenerate them:
```javascript
await client.generateKeys({
saveToFile: true,
keyDir: 'client_keys',
keySize: 4096, // recommended
});
```
### `Error: Failed to load keys` (browser)
Key loading from files is a Node.js-only feature. In browsers, keys are generated in memory on first use. Do not call `loadKeys()` in a browser context.
---
## Rate Limit Errors
### `RateLimitError: Rate limit exceeded`
All automatic retries were exhausted. The default limit is 2 requests/second; burst allows 4 requests/second once per 10-second window.
**Fixes:**
- Reduce concurrency — avoid large `Promise.all` batches.
- Add client-side throttling (see [Rate Limits](rate-limits.md)).
- Increase `maxRetries` so the client backs off longer before giving up:
```javascript
const client = new SecureChatCompletion({
apiKey: process.env.NOMYO_API_KEY,
maxRetries: 5,
});
```
### `ServiceUnavailableError` with 30-minute cool-down
Burst limits were hit repeatedly and a cool-down was applied to your key. Wait 30 minutes, then review your request patterns.
---
## Model / Tier Errors
### `ForbiddenError: Model not allowed for this security tier`
The model you requested is not available at the security tier you specified. Try a lower tier or a different model:
```javascript
// If 'maximum' tier rejects the model, try 'high' or 'standard'
const response = await client.create({
model: 'Qwen/Qwen3.5-27B',
messages: [...],
security_tier: 'high', // try 'standard' if still rejected
});
```
See [Models — Security Tier Compatibility](models.md#security-tier-compatibility) for details.
---
## Crypto / Security Errors
### `SecurityError: Decryption failed`
The response could not be decrypted. This is intentionally vague to avoid leaking crypto details.
**Possible causes:**
- The server returned a malformed response (check `debug: true` output).
- A network proxy modified the response body.
- The server's public key changed mid-session — the next request will re-fetch it automatically.
Enable debug mode to log the raw response and narrow the cause:
```javascript
const client = new SecureChatCompletion({
apiKey: process.env.NOMYO_API_KEY,
debug: true,
});
```
### `Error: Unsupported protocol version` / `Error: Unsupported encryption algorithm`
The server sent a response in a protocol version or with an encryption algorithm not supported by this client version. Update the package:
```bash
npm update nomyo-js
```
---
## `DisposedError`: Method called after `dispose()`
You called a method on a client that has already been disposed.
```javascript
client.dispose();
await client.create(...); // throws DisposedError
```
Create a new client instance if you need to make more requests after disposal.
---
## Memory Protection Warnings
### `getMemoryProtectionInfo()` returns `method: 'zero-only'`
This is normal for a pure JavaScript installation. The library zeroes sensitive buffers immediately after use but cannot lock pages to prevent swapping (OS `mlock` requires a native addon).
```javascript
import { getMemoryProtectionInfo } from 'nomyo-js';
const info = getMemoryProtectionInfo();
// { method: 'zero-only', canLock: false, isPlatformSecure: false }
```
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.
---
## Node.js-Specific Issues
### `ReferenceError: crypto is not defined`
In CommonJS modules on Node.js before v19, `crypto` is not a global. Import it explicitly:
```javascript
// CommonJS
const { webcrypto } = require('crypto');
global.crypto = webcrypto;
// Or switch to ES modules (recommended)
// package.json: "type": "module"
```
The library itself imports `crypto` correctly — this error only appears if your own application code tries to use `crypto` directly.
### `SyntaxError: Cannot use import statement in a module` / CommonJS vs ESM
The package ships both CommonJS (`dist/node/`) and ESM (`dist/esm/`) builds. Node.js selects the correct one automatically via `package.json` `exports`. If you see import errors, check that your `package.json` or bundler is not forcing the wrong format.
For ESM: set `"type": "module"` in your `package.json` or use `.mjs` file extensions.
For CommonJS: use `require('nomyo-js')` or `.cjs` extensions.
### TypeScript: `Cannot find module 'nomyo-js'` / missing types
Ensure your `tsconfig.json` includes `"moduleResolution": "bundler"` or `"moduleResolution": "node16"` and that `nomyo-js` is in `dependencies` (not just `devDependencies`):
```bash
npm install nomyo-js
```
---
## Browser-Specific Issues
### `Content Security Policy blocked`
If your app's CSP restricts `script-src` or `connect-src`, add the NOMYO API domain:
```
Content-Security-Policy: connect-src https://api.nomyo.ai;
```
### `TypeError: Failed to fetch` (CORS)
The NOMYO API includes CORS headers. If you see CORS errors in a browser, verify the `baseUrl` is correct (HTTPS, correct port) and that no browser extension is blocking the request.
### Keys not persisted across page reloads
This is expected behaviour — browsers do not have file system access. Keys are generated fresh on each page load. If you need persistent keys in a browser context, implement your own `loadKeys`/`generateKeys` wrapper using `localStorage` or `IndexedDB` (not recommended for high-security scenarios).
---
## Debugging Tips
### Enable verbose logging
```javascript
const client = new SecureChatCompletion({
apiKey: process.env.NOMYO_API_KEY,
debug: true,
});
```
Debug mode logs: key generation/loading, server public key fetches, request encryption details, retry attempts, and response decryption.
### Check memory protection status
```javascript
import { getMemoryProtectionInfo } from 'nomyo-js';
console.log(getMemoryProtectionInfo());
```
### Inspect response metadata
The `_metadata` field in every response carries server-side diagnostics:
```javascript
const response = await client.create({ ... });
console.log(response._metadata);
// {
// payload_id: '...',
// is_encrypted: true,
// encryption_algorithm: 'hybrid-aes256-rsa4096',
// security_tier: 'standard',
// memory_protection: { ... },
// }
```
### Test with minimum configuration
Strip all optional configuration and test with the simplest possible call to isolate the issue:
```javascript
import { SecureChatCompletion } from 'nomyo-js';
const client = new SecureChatCompletion({ apiKey: process.env.NOMYO_API_KEY });
const r = await client.create({
model: 'Qwen/Qwen3-0.6B',
messages: [{ role: 'user', content: 'ping' }],
});
console.log(r.choices[0].message.content);
client.dispose();
```

View file

@ -7,7 +7,7 @@ import { SecureChatCompletion } from 'nomyo-js';
async function main() { async function main() {
// Initialize client // Initialize client
const client = new SecureChatCompletion({ const client = new SecureChatCompletion({
baseUrl: 'https://api.nomyo.ai:12434', baseUrl: 'https://api.nomyo.ai',
// For local development, use: // For local development, use:
// baseUrl: 'http://localhost:12434', // baseUrl: 'http://localhost:12434',
// allowHttp: true // allowHttp: true

View file

@ -6,7 +6,7 @@ import { SecureChatCompletion } from 'nomyo-js';
async function main() { async function main() {
const client = new SecureChatCompletion({ const client = new SecureChatCompletion({
baseUrl: 'https://api.nomyo.ai:12434' baseUrl: 'https://api.nomyo.ai'
}); });
try { try {

View file

@ -10,20 +10,37 @@ import { ChatCompletionRequest, ChatCompletionResponse } from '../types/api';
export class SecureChatCompletion { export class SecureChatCompletion {
private client: SecureCompletionClient; private client: SecureCompletionClient;
private apiKey?: string; private apiKey?: string;
/** Stored config used to spin up a temporary per-request instance when base_url is overridden */
private readonly _config: ChatCompletionConfig;
constructor(config: ChatCompletionConfig = {}) { constructor(config: ChatCompletionConfig = {}) {
const { const {
baseUrl = 'https://api.nomyo.ai:12434', baseUrl = 'https://api.nomyo.ai',
allowHttp = false, allowHttp = false,
apiKey, apiKey,
secureMemory = true, secureMemory = true,
timeout,
debug,
keyRotationInterval,
keyRotationDir,
keyRotationPassword,
maxRetries,
keyDir,
} = config; } = config;
this._config = config;
this.apiKey = apiKey; this.apiKey = apiKey;
this.client = new SecureCompletionClient({ this.client = new SecureCompletionClient({
routerUrl: baseUrl, routerUrl: baseUrl,
allowHttp, allowHttp,
secureMemory, secureMemory,
...(timeout !== undefined && { timeout }),
...(debug !== undefined && { debug }),
...(keyRotationInterval !== undefined && { keyRotationInterval }),
...(keyRotationDir !== undefined && { keyRotationDir }),
...(keyRotationPassword !== undefined && { keyRotationPassword }),
...(maxRetries !== undefined && { maxRetries }),
...(keyDir !== undefined && { keyDir }),
}); });
} }
@ -33,18 +50,19 @@ export class SecureChatCompletion {
* Supports additional NOMYO-specific fields: * Supports additional NOMYO-specific fields:
* - `security_tier`: "standard" | "high" | "maximum" controls hardware routing * - `security_tier`: "standard" | "high" | "maximum" controls hardware routing
* - `api_key`: per-request API key override (takes precedence over constructor key) * - `api_key`: per-request API key override (takes precedence over constructor key)
* - `base_url`: per-request router URL override (creates a temporary client for
* this single call, matching the Python SDK's `create(base_url=...)` behaviour)
*/ */
async create(request: ChatCompletionRequest): Promise<ChatCompletionResponse> { async create(request: ChatCompletionRequest): Promise<ChatCompletionResponse> {
const payloadId = generateUUID(); const payloadId = generateUUID();
// Extract NOMYO-specific fields that must not go into the encrypted payload // Extract NOMYO-specific fields that must not go into the encrypted payload
const { security_tier, api_key, ...payload } = request as ChatCompletionRequest & { const { security_tier, api_key, base_url, ...payload } = request as ChatCompletionRequest & {
security_tier?: string; security_tier?: string;
api_key?: string; api_key?: string;
base_url?: string;
}; };
const apiKey = api_key ?? this.apiKey;
if (!payload.model) { if (!payload.model) {
throw new Error('Missing required field: model'); throw new Error('Missing required field: model');
} }
@ -52,6 +70,29 @@ export class SecureChatCompletion {
throw new Error('Missing or invalid required field: messages'); throw new Error('Missing or invalid required field: messages');
} }
const apiKey = api_key ?? this.apiKey;
// Per-request base_url: spin up a temporary client for this one call,
// inheriting all other config from the current instance.
if (base_url !== undefined) {
const tempInstance = new SecureChatCompletion({
...this._config,
baseUrl: base_url,
apiKey: this.apiKey,
});
try {
const response = await tempInstance.client.sendSecureRequest(
payload,
payloadId,
apiKey,
security_tier
);
return response as unknown as ChatCompletionResponse;
} finally {
tempInstance.dispose();
}
}
const response = await this.client.sendSecureRequest( const response = await this.client.sendSecureRequest(
payload, payload,
payloadId, payloadId,
@ -68,4 +109,11 @@ export class SecureChatCompletion {
async acreate(request: ChatCompletionRequest): Promise<ChatCompletionResponse> { async acreate(request: ChatCompletionRequest): Promise<ChatCompletionResponse> {
return this.create(request); return this.create(request);
} }
/**
* Release resources: stop key rotation timer and zero in-memory key material.
*/
dispose(): void {
this.client.dispose();
}
} }

View file

@ -21,6 +21,7 @@ import {
ServerError, ServerError,
ForbiddenError, ForbiddenError,
ServiceUnavailableError, ServiceUnavailableError,
DisposedError,
} from '../errors'; } from '../errors';
import { import {
arrayBufferToBase64, arrayBufferToBase64,
@ -64,41 +65,132 @@ export class SecureCompletionClient {
private secureMemoryImpl = createSecureMemory(); private secureMemoryImpl = createSecureMemory();
private readonly keySize: 2048 | 4096; private readonly keySize: 2048 | 4096;
constructor(config: ClientConfig = { routerUrl: 'https://api.nomyo.ai:12434' }) { private disposed = false;
private readonly debugMode: boolean;
private readonly requestTimeout: number;
private readonly keyRotationInterval: number;
private keyRotationTimer?: ReturnType<typeof setInterval>;
private readonly keyRotationDir?: string;
private readonly keyRotationPassword?: string;
private readonly maxRetries: number;
private readonly keyDir: string;
private _isHttps: boolean = true;
// Promise-based mutex: serialises concurrent ensureKeys() calls
private ensureKeysLock: Promise<void> = Promise.resolve();
constructor(config: ClientConfig = { routerUrl: 'https://api.nomyo.ai' }) {
const { const {
routerUrl = 'https://api.nomyo.ai:12434', routerUrl = 'https://api.nomyo.ai',
allowHttp = false, allowHttp = false,
secureMemory = true, secureMemory = true,
keySize = 4096, keySize = 4096,
timeout = 60000,
debug = false,
keyRotationInterval = 86400000, // 24 hours
keyRotationDir,
keyRotationPassword,
maxRetries = 2,
keyDir = 'client_keys',
} = config; } = config;
this.debugMode = debug;
this.requestTimeout = timeout;
this.keyRotationInterval = keyRotationInterval;
this.keyRotationDir = keyRotationDir;
this.keyRotationPassword = keyRotationPassword;
this.maxRetries = maxRetries;
this.keyDir = keyDir;
this.keySize = keySize; this.keySize = keySize;
this.routerUrl = routerUrl.replace(/\/$/, '');
this.allowHttp = allowHttp; this.allowHttp = allowHttp;
this.secureMemory = secureMemory; this.secureMemory = secureMemory;
// Validate HTTPS for security // Validate and parse URL
if (!this.routerUrl.startsWith('https://')) { let parsedUrl: URL;
try {
parsedUrl = new URL(routerUrl);
} catch {
throw new Error(`Invalid routerUrl: "${routerUrl}" is not a valid URL`);
}
this._isHttps = parsedUrl.protocol === 'https:';
this.routerUrl = routerUrl.replace(/\/$/, '');
if (!this._isHttps) {
if (!allowHttp) { if (!allowHttp) {
console.warn( console.warn(
'⚠️ WARNING: Using HTTP instead of HTTPS. ' + 'WARNING: Using HTTP instead of HTTPS. ' +
'This is INSECURE and should only be used for local development. ' + 'This is INSECURE and should only be used for local development. ' +
'Man-in-the-middle attacks are possible!' 'Man-in-the-middle attacks are possible!'
); );
} else { } else {
console.log('HTTP mode enabled for local development (INSECURE)'); if (this.debugMode) console.log('HTTP mode enabled for local development (INSECURE)');
} }
} }
// Initialize components // Initialize components
this.keyManager = new KeyManager(); this.keyManager = new KeyManager(this.debugMode);
this.aes = new AESEncryption(); this.aes = new AESEncryption();
this.rsa = new RSAOperations(); this.rsa = new RSAOperations();
this.httpClient = createHttpClient(); this.httpClient = createHttpClient();
// Log memory protection info // Log memory protection info
const protectionInfo = this.secureMemoryImpl.getProtectionInfo(); const protectionInfo = this.secureMemoryImpl.getProtectionInfo();
console.log(`Memory protection: ${protectionInfo.method} (${protectionInfo.details})`); if (this.debugMode) console.log(`Memory protection: ${protectionInfo.method} (${protectionInfo.details})`);
// Start key rotation timer
if (this.keyRotationInterval > 0) {
this.startKeyRotationTimer();
}
}
/**
* Release resources: cancel key rotation timer and zero in-memory key material.
* After calling dispose(), all methods throw DisposedError.
*/
dispose(): void {
if (this.disposed) return;
this.disposed = true;
if (this.keyRotationTimer !== undefined) {
clearInterval(this.keyRotationTimer);
this.keyRotationTimer = undefined;
}
this.keyManager.zeroKeys();
}
private assertNotDisposed(): void {
if (this.disposed) {
throw new DisposedError();
}
}
private startKeyRotationTimer(): void {
this.keyRotationTimer = setInterval(
() => { void this.rotateKeys(); },
this.keyRotationInterval
);
// Allow the process to exit without waiting for the next rotation tick
const timer = this.keyRotationTimer as unknown as { unref?: () => void };
if (typeof timer.unref === 'function') {
timer.unref();
}
}
private async rotateKeys(): Promise<void> {
if (this.disposed) return;
if (this.debugMode) console.log('Key rotation: generating new key pair...');
try {
await this.keyManager.rotateKeys({
keySize: this.keySize,
saveToFile: typeof window === 'undefined',
keyDir: this.keyRotationDir ?? 'client_keys',
password: this.keyRotationPassword,
});
if (this.debugMode) console.log('Key rotation: complete');
} catch (err) {
console.error('Key rotation failed:', err instanceof Error ? err.message : 'unknown error');
}
} }
/** /**
@ -109,6 +201,7 @@ export class SecureCompletionClient {
keyDir?: string; keyDir?: string;
password?: string; password?: string;
} = {}): Promise<void> { } = {}): Promise<void> {
this.assertNotDisposed();
await this.keyManager.generateKeys({ await this.keyManager.generateKeys({
keySize: this.keySize, keySize: this.keySize,
...options, ...options,
@ -123,6 +216,7 @@ export class SecureCompletionClient {
publicKeyPath?: string, publicKeyPath?: string,
password?: string password?: string
): Promise<void> { ): Promise<void> {
this.assertNotDisposed();
await this.keyManager.loadKeys( await this.keyManager.loadKeys(
{ privateKeyPath, publicKeyPath }, { privateKeyPath, publicKeyPath },
password password
@ -130,36 +224,54 @@ export class SecureCompletionClient {
} }
/** /**
* Ensure keys are loaded, generate if necessary * Ensure keys are loaded, generate if necessary.
* Uses a Promise-chain mutex to prevent concurrent key generation races.
*/ */
private async ensureKeys(): Promise<void> { private ensureKeys(): Promise<void> {
if (this.keyManager.hasKeys()) { let resolve!: () => void;
return; let reject!: (e: unknown) => void;
} const callerPromise = new Promise<void>((res, rej) => {
resolve = res;
reject = rej;
});
// Append to the shared chain so callers queue up
this.ensureKeysLock = this.ensureKeysLock.then(async () => {
try {
await this._doEnsureKeys();
resolve();
} catch (e) {
reject(e);
}
});
return callerPromise;
}
// Try to load keys from default location (Node.js only) private async _doEnsureKeys(): Promise<void> {
if (this.keyManager.hasKeys()) return;
// Try to load keys from the configured directory (Node.js only)
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
try { try {
const fs = require('fs').promises as { access: (p: string) => Promise<void> }; const fs = require('fs').promises as { access: (p: string) => Promise<void> };
const path = require('path') as { join: (...p: string[]) => string }; const path = require('path') as { join: (...p: string[]) => string };
const privateKeyPath = path.join('client_keys', 'private_key.pem'); const privateKeyPath = path.join(this.keyDir, 'private_key.pem');
const publicKeyPath = path.join('client_keys', 'public_key.pem'); const publicKeyPath = path.join(this.keyDir, 'public_key.pem');
await fs.access(privateKeyPath); await fs.access(privateKeyPath);
await fs.access(publicKeyPath); await fs.access(publicKeyPath);
await this.loadKeys(privateKeyPath, publicKeyPath); await this.loadKeys(privateKeyPath, publicKeyPath);
console.log('Loaded existing keys from client_keys/'); if (this.debugMode) console.log(`Loaded existing keys from ${this.keyDir}/`);
return; return;
} catch (_error) { } catch (_error) {
console.log('No existing keys found, generating new keys...'); if (this.debugMode) console.log(`No existing keys found in ${this.keyDir}/, generating new keys...`);
} }
} }
await this.generateKeys({ await this.generateKeys({
saveToFile: typeof window === 'undefined', saveToFile: typeof window === 'undefined',
keyDir: 'client_keys', keyDir: this.keyDir,
}); });
} }
@ -167,14 +279,15 @@ export class SecureCompletionClient {
* Fetch server's public key from /pki/public_key endpoint * Fetch server's public key from /pki/public_key endpoint
*/ */
async fetchServerPublicKey(): Promise<string> { async fetchServerPublicKey(): Promise<string> {
console.log("Fetching server's public key..."); this.assertNotDisposed();
if (this.debugMode) console.log("Fetching server's public key...");
if (!this.routerUrl.startsWith('https://')) { if (!this._isHttps) {
if (!this.allowHttp) { if (!this.allowHttp) {
throw new SecurityError( throw new SecurityError(
'Server public key must be fetched over HTTPS to prevent MITM attacks. ' + 'Server public key must be fetched over HTTPS to prevent MITM attacks. ' +
'For local development, initialize with allowHttp=true: ' + 'For local development, initialize with allowHttp=true: ' +
'new SecureChatCompletion({ baseUrl: "http://localhost:12434", allowHttp: true })' 'new SecureChatCompletion({ baseUrl: "http://localhost:12435", allowHttp: true })'
); );
} else { } else {
console.warn('Fetching key over HTTP (local development mode)'); console.warn('Fetching key over HTTP (local development mode)');
@ -184,7 +297,7 @@ export class SecureCompletionClient {
const url = `${this.routerUrl}/pki/public_key`; const url = `${this.routerUrl}/pki/public_key`;
try { try {
const response = await this.httpClient.get(url, { timeout: 60000 }); const response = await this.httpClient.get(url, { timeout: this.requestTimeout });
if (response.statusCode === 200) { if (response.statusCode === 200) {
const serverPublicKey = arrayBufferToString(response.body); const serverPublicKey = arrayBufferToString(response.body);
@ -196,8 +309,8 @@ export class SecureCompletionClient {
throw new Error('Server returned invalid public key format'); throw new Error('Server returned invalid public key format');
} }
if (this.routerUrl.startsWith('https://')) { if (this._isHttps) {
console.log("Server's public key fetched securely over HTTPS"); if (this.debugMode) console.log("Server's public key fetched securely over HTTPS");
} else { } else {
console.warn("Server's public key fetched over HTTP (INSECURE)"); console.warn("Server's public key fetched over HTTP (INSECURE)");
} }
@ -227,7 +340,7 @@ export class SecureCompletionClient {
* - encrypted_aes_key: AES key encrypted with server's RSA public key * - encrypted_aes_key: AES key encrypted with server's RSA public key
*/ */
async encryptPayload(payload: object): Promise<ArrayBuffer> { async encryptPayload(payload: object): Promise<ArrayBuffer> {
console.log('Encrypting payload...'); this.assertNotDisposed();
if (!payload || typeof payload !== 'object') { if (!payload || typeof payload !== 'object') {
throw new Error('Payload must be an object'); throw new Error('Payload must be an object');
@ -243,7 +356,7 @@ export class SecureCompletionClient {
throw new Error(`Payload too large: ${payloadBytes.byteLength} bytes (max: ${MAX_PAYLOAD_SIZE})`); throw new Error(`Payload too large: ${payloadBytes.byteLength} bytes (max: ${MAX_PAYLOAD_SIZE})`);
} }
console.log(`Payload size: ${payloadBytes.byteLength} bytes`); if (this.debugMode) console.log(`Payload size: ${payloadBytes.byteLength} bytes`);
if (this.secureMemory) { if (this.secureMemory) {
const context = new SecureByteContext(payloadBytes, true); const context = new SecureByteContext(payloadBytes, true);
@ -297,7 +410,7 @@ export class SecureCompletionClient {
const packageJson = JSON.stringify(encryptedPackage); const packageJson = JSON.stringify(encryptedPackage);
const packageBytes = stringToArrayBuffer(packageJson); const packageBytes = stringToArrayBuffer(packageJson);
console.log(`Encrypted package size: ${packageBytes.byteLength} bytes`); if (this.debugMode) console.log(`Encrypted package size: ${packageBytes.byteLength} bytes`);
return packageBytes; return packageBytes;
}); });
@ -310,7 +423,7 @@ export class SecureCompletionClient {
* Web Crypto AES-GCM decrypt expects ciphertext || tag concatenated. * Web Crypto AES-GCM decrypt expects ciphertext || tag concatenated.
*/ */
async decryptResponse(encryptedResponse: ArrayBuffer, payloadId: string): Promise<Record<string, unknown>> { async decryptResponse(encryptedResponse: ArrayBuffer, payloadId: string): Promise<Record<string, unknown>> {
console.log('Decrypting response...'); this.assertNotDisposed();
if (!encryptedResponse || encryptedResponse.byteLength === 0) { if (!encryptedResponse || encryptedResponse.byteLength === 0) {
throw new Error('Empty encrypted response'); throw new Error('Empty encrypted response');
@ -332,6 +445,22 @@ export class SecureCompletionClient {
} }
} }
// Validate version and algorithm to prevent downgrade attacks
const SUPPORTED_VERSION = '1.0';
const SUPPORTED_ALGORITHM = 'hybrid-aes256-rsa4096';
if (packageData.version !== SUPPORTED_VERSION) {
throw new Error(
`Unsupported protocol version: '${String(packageData.version)}'. ` +
`Expected: '${SUPPORTED_VERSION}'`
);
}
if (packageData.algorithm !== SUPPORTED_ALGORITHM) {
throw new Error(
`Unsupported encryption algorithm: '${String(packageData.algorithm)}'. ` +
`Expected: '${SUPPORTED_ALGORITHM}'`
);
}
const encryptedPayload = packageData.encrypted_payload as Record<string, unknown>; const encryptedPayload = packageData.encrypted_payload as Record<string, unknown>;
if (typeof encryptedPayload !== 'object' || encryptedPayload === null) { if (typeof encryptedPayload !== 'object' || encryptedPayload === null) {
throw new Error('Invalid encrypted_payload: must be an object'); throw new Error('Invalid encrypted_payload: must be an object');
@ -368,7 +497,22 @@ export class SecureCompletionClient {
const plaintextContext = new SecureByteContext(plaintext, this.secureMemory); const plaintextContext = new SecureByteContext(plaintext, this.secureMemory);
return await plaintextContext.use(async (protectedPlaintext) => { return await plaintextContext.use(async (protectedPlaintext) => {
const responseJson = arrayBufferToString(protectedPlaintext); const responseJson = arrayBufferToString(protectedPlaintext);
return JSON.parse(responseJson) as Record<string, unknown>; const decoded = JSON.parse(responseJson) as Record<string, unknown>;
// Validate required ChatCompletionResponse fields
if (
typeof decoded.id !== 'string' ||
typeof decoded.object !== 'string' ||
typeof decoded.created !== 'number' ||
typeof decoded.model !== 'string' ||
!Array.isArray(decoded.choices)
) {
throw new SecurityError(
'Decrypted response does not conform to expected schema'
);
}
return decoded;
}); });
}); });
@ -382,7 +526,7 @@ export class SecureCompletionClient {
encryption_algorithm: packageData.algorithm, encryption_algorithm: packageData.algorithm,
}; };
console.log('Response decrypted successfully'); if (this.debugMode) console.log('Response decrypted successfully');
return response; return response;
} catch (error) { } catch (error) {
// Don't leak specific decryption errors (timing attacks) // Don't leak specific decryption errors (timing attacks)
@ -393,6 +537,9 @@ export class SecureCompletionClient {
/** /**
* Send a secure chat completion request to the router. * Send a secure chat completion request to the router.
* *
* Retries on transient errors (429, 500, 502, 503, 504, network errors)
* with exponential backoff matching the Python SDK's `max_retries` behaviour.
*
* @param securityTier Optional routing tier: "standard" | "high" | "maximum" * @param securityTier Optional routing tier: "standard" | "high" | "maximum"
*/ */
async sendSecureRequest( async sendSecureRequest(
@ -401,7 +548,8 @@ export class SecureCompletionClient {
apiKey?: string, apiKey?: string,
securityTier?: string securityTier?: string
): Promise<Record<string, unknown>> { ): Promise<Record<string, unknown>> {
console.log('Sending secure chat completion request...'); this.assertNotDisposed();
if (this.debugMode) console.log('Sending secure chat completion request...');
// Validate security tier // Validate security tier
if (securityTier !== undefined) { if (securityTier !== undefined) {
@ -413,9 +561,14 @@ export class SecureCompletionClient {
} }
} }
await this.ensureKeys(); // Validate API key does not contain header injection characters
if (apiKey !== undefined) {
if (/[\r\n]/.test(apiKey)) {
throw new SecurityError('Invalid API key: must not contain line separator characters');
}
}
const encryptedPayload = await this.encryptPayload(payload); await this.ensureKeys();
const publicKeyPem = await this.keyManager.getPublicKeyPEM(); const publicKeyPem = await this.keyManager.getPublicKeyPEM();
const headers: Record<string, string> = { const headers: Record<string, string> = {
@ -433,33 +586,88 @@ export class SecureCompletionClient {
} }
const url = `${this.routerUrl}/v1/chat/secure_completion`; const url = `${this.routerUrl}/v1/chat/secure_completion`;
console.log(`Target URL: ${url}`); if (this.debugMode) console.log(`Target URL: ${url}`);
let response: { statusCode: number; body: ArrayBuffer }; // Retry loop — mirrors Python SDK's max_retries + exponential backoff.
try { // The payload is re-encrypted on every attempt so each attempt gets a
response = await this.httpClient.post(url, { // fresh AES key and nonce (the HTTP client zeros the buffer after write).
headers, let lastError: Error = new APIConnectionError('Request failed');
body: encryptedPayload,
timeout: 60000, for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
}); if (attempt > 0) {
} catch (error) { const delaySec = Math.pow(2, attempt - 1); // 1 s, 2 s, 4 s, …
if (error instanceof Error) { if (this.debugMode) {
if (error.message === 'Request timeout') { console.warn(
throw new APIConnectionError('Connection to server timed out'); `Retrying request (attempt ${attempt}/${this.maxRetries}) ` +
`after ${delaySec}s...`
);
} }
throw new APIConnectionError(`Failed to connect to router: ${error.message}`); await new Promise<void>(resolve => setTimeout(resolve, delaySec * 1000));
} }
throw error;
// Re-encrypt each attempt (throws non-retryable errors like SecurityError
// or DisposedError — let those propagate immediately)
const encryptedPayload = await this.encryptPayload(payload);
let response: { statusCode: number; body: ArrayBuffer };
try {
response = await this.httpClient.post(url, {
headers,
body: encryptedPayload,
timeout: this.requestTimeout,
});
} catch (error) {
// Network / timeout errors from the HTTP client
let connError: APIConnectionError;
if (error instanceof Error) {
connError = error.message === 'Request timeout'
? new APIConnectionError('Connection to server timed out')
: new APIConnectionError(`Failed to connect to router: ${error.message}`);
} else {
connError = new APIConnectionError('Failed to connect to router: unknown error');
}
lastError = connError;
if (attempt < this.maxRetries) {
if (this.debugMode) console.warn(`Network error on attempt ${attempt}: ${connError.message}`);
continue;
}
throw lastError;
}
if (this.debugMode) console.log(`HTTP Status: ${response.statusCode}`);
if (response.statusCode === 200) {
return await this.decryptResponse(response.body, payloadId);
}
const err = this.buildErrorFromResponse(response);
if (this.isRetryableError(err) && attempt < this.maxRetries) {
if (this.debugMode) {
console.warn(`Got retryable status ${response.statusCode}: retrying...`);
}
lastError = err;
continue;
}
throw err;
} }
console.log(`HTTP Status: ${response.statusCode}`); throw lastError;
}
if (response.statusCode === 200) { /**
return await this.decryptResponse(response.body, payloadId); * Return true for errors that warrant a retry (transient failures).
} * Non-retryable errors (auth, bad request, forbidden, etc.) propagate immediately.
*/
// Map HTTP error status codes to typed errors private isRetryableError(error: Error): boolean {
throw this.buildErrorFromResponse(response); if (error instanceof APIConnectionError) return true;
if (error instanceof RateLimitError) return true;
if (error instanceof ServerError) return true;
if (error instanceof ServiceUnavailableError) return true;
// 502 Bad Gateway and 504 Gateway Timeout fall through as generic APIError
if (error instanceof APIError && (error.statusCode === 502 || error.statusCode === 504)) return true;
return false;
} }
/** /**
@ -474,7 +682,9 @@ export class SecureCompletionClient {
// Ignore JSON parse errors // Ignore JSON parse errors
} }
const detail = (errorData.detail as string | undefined) ?? 'Unknown error'; // Truncate and strip non-printable chars to prevent log injection
const rawDetail = (errorData.detail as string | undefined) ?? 'Unknown error';
const detail = rawDetail.slice(0, 100).replace(/[^\x20-\x7E]/g, '');
switch (response.statusCode) { switch (response.statusCode) {
case 400: case 400:
@ -526,7 +736,7 @@ export class SecureCompletionClient {
); );
} }
console.log(`Valid ${algorithm.modulusLength}-bit RSA ${keyType} key`); if (this.debugMode) console.log(`Valid ${algorithm.modulusLength}-bit RSA ${keyType} key`);
} }
} }

View file

@ -36,14 +36,20 @@ export class AESEncryption {
data: ArrayBuffer, data: ArrayBuffer,
key: CryptoKey key: CryptoKey
): Promise<{ ciphertext: ArrayBuffer; nonce: ArrayBuffer }> { ): Promise<{ ciphertext: ArrayBuffer; nonce: ArrayBuffer }> {
// Generate random 96-bit (12-byte) nonce // Generate random 96-bit (12-byte) nonce — copy into a plain ArrayBuffer
const nonce = generateRandomBytes(12); // so the buffer type is strictly ArrayBuffer (not ArrayBufferLike)
const nonceRaw = generateRandomBytes(12);
const nonce = nonceRaw.buffer.slice(
nonceRaw.byteOffset,
nonceRaw.byteOffset + nonceRaw.byteLength
) as ArrayBuffer;
const nonceView = new Uint8Array(nonce);
// Encrypt with AES-GCM // Encrypt with AES-GCM
const ciphertext = await this.subtle.encrypt( const ciphertext = await this.subtle.encrypt(
{ {
name: 'AES-GCM', name: 'AES-GCM',
iv: nonce, iv: nonceView,
tagLength: 128, // 128-bit authentication tag tagLength: 128, // 128-bit authentication tag
}, },
key, key,
@ -52,7 +58,7 @@ export class AESEncryption {
return { return {
ciphertext, ciphertext,
nonce: nonce.buffer, nonce,
}; };
} }
@ -80,8 +86,8 @@ export class AESEncryption {
); );
return plaintext; return plaintext;
} catch (error) { } catch (_error) {
throw new Error(`AES-GCM decryption failed: ${error instanceof Error ? error.message : 'Unknown error'}`); throw new Error('AES-GCM decryption failed');
} }
} }

View file

@ -1,12 +1,13 @@
/** /**
* Key management for RSA key pairs * Key management for RSA key pairs
* Handles key generation, loading, and persistence * Handles key generation, loading, and persistence
* *
* NOTE: Browser storage is NOT implemented in this version for security reasons. * NOTE: Browser storage is NOT implemented in this version for security reasons.
* Keys are kept in-memory only in browsers. For persistent keys, use Node.js. * Keys are kept in-memory only in browsers. For persistent keys, use Node.js.
*/ */
import { RSAOperations } from './rsa'; import { RSAOperations } from './rsa';
import { getCrypto } from './utils';
import { KeyGenOptions, KeyPaths } from '../../types/client'; import { KeyGenOptions, KeyPaths } from '../../types/client';
export class KeyManager { export class KeyManager {
@ -14,9 +15,11 @@ export class KeyManager {
private publicKey?: CryptoKey; private publicKey?: CryptoKey;
private privateKey?: CryptoKey; private privateKey?: CryptoKey;
private publicKeyPem?: string; private publicKeyPem?: string;
private debug: boolean;
constructor() { constructor(debug = false) {
this.rsa = new RSAOperations(); this.rsa = new RSAOperations();
this.debug = debug;
} }
/** /**
@ -31,7 +34,11 @@ export class KeyManager {
password, password,
} = options; } = options;
console.log(`Generating ${keySize}-bit RSA key pair...`); if (password !== undefined && password.length < 8) {
throw new Error('Password must be at least 8 characters');
}
if (this.debug) console.log(`Generating ${keySize}-bit RSA key pair...`);
// Generate key pair // Generate key pair
const keyPair = await this.rsa.generateKeyPair(keySize); const keyPair = await this.rsa.generateKeyPair(keySize);
@ -41,7 +48,7 @@ export class KeyManager {
// Export public key to PEM // Export public key to PEM
this.publicKeyPem = await this.rsa.exportPublicKey(this.publicKey); this.publicKeyPem = await this.rsa.exportPublicKey(this.publicKey);
console.log(`Generated ${keySize}-bit RSA key pair`); if (this.debug) console.log(`Generated ${keySize}-bit RSA key pair`);
// Save to file if requested (Node.js only) // Save to file if requested (Node.js only)
if (saveToFile) { if (saveToFile) {
@ -60,14 +67,40 @@ export class KeyManager {
throw new Error('File-based key loading is not supported in browsers. Use in-memory keys only.'); throw new Error('File-based key loading is not supported in browsers. Use in-memory keys only.');
} }
console.log('Loading keys from files...'); if (password !== undefined && password.length < 8) {
throw new Error('Password must be at least 8 characters');
}
if (this.debug) console.log('Loading keys from files...');
const fs = require('fs').promises; const fs = require('fs').promises;
const path = require('path'); const path = require('path');
// Load private key // Load private key
const privateKeyPem = await fs.readFile(paths.privateKeyPath, 'utf-8'); const privateKeyPem = await fs.readFile(paths.privateKeyPath, 'utf-8') as string;
this.privateKey = await this.rsa.importPrivateKey(privateKeyPem, password);
if (password && privateKeyPem.includes('BEGIN ENCRYPTED PRIVATE KEY')) {
// Standard PKCS#8 encrypted format (produced by Python/OpenSSL)
const { createPrivateKey } = require('crypto') as typeof import('crypto');
const keyObject = createPrivateKey({ key: privateKeyPem, format: 'pem', passphrase: password });
const pkcs8Der = keyObject.export({ type: 'pkcs8', format: 'der' }) as Buffer;
// Copy into a plain ArrayBuffer to satisfy strict Web Crypto typings
const pkcs8Buf = pkcs8Der.buffer.slice(
pkcs8Der.byteOffset,
pkcs8Der.byteOffset + pkcs8Der.byteLength
) as ArrayBuffer;
const subtle = getCrypto();
this.privateKey = await subtle.importKey(
'pkcs8',
pkcs8Buf,
{ name: 'RSA-OAEP', hash: 'SHA-256' },
true,
['decrypt']
);
} else {
// Unencrypted PKCS#8 or legacy JS custom-encrypted format
this.privateKey = await this.rsa.importPrivateKey(privateKeyPem, password);
}
// Validate private key size (minimum 2048 bits) // Validate private key size (minimum 2048 bits)
const privAlgorithm = this.privateKey.algorithm as RsaHashedKeyAlgorithm; const privAlgorithm = this.privateKey.algorithm as RsaHashedKeyAlgorithm;
@ -79,21 +112,25 @@ export class KeyManager {
); );
} }
// Load or derive public key // Load or derive public key — use local variables to satisfy strict null checks
if (paths.publicKeyPath) { if (paths.publicKeyPath) {
this.publicKeyPem = await fs.readFile(paths.publicKeyPath, 'utf-8'); const pem = await fs.readFile(paths.publicKeyPath, 'utf-8') as string;
this.publicKey = await this.rsa.importPublicKey(this.publicKeyPem); this.publicKeyPem = pem;
this.publicKey = await this.rsa.importPublicKey(pem);
} else { } else {
const publicKeyPath = path.join( const publicKeyPath = path.join(
path.dirname(paths.privateKeyPath), path.dirname(paths.privateKeyPath),
'public_key.pem' 'public_key.pem'
); );
this.publicKeyPem = await fs.readFile(publicKeyPath, 'utf-8'); const pem = await fs.readFile(publicKeyPath, 'utf-8') as string;
this.publicKey = await this.rsa.importPublicKey(this.publicKeyPem); this.publicKeyPem = pem;
this.publicKey = await this.rsa.importPublicKey(pem);
} }
console.log(`Valid ${privAlgorithm.modulusLength}-bit RSA private key`); if (this.debug) {
console.log('Keys loaded successfully'); console.log(`Valid ${privAlgorithm.modulusLength}-bit RSA private key`);
console.log('Keys loaded successfully');
}
} }
/** /**
@ -114,20 +151,30 @@ export class KeyManager {
const fs = require('fs').promises; const fs = require('fs').promises;
const path = require('path'); const path = require('path');
console.log(`Saving keys to ${directory}/...`); if (this.debug) console.log(`Saving keys to ${directory}/...`);
// Create directory if it doesn't exist // Create directory if it doesn't exist
await fs.mkdir(directory, { recursive: true }); await fs.mkdir(directory, { recursive: true });
// Export and save private key // Export and save private key
const privateKeyPem = await this.rsa.exportPrivateKey(this.privateKey, password); let privateKeyPem: string;
if (password) {
// Use standard PKCS#8 encrypted format (compatible with Python/OpenSSL)
const { createPrivateKey } = require('crypto') as typeof import('crypto');
const subtle = getCrypto();
const pkcs8Der = await subtle.exportKey('pkcs8', this.privateKey);
const keyObject = createPrivateKey({ key: Buffer.from(pkcs8Der), format: 'der', type: 'pkcs8' });
privateKeyPem = keyObject.export({ type: 'pkcs8', format: 'pem', cipher: 'aes-256-cbc', passphrase: password }) as string;
} else {
privateKeyPem = await this.rsa.exportPrivateKey(this.privateKey);
}
const privateKeyPath = path.join(directory, 'private_key.pem'); const privateKeyPath = path.join(directory, 'private_key.pem');
await fs.writeFile(privateKeyPath, privateKeyPem, 'utf-8'); await fs.writeFile(privateKeyPath, privateKeyPem, 'utf-8');
// Set restrictive permissions on private key (Unix-like systems) // Set restrictive permissions on private key (Unix-like systems)
try { try {
await fs.chmod(privateKeyPath, 0o600); // Owner read/write only await fs.chmod(privateKeyPath, 0o600); // Owner read/write only
console.log('Private key permissions set to 600 (owner-only access)'); if (this.debug) console.log('Private key permissions set to 600 (owner-only access)');
} catch (error) { } catch (error) {
console.warn('Could not set private key permissions:', error); console.warn('Could not set private key permissions:', error);
} }
@ -142,18 +189,18 @@ export class KeyManager {
// Set permissions on public key // Set permissions on public key
try { try {
await fs.chmod(publicKeyPath, 0o644); // Owner read/write, others read await fs.chmod(publicKeyPath, 0o644); // Owner read/write, others read
console.log('Public key permissions set to 644'); if (this.debug) console.log('Public key permissions set to 644');
} catch (error) { } catch (error) {
console.warn('Could not set public key permissions:', error); console.warn('Could not set public key permissions:', error);
} }
if (password) { if (password) {
console.log('Private key encrypted with password'); if (this.debug) console.log('Private key encrypted with password');
} else { } else {
console.warn('Private key saved UNENCRYPTED (not recommended for production)'); console.warn('Private key saved UNENCRYPTED (not recommended for production)');
} }
console.log(`Keys saved to ${directory}/`); if (this.debug) console.log(`Keys saved to ${directory}/`);
} }
/** /**
@ -195,4 +242,26 @@ export class KeyManager {
hasKeys(): boolean { hasKeys(): boolean {
return !!(this.privateKey && this.publicKey); return !!(this.privateKey && this.publicKey);
} }
/**
* Zero in-memory key references.
* CryptoKey objects are opaque handles their backing memory is owned by the
* Web Crypto engine and cannot be zeroed from JavaScript. We sever the
* references so the GC can collect them as soon as possible.
*/
zeroKeys(): void {
this.privateKey = undefined;
this.publicKey = undefined;
// Strings are immutable; we can only null the reference.
this.publicKeyPem = undefined;
}
/**
* Rotate keys: zero the existing pair then generate a fresh one.
* @param options Key generation options (same as generateKeys)
*/
async rotateKeys(options: KeyGenOptions = {}): Promise<void> {
this.zeroKeys();
await this.generateKeys(options);
}
} }

View file

@ -3,7 +3,8 @@
* Matches the Python implementation using RSA-OAEP with SHA-256 * Matches the Python implementation using RSA-OAEP with SHA-256
*/ */
import { getCrypto, pemToArrayBuffer, arrayBufferToPem, stringToArrayBuffer, arrayBufferToString } from './utils'; import { getCrypto, pemToArrayBuffer, arrayBufferToPem, stringToArrayBuffer, arrayBufferToString, generateRandomBytes } from './utils';
import { SecureByteContext } from '../memory/secure';
export class RSAOperations { export class RSAOperations {
private subtle: SubtleCrypto; private subtle: SubtleCrypto;
@ -60,8 +61,8 @@ export class RSAOperations {
privateKey, privateKey,
encryptedKey encryptedKey
); );
} catch (error) { } catch (_error) {
throw new Error(`RSA-OAEP decryption failed: ${error instanceof Error ? error.message : 'Unknown error'}`); throw new Error('RSA key decryption failed');
} }
} }
@ -148,47 +149,52 @@ export class RSAOperations {
* @returns PEM-encoded encrypted private key * @returns PEM-encoded encrypted private key
*/ */
private async encryptPrivateKeyWithPassword(keyData: ArrayBuffer, password: string): Promise<string> { private async encryptPrivateKeyWithPassword(keyData: ArrayBuffer, password: string): Promise<string> {
// Derive encryption key from password using PBKDF2 // Wrap password bytes so they are zeroed after key derivation
const passwordKey = await this.subtle.importKey( const passwordBytes = stringToArrayBuffer(password);
'raw', const pwContext = new SecureByteContext(passwordBytes, true);
stringToArrayBuffer(password), return pwContext.use(async (pwData) => {
'PBKDF2', const passwordKey = await this.subtle.importKey(
false, 'raw',
['deriveKey'] pwData,
); 'PBKDF2',
false,
['deriveKey']
);
const salt = crypto.getRandomValues(new Uint8Array(16)); // Wrap salt so it is zeroed after use
const derivedKey = await this.subtle.deriveKey( const saltBytes = generateRandomBytes(16);
{ const saltContext = new SecureByteContext(saltBytes.buffer as ArrayBuffer, true);
name: 'PBKDF2', return saltContext.use(async (saltBuf) => {
salt: salt, const saltView = new Uint8Array(saltBuf);
iterations: 100000, const derivedKey = await this.subtle.deriveKey(
hash: 'SHA-256', { name: 'PBKDF2', salt: saltView, iterations: 100000, hash: 'SHA-256' },
}, passwordKey,
passwordKey, { name: 'AES-CBC', length: 256 },
{ name: 'AES-CBC', length: 256 }, false,
false, ['encrypt']
['encrypt'] );
);
// Encrypt private key with AES-256-CBC // Wrap IV so it is zeroed after use
const iv = crypto.getRandomValues(new Uint8Array(16)); const ivBytes = generateRandomBytes(16);
const encrypted = await this.subtle.encrypt( const ivContext = new SecureByteContext(ivBytes.buffer as ArrayBuffer, true);
{ return ivContext.use(async (ivBuf) => {
name: 'AES-CBC', const ivView = new Uint8Array(ivBuf);
iv: iv, const encrypted = await this.subtle.encrypt(
}, { name: 'AES-CBC', iv: ivView },
derivedKey, derivedKey,
keyData keyData
); );
// Combine salt + iv + encrypted data // Combine salt + iv + encrypted data
const combined = new Uint8Array(salt.length + iv.length + encrypted.byteLength); const combined = new Uint8Array(saltView.length + ivView.length + encrypted.byteLength);
combined.set(salt, 0); combined.set(saltView, 0);
combined.set(iv, salt.length); combined.set(ivView, saltView.length);
combined.set(new Uint8Array(encrypted), salt.length + iv.length); combined.set(new Uint8Array(encrypted), saltView.length + ivView.length);
return arrayBufferToPem(combined.buffer, 'PRIVATE'); return arrayBufferToPem(combined.buffer, 'PRIVATE');
});
});
});
} }
/** /**

View file

@ -75,6 +75,10 @@ export class NodeHttpClient implements HttpClient {
}); });
req.write(bodyBuffer); req.write(bodyBuffer);
// Zero the source ArrayBuffer after data has been handed to the socket
if (body instanceof ArrayBuffer) {
new Uint8Array(body).fill(0);
}
req.end(); req.end();
}); });
} }

View file

@ -1,15 +1,46 @@
/** /**
* Secure memory interface and context manager * Secure memory interface, context manager, and public API.
* *
* IMPORTANT: This is a pure JavaScript implementation that provides memory zeroing only. * IMPORTANT: This is a pure JavaScript implementation that provides memory zeroing only.
* OS-level memory locking (mlock) is NOT implemented in this version. * OS-level memory locking (mlock) is NOT implemented in this version.
* *
* For production use, consider implementing a native addon for true memory locking. * For production use, consider implementing a native addon for true memory locking.
* See SECURITY.md for details on memory protection limitations. * See SECURITY.md for details on memory protection limitations.
*/ */
import { ProtectionInfo } from '../../types/crypto'; import { ProtectionInfo } from '../../types/crypto';
// ─── Global secure-memory state ──────────────────────────────────────────────
/** Module-level flag, mirrors Python's global _secure_memory.enabled. */
let _globalSecureMemoryEnabled = true;
/**
* Disable secure memory operations globally.
* Affects new SecureByteContext instances created without an explicit `useSecure` argument.
* Existing client instances are unaffected (they pass `useSecure` explicitly).
* Mirrors Python's `disable_secure_memory()`.
*/
export function disableSecureMemory(): void {
_globalSecureMemoryEnabled = false;
}
/**
* Re-enable secure memory operations globally.
* Mirrors Python's `enable_secure_memory()`.
*/
export function enableSecureMemory(): void {
_globalSecureMemoryEnabled = true;
}
/**
* Return information about the memory protection capabilities available on this
* platform/runtime. Mirrors Python's `get_memory_protection_info()`.
*/
export function getMemoryProtectionInfo(): ProtectionInfo {
return createSecureMemory().getProtectionInfo();
}
export interface SecureMemory { export interface SecureMemory {
/** /**
* Zero memory (fill with zeros) * Zero memory (fill with zeros)
@ -24,15 +55,19 @@ export interface SecureMemory {
} }
/** /**
* Secure byte context manager * Secure byte context manager.
* Ensures memory is zeroed even if an exception occurs (similar to Python's context manager) * Ensures memory is zeroed even if an exception occurs (analogous to Python's
* `secure_bytearray()` context manager and `SecureBuffer` class).
*
* When `useSecure` is omitted, the module-level global flag set by
* `disableSecureMemory()` / `enableSecureMemory()` is consulted.
*/ */
export class SecureByteContext { export class SecureByteContext {
private data: ArrayBuffer; private data: ArrayBuffer;
private secureMemory: SecureMemory; private secureMemory: SecureMemory;
private useSecure: boolean; private useSecure: boolean;
constructor(data: ArrayBuffer, useSecure: boolean = true) { constructor(data: ArrayBuffer, useSecure: boolean = _globalSecureMemoryEnabled) {
this.data = data; this.data = data;
this.useSecure = useSecure; this.useSecure = useSecure;
this.secureMemory = createSecureMemory(); this.secureMemory = createSecureMemory();
@ -48,7 +83,11 @@ export class SecureByteContext {
} finally { } finally {
// Always zero memory, even if exception occurred // Always zero memory, even if exception occurred
if (this.useSecure) { if (this.useSecure) {
this.secureMemory.zeroMemory(this.data); try {
this.secureMemory.zeroMemory(this.data);
} catch (_zeroErr) {
// zeroMemory failure must not mask the original error
}
} }
} }
} }

View file

@ -84,3 +84,14 @@ export class SecurityError extends Error {
} }
} }
} }
export class DisposedError extends Error {
constructor(message = 'This client instance has been disposed and can no longer be used') {
super(message);
this.name = 'DisposedError';
if (captureStackTrace) {
captureStackTrace(this, this.constructor);
}
}
}

View file

@ -13,3 +13,13 @@ export * from './types/crypto';
// Export errors // Export errors
export * from './errors'; export * from './errors';
// Secure memory public API — mirrors Python's get_memory_protection_info(),
// disable_secure_memory(), enable_secure_memory(), and SecureBuffer/secure_bytearray()
export {
getMemoryProtectionInfo,
disableSecureMemory,
enableSecureMemory,
SecureByteContext,
createSecureMemory,
} from './core/memory/secure';

View file

@ -9,6 +9,8 @@ export interface Message {
name?: string; name?: string;
tool_calls?: ToolCall[]; tool_calls?: ToolCall[];
tool_call_id?: string; tool_call_id?: string;
/** Thinking-model reasoning output (Qwen3, DeepSeek-R1, etc.) */
reasoning_content?: string;
} }
export interface ToolCall { export interface ToolCall {
@ -70,12 +72,28 @@ export interface Choice {
logprobs?: unknown; logprobs?: unknown;
} }
export interface MemoryProtectionInfo {
enabled: boolean;
platform: string;
protection_level: string;
has_memory_locking: boolean;
has_secure_zeroing: boolean;
supports_full_protection: boolean;
page_size?: number;
}
export interface ResponseMetadata { export interface ResponseMetadata {
payload_id: string; payload_id: string;
processed_at: number; processed_at: number;
is_encrypted: boolean; is_encrypted: boolean;
encryption_algorithm: string; encryption_algorithm: string;
response_status: string; response_status: string;
/** Hardware routing tier used for this request */
security_tier?: string;
/** Server-side memory protection details */
memory_protection?: MemoryProtectionInfo;
/** CUDA device ID used for inference (if applicable) */
cuda_device?: string | number;
} }
export interface ChatCompletionResponse { export interface ChatCompletionResponse {

View file

@ -3,7 +3,7 @@
*/ */
export interface ClientConfig { export interface ClientConfig {
/** Base URL of the NOMYO router (e.g., https://api.nomyo.ai:12434) */ /** Base URL of the NOMYO router (e.g., https://api.nomyo.ai) */
routerUrl: string; routerUrl: string;
/** Allow HTTP connections (ONLY for local development, never in production) */ /** Allow HTTP connections (ONLY for local development, never in production) */
@ -17,6 +17,35 @@ export interface ClientConfig {
/** Optional API key for authentication */ /** Optional API key for authentication */
apiKey?: string; apiKey?: string;
/** Request timeout in milliseconds (default: 60000) */
timeout?: number;
/** Enable debug logging (default: false) */
debug?: boolean;
/** Key rotation interval in milliseconds. Set to 0 to disable. (default: 86400000 = 24h) */
keyRotationInterval?: number;
/** Directory for rotated key files (Node.js only, default: 'client_keys') */
keyRotationDir?: string;
/** Password to encrypt rotated private key files */
keyRotationPassword?: string;
/**
* 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. Default: 'client_keys'.
* Matches the Python SDK's `key_dir` constructor parameter.
*/
keyDir?: string;
/**
* Maximum number of retries on retryable errors (429, 500, 502, 503, 504,
* network errors). Uses exponential backoff (1 s, 2 s, 4 s, ). Default: 2.
*/
maxRetries?: number;
} }
export interface KeyGenOptions { export interface KeyGenOptions {
@ -53,4 +82,34 @@ export interface ChatCompletionConfig {
/** Enable secure memory protection */ /** Enable secure memory protection */
secureMemory?: boolean; secureMemory?: boolean;
/** Request timeout in milliseconds (default: 60000) */
timeout?: number;
/** Enable debug logging (default: false) */
debug?: boolean;
/** Key rotation interval in milliseconds. Set to 0 to disable. (default: 86400000 = 24h) */
keyRotationInterval?: number;
/** Directory for rotated key files (Node.js only, default: 'client_keys') */
keyRotationDir?: string;
/** Password to encrypt rotated private key files */
keyRotationPassword?: string;
/**
* 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 set to undefined) to use the default 'client_keys/' directory.
* Matches the Python SDK's `key_dir` constructor parameter.
*/
keyDir?: string;
/**
* Maximum number of retries on retryable errors (429, 500, 502, 503, 504,
* network errors). Uses exponential backoff (1 s, 2 s, 4 s, ). Default: 2.
*/
maxRetries?: number;
} }

View file

@ -40,6 +40,20 @@ describe('AESEncryption', () => {
await expect(aes.decrypt(ciphertext, nonce, key2)).rejects.toThrow(); await expect(aes.decrypt(ciphertext, nonce, key2)).rejects.toThrow();
}); });
test('decrypt with wrong key throws generic message (no internal details)', async () => {
const key1 = await aes.generateKey();
const key2 = await aes.generateKey();
const { ciphertext, nonce } = await aes.encrypt(stringToArrayBuffer('secret'), key1);
await expect(aes.decrypt(ciphertext, nonce, key2))
.rejects.toThrow('AES-GCM decryption failed');
try {
await aes.decrypt(ciphertext, nonce, key2);
} catch (e) {
expect((e as Error).message).toBe('AES-GCM decryption failed');
}
});
test('exportKey / importKey roundtrip', async () => { test('exportKey / importKey roundtrip', async () => {
const key = await aes.generateKey(); const key = await aes.generateKey();
const exported = await aes.exportKey(key); const exported = await aes.exportKey(key);
@ -124,6 +138,64 @@ describe('RSAOperations', () => {
const pem = await rsa.exportPrivateKey(kp.privateKey, 'correct-password'); const pem = await rsa.exportPrivateKey(kp.privateKey, 'correct-password');
await expect(rsa.importPrivateKey(pem, 'wrong-password')).rejects.toThrow(); await expect(rsa.importPrivateKey(pem, 'wrong-password')).rejects.toThrow();
}, 30000); }, 30000);
test('decryptKey with wrong private key throws generic message', async () => {
const kp1 = await rsa.generateKeyPair(2048);
const kp2 = await rsa.generateKeyPair(2048);
const aes = new AESEncryption();
const aesKey = await aes.generateKey();
const aesKeyBytes = await aes.exportKey(aesKey);
const encrypted = await rsa.encryptKey(aesKeyBytes, kp1.publicKey);
await expect(rsa.decryptKey(encrypted, kp2.privateKey))
.rejects.toThrow('RSA key decryption failed');
try {
await rsa.decryptKey(encrypted, kp2.privateKey);
} catch (e) {
// Must not contain internal engine error details
expect((e as Error).message).toBe('RSA key decryption failed');
}
}, 30000);
});
describe('KeyManager password validation', () => {
test('generateKeys rejects password shorter than 8 characters', async () => {
const { KeyManager } = await import('../../src/core/crypto/keys');
const km = new KeyManager();
await expect(km.generateKeys({ keySize: 2048, password: 'short' }))
.rejects.toThrow('at least 8 characters');
}, 30000);
test('generateKeys rejects empty password', async () => {
const { KeyManager } = await import('../../src/core/crypto/keys');
const km = new KeyManager();
await expect(km.generateKeys({ keySize: 2048, password: '' }))
.rejects.toThrow('at least 8 characters');
}, 30000);
test('generateKeys accepts password of exactly 8 characters', async () => {
const { KeyManager } = await import('../../src/core/crypto/keys');
const km = new KeyManager();
await expect(km.generateKeys({ keySize: 2048, password: '12345678' }))
.resolves.toBeUndefined();
}, 30000);
test('generateKeys accepts undefined password (no encryption)', async () => {
const { KeyManager } = await import('../../src/core/crypto/keys');
const km = new KeyManager();
await expect(km.generateKeys({ keySize: 2048 }))
.resolves.toBeUndefined();
}, 30000);
test('zeroKeys clears key references', async () => {
const { KeyManager } = await import('../../src/core/crypto/keys');
const km = new KeyManager();
await km.generateKeys({ keySize: 2048 });
expect(km.hasKeys()).toBe(true);
km.zeroKeys();
expect(km.hasKeys()).toBe(false);
}, 30000);
}); });
describe('Base64 utilities', () => { describe('Base64 utilities', () => {

View file

@ -10,6 +10,7 @@ import {
ForbiddenError, ForbiddenError,
ServiceUnavailableError, ServiceUnavailableError,
RateLimitError, RateLimitError,
DisposedError,
} from '../../src/errors'; } from '../../src/errors';
import { stringToArrayBuffer } from '../../src/core/crypto/utils'; import { stringToArrayBuffer } from '../../src/core/crypto/utils';
@ -50,20 +51,74 @@ describe('SecureCompletionClient constructor', () => {
test('removes trailing slash from routerUrl', () => { test('removes trailing slash from routerUrl', () => {
const client = new SecureCompletionClient({ routerUrl: 'https://api.example.com:12434/' }); const client = new SecureCompletionClient({ routerUrl: 'https://api.example.com:12434/' });
// We can verify indirectly via fetchServerPublicKey URL construction
expect((client as unknown as { routerUrl: string }).routerUrl).toBe('https://api.example.com:12434'); expect((client as unknown as { routerUrl: string }).routerUrl).toBe('https://api.example.com:12434');
client.dispose();
});
test('throws on invalid URL', () => {
expect(() => new SecureCompletionClient({ routerUrl: 'not-a-url' }))
.toThrow('Invalid routerUrl');
});
test('http:// URL with allowHttp=true does not throw', () => {
expect(() => new SecureCompletionClient({
routerUrl: 'http://localhost:1234',
allowHttp: true,
})).not.toThrow();
});
});
describe('SecureCompletionClient.dispose()', () => {
test('calling dispose() twice does not throw', () => {
const client = new SecureCompletionClient({ routerUrl: 'https://api.example.com', keyRotationInterval: 0 });
client.dispose();
expect(() => client.dispose()).not.toThrow();
});
test('methods throw DisposedError after dispose()', async () => {
const client = new SecureCompletionClient({ routerUrl: 'https://api.example.com', keyRotationInterval: 0 });
client.dispose();
await expect(client.fetchServerPublicKey()).rejects.toBeInstanceOf(DisposedError);
await expect(client.encryptPayload({})).rejects.toBeInstanceOf(DisposedError);
await expect(client.sendSecureRequest({}, 'id')).rejects.toBeInstanceOf(DisposedError);
});
test('dispose() clears key rotation timer', () => {
jest.useFakeTimers();
const client = new SecureCompletionClient({
routerUrl: 'https://api.example.com',
keyRotationInterval: 1000,
});
const timerBefore = (client as unknown as { keyRotationTimer: unknown }).keyRotationTimer;
expect(timerBefore).toBeDefined();
client.dispose();
const timerAfter = (client as unknown as { keyRotationTimer: unknown }).keyRotationTimer;
expect(timerAfter).toBeUndefined();
jest.useRealTimers();
});
test('keyRotationInterval=0 does not start timer', () => {
const client = new SecureCompletionClient({
routerUrl: 'https://api.example.com',
keyRotationInterval: 0,
});
const timer = (client as unknown as { keyRotationTimer: unknown }).keyRotationTimer;
expect(timer).toBeUndefined();
client.dispose();
}); });
}); });
describe('SecureCompletionClient.fetchServerPublicKey', () => { describe('SecureCompletionClient.fetchServerPublicKey', () => {
test('throws SecurityError over HTTP without allowHttp', async () => { test('throws SecurityError over HTTP without allowHttp', async () => {
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
const client = new SecureCompletionClient({ const client = new SecureCompletionClient({
routerUrl: 'http://localhost:1234', routerUrl: 'http://localhost:1234',
allowHttp: false, allowHttp: false,
keyRotationInterval: 0,
}); });
// Suppress console.warn from constructor
jest.spyOn(console, 'warn').mockImplementation(() => undefined);
await expect(client.fetchServerPublicKey()).rejects.toBeInstanceOf(SecurityError); await expect(client.fetchServerPublicKey()).rejects.toBeInstanceOf(SecurityError);
client.dispose();
warnSpy.mockRestore();
}); });
}); });
@ -71,42 +126,122 @@ describe('SecureCompletionClient.sendSecureRequest — security tier validation'
test('throws for invalid security tier', async () => { test('throws for invalid security tier', async () => {
const client = new SecureCompletionClient({ const client = new SecureCompletionClient({
routerUrl: 'https://api.example.com:12434', routerUrl: 'https://api.example.com:12434',
keyRotationInterval: 0,
}); });
await expect( await expect(
client.sendSecureRequest({}, 'test-id', undefined, 'ultra') client.sendSecureRequest({}, 'test-id', undefined, 'ultra')
).rejects.toThrow("Invalid securityTier: 'ultra'"); ).rejects.toThrow("Invalid securityTier: 'ultra'");
client.dispose();
}); });
test('accepts valid security tiers', async () => { test('accepts valid security tiers', async () => {
// We just need to verify no validation error is thrown at the tier check stage
// (subsequent network call will fail, which is expected in unit tests)
const client = new SecureCompletionClient({ const client = new SecureCompletionClient({
routerUrl: 'https://api.example.com:12434', routerUrl: 'https://api.example.com:12434',
keyRotationInterval: 0,
}); });
for (const tier of ['standard', 'high', 'maximum']) { for (const tier of ['standard', 'high', 'maximum']) {
// Should not throw a tier validation error (will throw something else)
await expect( await expect(
client.sendSecureRequest({}, 'test-id', undefined, tier) client.sendSecureRequest({}, 'test-id', undefined, tier)
).rejects.not.toThrow("Invalid securityTier"); ).rejects.not.toThrow("Invalid securityTier");
} }
client.dispose();
});
});
describe('SecureCompletionClient — header injection validation', () => {
test('apiKey containing CR throws SecurityError', async () => {
const client = new SecureCompletionClient({
routerUrl: 'https://api.example.com',
keyRotationInterval: 0,
});
await (client as unknown as { generateKeys: () => Promise<void> }).generateKeys();
jest.spyOn(client as unknown as { encryptPayload: (p: object) => Promise<ArrayBuffer> }, 'encryptPayload')
.mockResolvedValue(new ArrayBuffer(8));
await expect(
client.sendSecureRequest({}, 'id', 'key\rwith\rcr')
).rejects.toBeInstanceOf(SecurityError);
client.dispose();
}, 30000);
test('apiKey containing LF throws SecurityError', async () => {
const client = new SecureCompletionClient({
routerUrl: 'https://api.example.com',
keyRotationInterval: 0,
});
await (client as unknown as { generateKeys: () => Promise<void> }).generateKeys();
jest.spyOn(client as unknown as { encryptPayload: (p: object) => Promise<ArrayBuffer> }, 'encryptPayload')
.mockResolvedValue(new ArrayBuffer(8));
await expect(
client.sendSecureRequest({}, 'id', 'key\nwith\nlf')
).rejects.toBeInstanceOf(SecurityError);
client.dispose();
}, 30000);
});
describe('SecureCompletionClient — error detail sanitization', () => {
test('long server detail is truncated to ≤100 chars in error message', async () => {
const client = new SecureCompletionClient({
routerUrl: 'https://api.example.com:12434',
keyRotationInterval: 0,
});
const http = mockHttpClient(async () => makeJsonResponse(400, { detail: 'x'.repeat(200) }));
(client as unknown as { httpClient: typeof http }).httpClient = http;
await (client as unknown as { generateKeys: () => Promise<void> }).generateKeys();
jest.spyOn(client as unknown as { encryptPayload: (p: object) => Promise<ArrayBuffer> }, 'encryptPayload')
.mockResolvedValue(new ArrayBuffer(8));
try {
await client.sendSecureRequest({}, 'id');
} catch (err) {
expect(err).toBeInstanceOf(Error);
// "Bad request: " prefix + max 100 char detail
expect((err as Error).message.length).toBeLessThanOrEqual(115);
}
client.dispose();
}, 30000);
});
describe('SecureCompletionClient — debug flag', () => {
test('console.log not called during construction when debug=false', () => {
const spy = jest.spyOn(console, 'log').mockImplementation(() => undefined);
const client = new SecureCompletionClient({
routerUrl: 'https://api.example.com',
debug: false,
keyRotationInterval: 0,
});
expect(spy).not.toHaveBeenCalled();
spy.mockRestore();
client.dispose();
});
test('console.log called during construction when debug=true', () => {
const spy = jest.spyOn(console, 'log').mockImplementation(() => undefined);
const client = new SecureCompletionClient({
routerUrl: 'https://api.example.com',
debug: true,
keyRotationInterval: 0,
});
expect(spy).toHaveBeenCalled();
spy.mockRestore();
client.dispose();
}); });
}); });
describe('SecureCompletionClient.buildErrorFromResponse (via sendSecureRequest)', () => { describe('SecureCompletionClient.buildErrorFromResponse (via sendSecureRequest)', () => {
// We can test error mapping by making the HTTP mock return specific status codes
// and verifying the correct typed error is thrown.
async function clientWithMockedHttp(statusCode: number, body: object) { async function clientWithMockedHttp(statusCode: number, body: object) {
const client = new SecureCompletionClient({ const client = new SecureCompletionClient({
routerUrl: 'https://api.example.com:12434', routerUrl: 'https://api.example.com:12434',
keyRotationInterval: 0,
}); });
// Inject mocked HTTP client
const http = mockHttpClient(async (url: string) => { const http = mockHttpClient(async (url: string) => {
if (url.includes('/pki/public_key')) { if (url.includes('/pki/public_key')) {
// Should not be reached in error tests
throw new Error('unexpected pki call'); throw new Error('unexpected pki call');
} }
return makeJsonResponse(statusCode, body); return makeJsonResponse(statusCode, body);
@ -118,104 +253,99 @@ describe('SecureCompletionClient.buildErrorFromResponse (via sendSecureRequest)'
test('401 → AuthenticationError', async () => { test('401 → AuthenticationError', async () => {
const client = await clientWithMockedHttp(401, { detail: 'bad key' }); const client = await clientWithMockedHttp(401, { detail: 'bad key' });
// Keys must be generated first, so inject a pre-generated key set
await (client as unknown as { generateKeys: () => Promise<void> }).generateKeys(); await (client as unknown as { generateKeys: () => Promise<void> }).generateKeys();
jest.spyOn(client as unknown as { encryptPayload: (p: object) => Promise<ArrayBuffer> }, 'encryptPayload')
// Mock encryptPayload to skip actual encryption
jest.spyOn(client as unknown as { encryptPayload: () => Promise<ArrayBuffer> }, 'encryptPayload')
.mockResolvedValue(new ArrayBuffer(8)); .mockResolvedValue(new ArrayBuffer(8));
await expect( await expect(
client.sendSecureRequest({ model: 'test', messages: [] }, 'id-1') client.sendSecureRequest({ model: 'test', messages: [] }, 'id-1')
).rejects.toBeInstanceOf(AuthenticationError); ).rejects.toBeInstanceOf(AuthenticationError);
client.dispose();
}, 30000); }, 30000);
test('403 → ForbiddenError', async () => { test('403 → ForbiddenError', async () => {
const client = await clientWithMockedHttp(403, { detail: 'not allowed' }); const client = await clientWithMockedHttp(403, { detail: 'not allowed' });
await (client as unknown as { generateKeys: () => Promise<void> }).generateKeys(); await (client as unknown as { generateKeys: () => Promise<void> }).generateKeys();
jest.spyOn(client as unknown as { encryptPayload: () => Promise<ArrayBuffer> }, 'encryptPayload') jest.spyOn(client as unknown as { encryptPayload: (p: object) => Promise<ArrayBuffer> }, 'encryptPayload')
.mockResolvedValue(new ArrayBuffer(8)); .mockResolvedValue(new ArrayBuffer(8));
await expect( await expect(
client.sendSecureRequest({ model: 'test', messages: [] }, 'id-1') client.sendSecureRequest({ model: 'test', messages: [] }, 'id-1')
).rejects.toBeInstanceOf(ForbiddenError); ).rejects.toBeInstanceOf(ForbiddenError);
client.dispose();
}, 30000); }, 30000);
test('429 → RateLimitError', async () => { test('429 → RateLimitError', async () => {
const client = await clientWithMockedHttp(429, { detail: 'too many' }); const client = await clientWithMockedHttp(429, { detail: 'too many' });
await (client as unknown as { generateKeys: () => Promise<void> }).generateKeys(); await (client as unknown as { generateKeys: () => Promise<void> }).generateKeys();
jest.spyOn(client as unknown as { encryptPayload: () => Promise<ArrayBuffer> }, 'encryptPayload') jest.spyOn(client as unknown as { encryptPayload: (p: object) => Promise<ArrayBuffer> }, 'encryptPayload')
.mockResolvedValue(new ArrayBuffer(8)); .mockResolvedValue(new ArrayBuffer(8));
await expect( await expect(
client.sendSecureRequest({ model: 'test', messages: [] }, 'id-1') client.sendSecureRequest({ model: 'test', messages: [] }, 'id-1')
).rejects.toBeInstanceOf(RateLimitError); ).rejects.toBeInstanceOf(RateLimitError);
client.dispose();
}, 30000); }, 30000);
test('503 → ServiceUnavailableError', async () => { test('503 → ServiceUnavailableError', async () => {
const client = await clientWithMockedHttp(503, { detail: 'down' }); const client = await clientWithMockedHttp(503, { detail: 'down' });
await (client as unknown as { generateKeys: () => Promise<void> }).generateKeys(); await (client as unknown as { generateKeys: () => Promise<void> }).generateKeys();
jest.spyOn(client as unknown as { encryptPayload: () => Promise<ArrayBuffer> }, 'encryptPayload') jest.spyOn(client as unknown as { encryptPayload: (p: object) => Promise<ArrayBuffer> }, 'encryptPayload')
.mockResolvedValue(new ArrayBuffer(8)); .mockResolvedValue(new ArrayBuffer(8));
await expect( await expect(
client.sendSecureRequest({ model: 'test', messages: [] }, 'id-1') client.sendSecureRequest({ model: 'test', messages: [] }, 'id-1')
).rejects.toBeInstanceOf(ServiceUnavailableError); ).rejects.toBeInstanceOf(ServiceUnavailableError);
client.dispose();
}, 30000); }, 30000);
test('network error → APIConnectionError (not wrapping typed errors)', async () => { test('network error → APIConnectionError', async () => {
const client = new SecureCompletionClient({ const client = new SecureCompletionClient({
routerUrl: 'https://api.example.com:12434', routerUrl: 'https://api.example.com:12434',
keyRotationInterval: 0,
}); });
const http = mockHttpClient(async () => { throw new Error('ECONNREFUSED'); }); const http = mockHttpClient(async () => { throw new Error('ECONNREFUSED'); });
(client as unknown as { httpClient: typeof http }).httpClient = http; (client as unknown as { httpClient: typeof http }).httpClient = http;
await (client as unknown as { generateKeys: () => Promise<void> }).generateKeys(); await (client as unknown as { generateKeys: () => Promise<void> }).generateKeys();
jest.spyOn(client as unknown as { encryptPayload: () => Promise<ArrayBuffer> }, 'encryptPayload') jest.spyOn(client as unknown as { encryptPayload: (p: object) => Promise<ArrayBuffer> }, 'encryptPayload')
.mockResolvedValue(new ArrayBuffer(8)); .mockResolvedValue(new ArrayBuffer(8));
await expect( await expect(
client.sendSecureRequest({ model: 'test', messages: [] }, 'id-1') client.sendSecureRequest({ model: 'test', messages: [] }, 'id-1')
).rejects.toBeInstanceOf(APIConnectionError); ).rejects.toBeInstanceOf(APIConnectionError);
client.dispose();
}, 30000); }, 30000);
}); });
describe('SecureCompletionClient encrypt/decrypt roundtrip', () => { describe('SecureCompletionClient encrypt/decrypt roundtrip', () => {
test('encryptPayload + decryptResponse roundtrip', async () => { test('encryptPayload + decryptResponse roundtrip', async () => {
// Use two clients: one for "client", one to simulate "server" const clientA = new SecureCompletionClient({ routerUrl: 'https://x', allowHttp: true, keyRotationInterval: 0 });
const clientA = new SecureCompletionClient({ routerUrl: 'https://x', allowHttp: true }); const clientB = new SecureCompletionClient({ routerUrl: 'https://x', allowHttp: true, keyRotationInterval: 0 });
const clientB = new SecureCompletionClient({ routerUrl: 'https://x', allowHttp: true });
await (clientA as unknown as { generateKeys: () => Promise<void> }).generateKeys(); await (clientA as unknown as { generateKeys: () => Promise<void> }).generateKeys();
await (clientB as unknown as { generateKeys: () => Promise<void> }).generateKeys(); await (clientB as unknown as { generateKeys: () => Promise<void> }).generateKeys();
const payload = { model: 'test', messages: [{ role: 'user', content: 'hi' }] }; const payload = { model: 'test', messages: [{ role: 'user', content: 'hi' }] };
// clientA encrypts, clientB decrypts (simulating server responding)
// We can only test the client-side encrypt → client-side decrypt roundtrip
// because the server uses its own key pair to encrypt the response.
// Directly test encryptPayload → decryptResponse using the SAME client's keys
// (as the server would decrypt with its private key and re-encrypt with client's public key)
// For a full roundtrip test we encrypt with clientB's public key and decrypt with clientB's private key.
const serverPublicKeyPem = await (clientB as unknown as { keyManager: { getPublicKeyPEM: () => Promise<string> } }).keyManager.getPublicKeyPEM(); const serverPublicKeyPem = await (clientB as unknown as { keyManager: { getPublicKeyPEM: () => Promise<string> } }).keyManager.getPublicKeyPEM();
// Mock fetchServerPublicKey on clientA to return clientB's public key
jest.spyOn(clientA as unknown as { fetchServerPublicKey: () => Promise<string> }, 'fetchServerPublicKey') jest.spyOn(clientA as unknown as { fetchServerPublicKey: () => Promise<string> }, 'fetchServerPublicKey')
.mockResolvedValue(serverPublicKeyPem); .mockResolvedValue(serverPublicKeyPem);
const encrypted = await clientA.encryptPayload(payload); const encrypted = await clientA.encryptPayload(payload);
expect(encrypted.byteLength).toBeGreaterThan(0); expect(encrypted.byteLength).toBeGreaterThan(0);
// Now simulate clientB decrypting (server decrypts the payload — we can only test
// structure here since decryptResponse expects server-format encrypted response)
const pkg = JSON.parse(new TextDecoder().decode(encrypted)); const pkg = JSON.parse(new TextDecoder().decode(encrypted));
expect(pkg.version).toBe('1.0'); expect(pkg.version).toBe('1.0');
expect(pkg.algorithm).toBe('hybrid-aes256-rsa4096'); expect(pkg.algorithm).toBe('hybrid-aes256-rsa4096');
expect(pkg.encrypted_payload.ciphertext).toBeTruthy(); expect(pkg.encrypted_payload.ciphertext).toBeTruthy();
expect(pkg.encrypted_payload.nonce).toBeTruthy(); expect(pkg.encrypted_payload.nonce).toBeTruthy();
expect(pkg.encrypted_payload.tag).toBeTruthy(); // tag must be present expect(pkg.encrypted_payload.tag).toBeTruthy();
expect(pkg.encrypted_aes_key).toBeTruthy(); expect(pkg.encrypted_aes_key).toBeTruthy();
clientA.dispose();
clientB.dispose();
}, 60000); }, 60000);
}); });