From fd1a3b50cb53bd3df74c4576908f24cfe8391f13 Mon Sep 17 00:00:00 2001 From: alpha-nerd-nomyo Date: Sat, 17 Jan 2026 12:02:08 +0100 Subject: [PATCH] feature: port from python client lib --- .gitignore | 9 + README.md | 351 +++++++++++++++++++++- docs/SECURITY.md | 404 +++++++++++++++++++++++++ examples/browser/basic.html | 196 +++++++++++++ examples/node/basic.js | 47 +++ examples/node/with-tools.js | 69 +++++ package.json | 65 +++++ rollup.config.js | 41 +++ src/api/SecureChatCompletion.ts | 79 +++++ src/browser.ts | 6 + src/core/SecureCompletionClient.ts | 454 +++++++++++++++++++++++++++++ src/core/crypto/encryption.ts | 118 ++++++++ src/core/crypto/keys.ts | 189 ++++++++++++ src/core/crypto/rsa.ts | 245 ++++++++++++++++ src/core/crypto/utils.ts | 123 ++++++++ src/core/http/browser.ts | 94 ++++++ src/core/http/client.ts | 36 +++ src/core/http/node.ts | 143 +++++++++ src/core/memory/browser.ts | 33 +++ src/core/memory/node.ts | 41 +++ src/core/memory/secure.ts | 70 +++++ src/errors/index.ts | 70 +++++ src/index.ts | 15 + src/node.ts | 6 + src/types/api.ts | 105 +++++++ src/types/client.ts | 56 ++++ src/types/crypto.ts | 42 +++ tests/build.txt | 4 + tsconfig.json | 32 ++ 29 files changed, 3141 insertions(+), 2 deletions(-) create mode 100644 .gitignore create mode 100644 docs/SECURITY.md create mode 100644 examples/browser/basic.html create mode 100644 examples/node/basic.js create mode 100644 examples/node/with-tools.js create mode 100644 package.json create mode 100644 rollup.config.js create mode 100644 src/api/SecureChatCompletion.ts create mode 100644 src/browser.ts create mode 100644 src/core/SecureCompletionClient.ts create mode 100644 src/core/crypto/encryption.ts create mode 100644 src/core/crypto/keys.ts create mode 100644 src/core/crypto/rsa.ts create mode 100644 src/core/crypto/utils.ts create mode 100644 src/core/http/browser.ts create mode 100644 src/core/http/client.ts create mode 100644 src/core/http/node.ts create mode 100644 src/core/memory/browser.ts create mode 100644 src/core/memory/node.ts create mode 100644 src/core/memory/secure.ts create mode 100644 src/errors/index.ts create mode 100644 src/index.ts create mode 100644 src/node.ts create mode 100644 src/types/api.ts create mode 100644 src/types/client.ts create mode 100644 src/types/crypto.ts create mode 100644 tests/build.txt create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8355d64 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +dist/ +*.log +*.tgz +.DS_Store +coverage/ +.nyc_output/ +build/ +*.node diff --git a/README.md b/README.md index 74f5644..7c9bdfb 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,349 @@ -# nomyo-js -OpenAI compatible secure chat client with end-to-end encryption on NOMYO Inference Endpoints +# NOMYO.js - Secure JavaScript Chat Client + +**OpenAI-compatible secure chat client with end-to-end encryption for NOMYO Inference Endpoints** + +๐Ÿ”’ **All prompts and responses are automatically encrypted and decrypted** +๐Ÿ”‘ **Uses hybrid encryption (AES-256-GCM + RSA-OAEP with 4096-bit keys)** +๐Ÿ”„ **Drop-in replacement for OpenAI's ChatCompletion API** +๐ŸŒ **Works in both Node.js and browsers** + +## ๐Ÿš€ Quick Start + +### Installation + +```bash +npm install nomyo-js +``` + +### Basic Usage (Node.js) + +```javascript +import { SecureChatCompletion } from 'nomyo-js'; + +// Initialize client (defaults to https://api.nomyo.ai:12434) +const client = new SecureChatCompletion({ + baseUrl: 'https://api.nomyo.ai:12434' +}); + +// Simple chat completion +const response = await client.create({ + model: 'Qwen/Qwen3-0.6B', + messages: [ + { role: 'user', content: 'Hello! How are you today?' } + ], + temperature: 0.7 +}); + +console.log(response.choices[0].message.content); +``` + +### Basic Usage (Browser) + +```html + + + + + + +

NOMYO Secure Chat

+ + +``` + +## ๐Ÿ” Security Features + +### Hybrid Encryption + +-**Payload encryption**: AES-256-GCM (authenticated encryption) +- **Key exchange**: RSA-OAEP with SHA-256 +- **Key size**: 4096-bit RSA keys +- **All communication**: End-to-end encrypted + +### Key Management + +- **Automatic key generation**: Keys are automatically generated on first use +- **Automatic key loading**: Existing keys are loaded automatically from `client_keys/` directory (Node.js only) +- **No manual intervention required**: The library handles key management automatically +- **Optional persistence**: Keys can be saved to `client_keys/` directory for reuse across sessions (Node.js only) +- **Password protection**: Optional password encryption for private keys (recommended for production) +- **Secure permissions**: Private keys stored with restricted permissions (600 - owner-only access) + +### Secure Memory Protection + +> [!NOTE] +> **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 +- **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 +{ + "id": "chatcmpl-123", + "object": "chat.completion", + "created": 1234567890, + "model": "Qwen/Qwen3-0.6B", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "Hello! I'm doing well, thank you for asking." + }, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 10, + "completion_tokens": 20, + "total_tokens": 30 + }, + "_metadata": { + "payload_id": "openai-compat-abc123", + "processed_at": 1765250382, + "is_encrypted": true, + "encryption_algorithm": "hybrid-aes256-rsa4096", + "response_status": "success" + } +} +``` + +## ๐Ÿ› ๏ธ Usage Examples + +### Basic Chat + +```javascript +import { SecureChatCompletion } from 'nomyo-js'; + +const client = new SecureChatCompletion({ + baseUrl: 'https://api.nomyo.ai:12434' +}); + +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); +``` + +### With Tools + +```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', + parameters: { + type: 'object', + properties: { + location: { type: 'string' } + }, + required: ['location'] + } + } + } + ] +}); +``` + +### With API Key Authentication + +```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({ + model: 'Qwen/Qwen3-0.6B', + messages: [ + { role: 'user', content: 'Hello!' } + ] +}); +``` + +### Custom Key Management (Node.js) + +```javascript +import { SecureCompletionClient } from 'nomyo-js'; + +const client = new SecureCompletionClient({ + routerUrl: 'https://api.nomyo.ai:12434' +}); + +// Generate keys with password protection +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` +- `acreate(request: ChatCompletionRequest): Promise` (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` +- `loadKeys(privateKeyPath: string, publicKeyPath?: string, password?: string): Promise` +- `fetchServerPublicKey(): Promise` +- `encryptPayload(payload: object): Promise` +- `decryptResponse(encrypted: ArrayBuffer, payloadId: string): Promise` +- `sendSecureRequest(payload: object, payloadId: string, apiKey?: string): Promise` + +## ๐Ÿ”ง Configuration + +### Local Development (HTTP) + +```javascript +const client = new SecureChatCompletion({ + baseUrl: 'http://localhost:12434', + allowHttp: true // Required for HTTP connections +}); +``` + +โš ๏ธ **Warning**: Only use HTTP for local development. Never use in production! + +### Disable Secure Memory + +```javascript +const client = new SecureChatCompletion({ + baseUrl: 'https://api.nomyo.ai:12434', + secureMemory: false // Disable memory protection (not recommended) +}); +``` + +## ๐Ÿ“ Security Best Practices + +- โœ… Always use HTTPS in production +- โœ… 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 + +Contributions are welcome! Please open issues or pull requests on the project repository. + +## ๐Ÿ“„ License + +See LICENSE file for licensing information. + +## ๐Ÿ“ž Support + +For questions or issues, please refer to the project documentation or open an issue. diff --git a/docs/SECURITY.md b/docs/SECURITY.md new file mode 100644 index 0000000..eef7e37 --- /dev/null +++ b/docs/SECURITY.md @@ -0,0 +1,404 @@ +# Security Documentation - NOMYO.js + +## Overview + +NOMYO.js implements end-to-end encryption for OpenAI-compatible chat completions using hybrid cryptography. This document details the security architecture, current implementation status, and limitations. + +--- + +## Encryption Architecture + +### Hybrid Encryption (AES-256-GCM + RSA-OAEP) + +**Request Encryption Flow:** +1. Client generates ephemeral AES-256 key (32 bytes) +2. Payload serialized to JSON and encrypted with AES-256-GCM +3. AES key encrypted with server's RSA-4096 public key (RSA-OAEP-SHA256) +4. Encrypted package sent to server with client's public key + +**Response Decryption Flow:** +1. Server encrypts response with new AES-256 key +2. AES key encrypted with client's RSA public key +3. Client decrypts AES key with private RSA key +4. Client decrypts response with AES key + +### Cryptographic Primitives + +| Component | Algorithm | Parameters | +|-----------|-----------|------------| +| **Symmetric Encryption** | AES-256-GCM | 256-bit key, 96-bit nonce, 128-bit tag | +| **Asymmetric Encryption** | RSA-OAEP | 4096-bit modulus, SHA-256 hash, MGF1-SHA256 | +| **Key Derivation** | PBKDF2 | 100,000 iterations, SHA-256, 16-byte salt | +| **Private Key Encryption** | AES-256-CBC | 256-bit key (from PBKDF2), 128-bit IV | + +--- + +## Current Implementation Status + +### โœ… Fully Implemented + +1. **Web Crypto API Integration** + - Platform-agnostic cryptography (Node.js 15+ and modern browsers) + - Hardware-accelerated when available + - Constant-time operations (timing attack resistant) + +2. **Hybrid Encryption** + - AES-256-GCM for payload encryption + - RSA-OAEP-SHA256 for key exchange + - Authenticated encryption (GCM provides AEAD) + - Unique nonce per encryption (96-bit random) + +3. **Key Management** + - 4096-bit RSA keys (default, configurable to 2048) + - Automatic key generation on first use + - File-based persistence (Node.js) + - In-memory keys (browsers) + - Password protection via PBKDF2 + AES-256-CBC + +4. **Transport Security** + - HTTPS enforcement (with warnings for HTTP) + - Certificate validation (browsers/Node.js) + - Optional HTTP for local development (explicit opt-in) + +5. **Memory Protection (Pure JavaScript)** + - Immediate zeroing of sensitive buffers + - Context managers for automatic cleanup + - Best-effort memory management + +### โš ๏ธ Limitations (Pure JavaScript) + +1. **No OS-Level Memory Locking** + - Cannot use `mlock()` (Linux/macOS) or `VirtualLock()` (Windows) + - JavaScript GC controls memory lifecycle + - Memory may be paged to swap + - **Impact**: Sensitive data could be written to disk during high memory pressure + +2. **Memory Zeroing Only** + - Zeroes `ArrayBuffer` contents immediately after use + - Cannot prevent GC from copying data internally + - Cannot guarantee memory won't be swapped + - **Mitigation**: Minimizes exposure window + +3. **Browser Limitations** + - Keys not persisted (in-memory only) + - No file system access + - Subject to browser security policies + - **Impact**: Keys regenerated on each page load + +--- + +## Security Best Practices + +### Deployment + +โœ… **DO:** +- Use HTTPS in production (enforced by default) +- Enable secure memory protection (default: `secureMemory: true`) +- Use password-protected private keys in Node.js +- Set private key file permissions to 600 (owner-only) +- Rotate keys periodically +- Validate server public key fingerprint on first use + +โŒ **DON'T:** +- Use HTTP in production (only for localhost development) +- Disable secure memory unless absolutely necessary +- Store unencrypted private keys +- Share private keys across systems +- Store keys in public repositories + +### Key Management + +**Node.js (Recommended):** +```javascript +const client = new SecureCompletionClient({ routerUrl: 'https://...' }); + +// Generate with password protection +await client.generateKeys({ + saveToFile: true, + keyDir: 'client_keys', + password: process.env.KEY_PASSWORD // From environment variable +}); +``` + +**Browsers (In-Memory):** +```javascript +// Keys generated automatically, not persisted +const client = new SecureChatCompletion({ baseUrl: 'https://...' }); +``` + +### Environment Variables + +```bash +# .env file (never commit to git) +NOMYO_API_KEY=your-api-key +NOMYO_KEY_PASSWORD=your-key-password +NOMYO_SERVER_URL=https://api.nomyo.ai:12434 +``` + +--- + +## Cryptographic Implementation Details + +### AES-256-GCM + +```typescript +// Key generation +const key = await crypto.subtle.generateKey( + { name: 'AES-GCM', length: 256 }, + true, // extractable + ['encrypt', 'decrypt'] +); + +// Encryption +const nonce = crypto.getRandomValues(new Uint8Array(12)); // 96 bits +const ciphertext = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv: nonce, tagLength: 128 }, + key, + plaintext +); +``` + +**Security Properties:** +- **Authenticated Encryption with Associated Data (AEAD)** +- **128-bit authentication tag** prevents tampering +- **Unique nonce requirement** - Never reuse nonce with same key +- **Ephemeral keys** - New AES key per request provides forward secrecy + +### RSA-OAEP + +```typescript +// Key generation +const keyPair = await crypto.subtle.generateKey( + { + name: 'RSA-OAEP', + modulusLength: 4096, + publicExponent: new Uint8Array([1, 0, 1]), // 65537 + hash: 'SHA-256' + }, + true, + ['encrypt', 'decrypt'] +); + +// Encryption +const encrypted = await crypto.subtle.encrypt( + { name: 'RSA-OAEP' }, + publicKey, + data +); +``` + +**Security Properties:** +- **OAEP padding** prevents chosen ciphertext attacks +- **SHA-256 hash** in MGF1 mask generation +- **4096-bit keys** provide ~152-bit security level +- **No label** (standard practice for hybrid encryption) + +### Password-Protected Keys + +```typescript +// Derive key from password +const salt = crypto.getRandomValues(new Uint8Array(16)); +const passwordKey = await crypto.subtle.importKey( + 'raw', + encoder.encode(password), + 'PBKDF2', + false, + ['deriveKey'] +); + +const derivedKey = await crypto.subtle.deriveKey( + { + name: 'PBKDF2', + salt: salt, + iterations: 100000, // OWASP recommendation + hash: 'SHA-256' + }, + passwordKey, + { name: 'AES-CBC', length: 256 }, + false, + ['encrypt'] +); + +// Encrypt private key +const iv = crypto.getRandomValues(new Uint8Array(16)); +const encrypted = await crypto.subtle.encrypt( + { name: 'AES-CBC', iv: iv }, + derivedKey, + privateKeyData +); + +// Store: salt + iv + encrypted +``` + +**Security Properties:** +- **100,000 PBKDF2 iterations** (meets OWASP 2023 recommendations) +- **SHA-256 hash function** +- **16-byte random salt** (unique per key) +- **AES-256-CBC** for key encryption + +--- + +## Memory Protection Details + +### Current Implementation (Pure JavaScript) + +```typescript +class SecureByteContext { + async use(fn: (data: ArrayBuffer) => Promise): Promise { + try { + return await fn(this.data); + } finally { + // Always zero, even if exception occurs + if (this.useSecure) { + new Uint8Array(this.data).fill(0); + } + } + } +} +``` + +**What it does:** +- โœ… Zeroes memory immediately after use +- โœ… Guarantees cleanup even on exceptions +- โœ… Minimizes exposure window + +**What it cannot do:** +- โŒ Prevent JavaScript GC from copying data +- โŒ Lock memory pages (no swap) +- โŒ Prevent core dumps from containing data +- โŒ Guarantee OS won't page data to disk + +### Future: Native Addon (Optional) + +A native Node.js addon can provide true memory protection: + +**Linux/macOS:** +```c +#include + +// Lock memory +mlock(data, length); + +// Zero and unlock +memset(data, 0, length); +munlock(data, length); +``` + +**Windows:** +```c +#include + +// Lock memory +VirtualLock(data, length); + +// Zero and unlock +SecureZeroMemory(data, length); +VirtualUnlock(data, length); +``` + +**Installation:** +```bash +# Optional dependency +npm install nomyo-native + +# Will use native addon if available, fallback to pure JS otherwise +``` + +--- + +## Threat Model + +### Protected Against + +โœ… **Network Eavesdropping** +- All data encrypted end-to-end +- HTTPS transport encryption +- Authenticated encryption prevents tampering + +โœ… **MITM Attacks** +- HTTPS certificate validation +- Server public key verification +- Warning on HTTP usage + +โœ… **Replay Attacks** +- Unique nonce per encryption +- Authenticated encryption with GCM +- Server timestamp validation (server-side) + +โœ… **Timing Attacks (Partial)** +- Web Crypto API uses constant-time operations +- No length leakage in comparisons + +โœ… **Key Compromise (Forward Secrecy)** +- Ephemeral AES keys +- Each request uses new AES key +- Compromise of one key affects only that request + +### Not Protected Against (Pure JS) + +โš ๏ธ **Memory Inspection** +- Admin/root can read process memory +- Debuggers can access sensitive data +- Core dumps may contain keys +- **Mitigation**: Use native addon for `mlock` support + +โš ๏ธ **Swap File Exposure** +- OS may page memory to disk +- Sensitive data could persist in swap +- **Mitigation**: Disable swap or use native addon + +โš ๏ธ **Local Malware** +- Keyloggers can capture passwords +- Memory scrapers can extract keys +- **Mitigation**: Standard OS security practices + +--- + +## Comparison: JavaScript vs Python Implementation + +| Feature | Python | JavaScript (Pure) | JavaScript (+ Native Addon) | +|---------|--------|-------------------|----------------------------| +| **Encryption** | AES-256-GCM | โœ… AES-256-GCM | โœ… AES-256-GCM | +| **Key Exchange** | RSA-OAEP-4096 | โœ… RSA-OAEP-4096 | โœ… RSA-OAEP-4096 | +| **Memory Locking** | โœ… `mlock` | โŒ Not available | โœ… `mlock` | +| **Memory Zeroing** | โœ… Guaranteed | โœ… Best-effort | โœ… Guaranteed | +| **Key Persistence** | โœ… File-based | โœ… Node.js only | โœ… Node.js only | +| **Browser Support** | โŒ | โœ… | โŒ | +| **Zero Dependencies** | โŒ | โœ… | โŒ (native addon) | + +--- + +## Audit & Compliance + +### Recommendations for Production + +1. **Code Review** + - Review cryptographic implementations + - Verify key generation randomness + - Check for timing vulnerabilities + +2. **Penetration Testing** + - Test against MITM attacks + - Verify HTTPS enforcement + - Test key management security + +3. **Compliance** + - Document security architecture + - Risk assessment for pure JS vs native + - Decide if `mlock` is required for your use case + +### Known Limitations + +This implementation uses **pure JavaScript** without native addons. For maximum security: +- Consider implementing the optional native addon +- Or use the Python client in security-critical server environments +- Or accept the risk given other security controls (encrypted disk, no swap, etc.) + +--- + +## References + +- [Web Crypto API Specification](https://www.w3.org/TR/WebCryptoAPI/) +- [NIST SP 800-38D](https://csrc.nist.gov/publications/detail/sp/800-38d/final) - GCM mode +- [RFC 8017](https://datatracker.ietf.org/doc/html/rfc8017) - RSA-OAEP +- [OWASP Password Storage Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html) diff --git a/examples/browser/basic.html b/examples/browser/basic.html new file mode 100644 index 0000000..ec7f8cf --- /dev/null +++ b/examples/browser/basic.html @@ -0,0 +1,196 @@ + + + + + + NOMYO Secure Chat - Browser Example + + + +
+

๐Ÿ”’ NOMYO Secure Chat

+

End-to-end encrypted chat using nomyo-js in the browser

+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
โณ Encrypting and sending...
+ +
+
+ + + + diff --git a/examples/node/basic.js b/examples/node/basic.js new file mode 100644 index 0000000..c29f530 --- /dev/null +++ b/examples/node/basic.js @@ -0,0 +1,47 @@ +/** + * Basic usage example for Node.js + */ + +import { SecureChatCompletion } from 'nomyo-js'; + +async function main() { + // Initialize client + const client = new SecureChatCompletion({ + baseUrl: 'https://api.nomyo.ai:12434', + // For local development, use: + // baseUrl: 'http://localhost:12434', + // allowHttp: true + }); + + try { + // Simple chat completion + console.log('Sending chat completion request...'); + + const response = await client.create({ + model: 'Qwen/Qwen3-0.6B', + messages: [ + { role: 'system', content: 'You are a helpful assistant.' }, + { role: 'user', content: 'Hello! How are you today?' } + ], + temperature: 0.7 + }); + + console.log('\n๐Ÿ“ Response:'); + console.log(response.choices[0].message.content); + + console.log('\n๐Ÿ“Š Usage:'); + console.log(`- Prompt tokens: ${response.usage?.prompt_tokens}`); + console.log(`- Completion tokens: ${response.usage?.completion_tokens}`); + console.log(`- Total tokens: ${response.usage?.total_tokens}`); + + console.log('\n๐Ÿ” Security info:'); + console.log(`- Encrypted: ${response._metadata?.is_encrypted}`); + console.log(`- Algorithm: ${response._metadata?.encryption_algorithm}`); + + } catch (error) { + console.error('โŒ Error:', error.message); + throw error; + } +} + +main(); diff --git a/examples/node/with-tools.js b/examples/node/with-tools.js new file mode 100644 index 0000000..937eac9 --- /dev/null +++ b/examples/node/with-tools.js @@ -0,0 +1,69 @@ +/** + * Example with tool calling for Node.js + */ + +import { SecureChatCompletion } from 'nomyo-js'; + +async function main() { + const client = new SecureChatCompletion({ + baseUrl: 'https://api.nomyo.ai:12434' + }); + + try { + console.log('Sending chat completion request with tools...'); + + const response = await client.create({ + model: 'Qwen/Qwen3-0.6B', + messages: [ + { role: 'user', content: "What's the weather like in Paris?" } + ], + tools: [ + { + type: 'function', + function: { + name: 'get_weather', + description: 'Get the current weather for a location', + parameters: { + type: 'object', + properties: { + location: { + type: 'string', + description: 'The city and country, e.g. Paris, France' + }, + unit: { + type: 'string', + enum: ['celsius', 'fahrenheit'], + description: 'Temperature unit' + } + }, + required: ['location'] + } + } + } + ], + temperature: 0.7 + }); + + console.log('\n๐Ÿ“ Response:'); + const message = response.choices[0].message; + + if (message.tool_calls) { + console.log('๐Ÿ”ง Tool calls requested:'); + message.tool_calls.forEach((toolCall, index) => { + console.log(`\n ${index + 1}. ${toolCall.function.name}`); + console.log(` Arguments: ${toolCall.function.arguments}`); + }); + } else { + console.log(message.content); + } + + console.log('\n๐Ÿ“Š Usage:'); + console.log(`- Total tokens: ${response.usage?.total_tokens}`); + + } catch (error) { + console.error('โŒ Error:', error.message); + throw error; + } +} + +main(); diff --git a/package.json b/package.json new file mode 100644 index 0000000..a4f2077 --- /dev/null +++ b/package.json @@ -0,0 +1,65 @@ +{ + "name": "nomyo-js", + "version": "0.1.0", + "description": "OpenAI-compatible secure chat client with end-to-end encryption", + "main": "dist/node/index.js", + "browser": "dist/browser/index.js", + "module": "dist/esm/index.js", + "types": "dist/types/index.d.ts", + "exports": { + ".": { + "node": { + "require": "./dist/node/index.js", + "import": "./dist/esm/index.js" + }, + "browser": { + "import": "./dist/browser/index.js", + "require": "./dist/browser/index.js" + }, + "types": "./dist/types/index.d.ts" + } + }, + "files": [ + "dist", + "native", + "README.md", + "LICENSE" + ], + "scripts": { + "build": "npm run build:node && npm run build:browser && npm run build:types", + "build:node": "rollup -c --environment TARGET:node", + "build:browser": "rollup -c --environment TARGET:browser", + "build:types": "tsc --emitDeclarationOnly", + "test": "jest", + "test:browser": "karma start", + "install": "node-gyp-build", + "prepublishOnly": "npm run build && npm test" + }, + "keywords": [ + "openai", + "encryption", + "secure", + "chat", + "e2e", + "privacy", + "nomyo" + ], + "author": "", + "license": "Apache-2.0", + "dependencies": {}, + "devDependencies": { + "@rollup/plugin-typescript": "^11.0.0", + "@rollup/plugin-node-resolve": "^15.0.0", + "@rollup/plugin-commonjs": "^25.0.0", + "@types/node": "^20.0.0", + "rollup": "^4.0.0", + "typescript": "^5.3.0", + "jest": "^29.0.0", + "karma": "^6.4.0", + "node-gyp": "^10.0.0", + "node-gyp-build": "^4.8.0" + }, + "optionalDependencies": { + "nomyo-native": "file:./native" + } +} \ No newline at end of file diff --git a/rollup.config.js b/rollup.config.js new file mode 100644 index 0000000..7b9474b --- /dev/null +++ b/rollup.config.js @@ -0,0 +1,41 @@ +import typescript from '@rollup/plugin-typescript'; +import resolve from '@rollup/plugin-node-resolve'; +import commonjs from '@rollup/plugin-commonjs'; + +const target = process.env.TARGET || 'node'; + +const config = { + input: target === 'browser' ? 'src/browser.ts' : 'src/node.ts', + plugins: [ + resolve(), + commonjs(), + typescript({ + tsconfig: './tsconfig.json', + declaration: false, + declarationDir: undefined + }) + ] +}; + +if (target === 'node') { + config.output = [ + { + file: 'dist/node/index.js', + format: 'cjs', + exports: 'named' + }, + { + file: 'dist/esm/index.js', + format: 'es' + } + ]; + config.external = ['crypto', 'https', 'fs', 'path']; +} else if (target === 'browser') { + config.output = { + file: 'dist/browser/index.js', + format: 'es', + name: 'Nomyo' + }; +} + +export default config; diff --git a/src/api/SecureChatCompletion.ts b/src/api/SecureChatCompletion.ts new file mode 100644 index 0000000..777e8a2 --- /dev/null +++ b/src/api/SecureChatCompletion.ts @@ -0,0 +1,79 @@ +/** + * OpenAI-compatible secure chat completion API + * Provides a drop-in replacement for OpenAI's ChatCompletion API with end-to-end encryption + */ + +import { SecureCompletionClient } from '../core/SecureCompletionClient'; +import { ChatCompletionConfig } from '../types/client'; +import { ChatCompletionRequest, ChatCompletionResponse } from '../types/api'; + +export class SecureChatCompletion { + private client: SecureCompletionClient; + private apiKey?: string; + + constructor(config: ChatCompletionConfig = {}) { + const { + baseUrl = 'https://api.nomyo.ai:12434', + allowHttp = false, + apiKey, + secureMemory = true, + } = config; + + this.apiKey = apiKey; + this.client = new SecureCompletionClient({ + routerUrl: baseUrl, + allowHttp, + secureMemory, + }); + } + + /** + * Create a chat completion (matches OpenAI API) + * @param request Chat completion request + * @returns Chat completion response + */ + async create(request: ChatCompletionRequest): Promise { + // Generate unique payload ID + const payloadId = `openai-compat-${this.generatePayloadId()}`; + + // Extract API key from request or use instance key + const apiKey = (request as any).api_key || this.apiKey; + + // Remove api_key from payload if present (it's in headers) + const payload = { ...request }; + delete (payload as any).api_key; + + // Validate required fields + if (!payload.model) { + throw new Error('Missing required field: model'); + } + if (!payload.messages || !Array.isArray(payload.messages)) { + throw new Error('Missing or invalid required field: messages'); + } + + // Send secure request + const response = await this.client.sendSecureRequest( + payload, + payloadId, + apiKey + ); + + return response as ChatCompletionResponse; + } + + /** + * Async alias for create() (for compatibility with OpenAI SDK) + */ + async acreate(request: ChatCompletionRequest): Promise { + return this.create(request); + } + + /** + * Generate a unique payload ID + */ + private generatePayloadId(): string { + const timestamp = Date.now(); + const random = Math.random().toString(36).substring(2, 15); + return `${timestamp}-${random}`; + } +} diff --git a/src/browser.ts b/src/browser.ts new file mode 100644 index 0000000..a79884c --- /dev/null +++ b/src/browser.ts @@ -0,0 +1,6 @@ +/** + * Browser-specific entry point + * Ensures browser-specific implementations are used + */ + +export * from './index'; diff --git a/src/core/SecureCompletionClient.ts b/src/core/SecureCompletionClient.ts new file mode 100644 index 0000000..6cf742d --- /dev/null +++ b/src/core/SecureCompletionClient.ts @@ -0,0 +1,454 @@ +/** + * Secure Completion Client + * Main client class for encrypted communication with NOMYO router + * + * Port of Python's SecureCompletionClient with full API compatibility + */ + +import { ClientConfig } from '../types/client'; +import { EncryptedPackage } from '../types/crypto'; +import { KeyManager } from './crypto/keys'; +import { AESEncryption } from './crypto/encryption'; +import { RSAOperations } from './crypto/rsa'; +import { createHttpClient, HttpClient } from './http/client'; +import { createSecureMemory, SecureByteContext } from './memory/secure'; +import { SecurityError, APIConnectionError } from '../errors'; +import { + arrayBufferToBase64, + base64ToArrayBuffer, + stringToArrayBuffer, + arrayBufferToString, +} from './crypto/utils'; + +export class SecureCompletionClient { + private routerUrl: string; + private allowHttp: boolean; + private secureMemory: boolean; + private keyManager: KeyManager; + private aes: AESEncryption; + private rsa: RSAOperations; + private httpClient: HttpClient; + private secureMemoryImpl = createSecureMemory(); + + constructor(config: ClientConfig = { routerUrl: 'https://api.nomyo.ai:12434' }) { + const { + routerUrl = 'https://api.nomyo.ai:12434', + allowHttp = false, + secureMemory = true, + keySize = 4096, + } = config; + + this.routerUrl = routerUrl.replace(/\/$/, ''); // Remove trailing slash + this.allowHttp = allowHttp; + this.secureMemory = secureMemory; + + // Validate HTTPS for security + if (!this.routerUrl.startsWith('https://')) { + if (!allowHttp) { + console.warn( + 'โš ๏ธ WARNING: Using HTTP instead of HTTPS. ' + + 'This is INSECURE and should only be used for local development. ' + + 'Man-in-the-middle attacks are possible!' + ); + } else { + console.log('HTTP mode enabled for local development (INSECURE)'); + } + } + + // Initialize components + this.keyManager = new KeyManager(); + this.aes = new AESEncryption(); + this.rsa = new RSAOperations(); + this.httpClient = createHttpClient(); + + // Log memory protection info + const protectionInfo = this.secureMemoryImpl.getProtectionInfo(); + console.log(`Memory protection: ${protectionInfo.method} (${protectionInfo.details})`); + } + + /** + * Generate RSA key pair + */ + async generateKeys(options: { + saveToFile?: boolean; + keyDir?: string; + password?: string; + } = {}): Promise { + await this.keyManager.generateKeys({ + keySize: 4096, + ...options, + }); + } + + /** + * Load existing keys from files (Node.js only) + */ + async loadKeys( + privateKeyPath: string, + publicKeyPath?: string, + password?: string + ): Promise { + await this.keyManager.loadKeys( + { privateKeyPath, publicKeyPath }, + password + ); + } + + /** + * Ensure keys are loaded, generate if necessary + */ + private async ensureKeys(): Promise { + if (this.keyManager.hasKeys()) { + return; + } + + // Try to load keys from default location (Node.js only) + if (typeof window === 'undefined') { + try { + const fs = require('fs').promises; + const path = require('path'); + + const privateKeyPath = path.join('client_keys', 'private_key.pem'); + const publicKeyPath = path.join('client_keys', 'public_key.pem'); + + // Check if keys exist + await fs.access(privateKeyPath); + await fs.access(publicKeyPath); + + // Load keys + await this.loadKeys(privateKeyPath, publicKeyPath); + console.log('Loaded existing keys from client_keys/'); + return; + } catch (error) { + // Keys don't exist, generate new ones + console.log('No existing keys found, generating new keys...'); + } + } + + // Generate new keys + await this.generateKeys({ + saveToFile: typeof window === 'undefined', // Only save in Node.js + keyDir: 'client_keys', + }); + } + + /** + * Fetch server's public key from /pki/public_key endpoint + */ + async fetchServerPublicKey(): Promise { + console.log("Fetching server's public key..."); + + // Security check: Ensure HTTPS is used unless HTTP explicitly allowed + if (!this.routerUrl.startsWith('https://')) { + if (!this.allowHttp) { + throw new SecurityError( + 'Server public key must be fetched over HTTPS to prevent MITM attacks. ' + + 'For local development, initialize with allowHttp=true: ' + + 'new SecureChatCompletion({ baseUrl: "http://localhost:12434", allowHttp: true })' + ); + } else { + console.warn('Fetching key over HTTP (local development mode)'); + } + } + + const url = `${this.routerUrl}/pki/public_key`; + + try { + const response = await this.httpClient.get(url, { timeout: 60000 }); + + if (response.statusCode === 200) { + const serverPublicKey = arrayBufferToString(response.body); + + // Validate it's a valid PEM key + try { + await this.rsa.importPublicKey(serverPublicKey); + } catch (error) { + throw new Error('Server returned invalid public key format'); + } + + if (this.routerUrl.startsWith('https://')) { + console.log("Server's public key fetched securely over HTTPS"); + } else { + console.warn("Server's public key fetched over HTTP (INSECURE)"); + } + + return serverPublicKey; + } else { + throw new Error(`Failed to fetch server's public key: HTTP ${response.statusCode}`); + } + } catch (error) { + if (error instanceof SecurityError) { + throw error; + } + if (error instanceof Error) { + throw new APIConnectionError(`Failed to fetch server's public key: ${error.message}`); + } + throw error; + } + } + + /** + * Encrypt a payload using hybrid encryption (AES-256-GCM + RSA-OAEP) + */ + async encryptPayload(payload: object): Promise { + console.log('Encrypting payload...'); + + // Validate payload + if (!payload || typeof payload !== 'object') { + throw new Error('Payload must be an object'); + } + + // Ensure keys are loaded + await this.ensureKeys(); + + // Serialize payload to JSON + const payloadJson = JSON.stringify(payload); + const payloadBytes = stringToArrayBuffer(payloadJson); + + // Validate payload size (prevent DoS) + const MAX_PAYLOAD_SIZE = 10 * 1024 * 1024; // 10MB limit + if (payloadBytes.byteLength > MAX_PAYLOAD_SIZE) { + throw new Error(`Payload too large: ${payloadBytes.byteLength} bytes (max: ${MAX_PAYLOAD_SIZE})`); + } + + console.log(`Payload size: ${payloadBytes.byteLength} bytes`); + + // Use secure memory context if enabled + if (this.secureMemory) { + const context = new SecureByteContext(payloadBytes, true); + return await context.use(async (protectedPayload) => { + return await this.performEncryption(protectedPayload); + }); + } else { + return await this.performEncryption(payloadBytes); + } + } + + /** + * Perform the actual encryption (separated for secure memory context) + */ + private async performEncryption(payloadBytes: ArrayBuffer): Promise { + // Generate AES key + const aesKey = await this.aes.generateKey(); + const aesKeyBytes = await this.aes.exportKey(aesKey); + + // Protect AES key in memory + const aesContext = new SecureByteContext(aesKeyBytes, this.secureMemory); + return await aesContext.use(async (protectedAesKey) => { + // Encrypt payload with AES-GCM + const { ciphertext, nonce } = await this.aes.encrypt(payloadBytes, aesKey); + + // Fetch server's public key + const serverPublicKeyPem = await this.fetchServerPublicKey(); + const serverPublicKey = await this.rsa.importPublicKey(serverPublicKeyPem); + + // Encrypt AES key with server's RSA public key + const encryptedAesKey = await this.rsa.encryptKey(protectedAesKey, serverPublicKey); + + // Create encrypted package (matching Python format) + const encryptedPackage = { + version: '1.0', + algorithm: 'hybrid-aes256-rsa4096', + encrypted_payload: { + ciphertext: arrayBufferToBase64(ciphertext), + nonce: arrayBufferToBase64(nonce), + // Note: GCM tag is included in ciphertext in Web Crypto API + }, + encrypted_aes_key: arrayBufferToBase64(encryptedAesKey), + key_algorithm: 'RSA-OAEP-SHA256', + payload_algorithm: 'AES-256-GCM', + }; + + // Serialize package to JSON + const packageJson = JSON.stringify(encryptedPackage); + const packageBytes = stringToArrayBuffer(packageJson); + + console.log(`Encrypted package size: ${packageBytes.byteLength} bytes`); + + return packageBytes; + }); + } + + /** + * Decrypt a response from the secure endpoint + */ + async decryptResponse(encryptedResponse: ArrayBuffer, payloadId: string): Promise { + console.log('Decrypting response...'); + + // Validate input + if (!encryptedResponse || encryptedResponse.byteLength === 0) { + throw new Error('Empty encrypted response'); + } + + // Parse encrypted package + let packageData: any; + try { + const packageJson = arrayBufferToString(encryptedResponse); + packageData = JSON.parse(packageJson); + } catch (error) { + throw new Error('Invalid encrypted package format: malformed JSON'); + } + + // Validate package structure + const requiredFields = ['version', 'algorithm', 'encrypted_payload', 'encrypted_aes_key']; + for (const field of requiredFields) { + if (!(field in packageData)) { + throw new Error(`Missing required field in encrypted package: ${field}`); + } + } + + // Validate encrypted_payload structure + const payloadRequired = ['ciphertext', 'nonce']; + for (const field of payloadRequired) { + if (!(field in packageData.encrypted_payload)) { + throw new Error(`Missing field in encrypted_payload: ${field}`); + } + } + + // Decrypt AES key with private key + try { + const encryptedAesKey = base64ToArrayBuffer(packageData.encrypted_aes_key); + const privateKey = this.keyManager.getPrivateKey(); + const aesKeyBytes = await this.rsa.decryptKey(encryptedAesKey, privateKey); + + // Use secure memory context for AES key + const aesContext = new SecureByteContext(aesKeyBytes, this.secureMemory); + const response = await aesContext.use(async (protectedAesKey) => { + // Import AES key + const aesKey = await this.aes.importKey(protectedAesKey); + + // Decrypt payload with AES-GCM + const ciphertext = base64ToArrayBuffer(packageData.encrypted_payload.ciphertext); + const nonce = base64ToArrayBuffer(packageData.encrypted_payload.nonce); + + const plaintext = await this.aes.decrypt(ciphertext, nonce, aesKey); + + // Use secure memory for plaintext + const plaintextContext = new SecureByteContext(plaintext, this.secureMemory); + return await plaintextContext.use(async (protectedPlaintext) => { + // Parse decrypted response + const responseJson = arrayBufferToString(protectedPlaintext); + return JSON.parse(responseJson); + }); + }); + + // Add metadata + if (!response._metadata) { + response._metadata = {}; + } + response._metadata = { + ...response._metadata, + payload_id: payloadId, + processed_at: packageData.processed_at, + is_encrypted: true, + encryption_algorithm: packageData.algorithm, + }; + + console.log('Response decrypted successfully'); + return response; + } catch (error) { + // Don't leak specific decryption errors (timing attacks) + throw new SecurityError('Decryption failed: integrity check or authentication failed'); + } + } + + /** + * Send a secure chat completion request to the router + */ + async sendSecureRequest( + payload: object, + payloadId: string, + apiKey?: string + ): Promise { + console.log('Sending secure chat completion request...'); + + // Ensure keys are loaded + await this.ensureKeys(); + + // Step 1: Encrypt the payload + const encryptedPayload = await this.encryptPayload(payload); + + // Step 2: Prepare headers + const publicKeyPem = await this.keyManager.getPublicKeyPEM(); + const headers: Record = { + 'X-Payload-ID': payloadId, + 'X-Public-Key': encodeURIComponent(publicKeyPem), + 'Content-Type': 'application/octet-stream', + }; + + // Add Authorization header if api_key is provided + if (apiKey) { + headers['Authorization'] = `Bearer ${apiKey}`; + } + + // Step 3: Send request to router + const url = `${this.routerUrl}/v1/chat/secure_completion`; + console.log(`Target URL: ${url}`); + + try { + const response = await this.httpClient.post(url, { + headers, + body: encryptedPayload, + timeout: 60000, + }); + + console.log(`HTTP Status: ${response.statusCode}`); + + if (response.statusCode === 200) { + // Step 4: Decrypt the response + const decryptedResponse = await this.decryptResponse(response.body, payloadId); + return decryptedResponse; + } else { + // Handle error responses + const { handleErrorResponse } = await import('../errors'); + throw this.handleErrorResponse(response); + } + } catch (error) { + if (error instanceof Error) { + if (error.message === 'Request timeout') { + throw new APIConnectionError('Connection to server timed out'); + } + throw new APIConnectionError(`Failed to connect to router: ${error.message}`); + } + throw error; + } + } + + /** + * Handle error HTTP responses + */ + private handleErrorResponse(response: { statusCode: number; body: ArrayBuffer }): Error { + const { + AuthenticationError, + InvalidRequestError, + RateLimitError, + ServerError, + APIError, + } = require('../errors'); + + let errorData: any = {}; + try { + const errorJson = arrayBufferToString(response.body); + errorData = JSON.parse(errorJson); + } catch (e) { + // Ignore JSON parse errors + } + + const detail = errorData.detail || 'Unknown error'; + + switch (response.statusCode) { + case 400: + return new InvalidRequestError(`Bad request: ${detail}`, 400, errorData); + case 401: + return new AuthenticationError(`Invalid API key or authentication failed: ${detail}`, 401, errorData); + case 404: + return new APIError(`Endpoint not found: ${detail}`, 404, errorData); + case 429: + return new RateLimitError(`Rate limit exceeded: ${detail}`, 429, errorData); + case 500: + return new ServerError(`Server error: ${detail}`, 500, errorData); + default: + return new APIError(`Unexpected status code: ${response.statusCode}`, response.statusCode, errorData); + } + } +} diff --git a/src/core/crypto/encryption.ts b/src/core/crypto/encryption.ts new file mode 100644 index 0000000..780696b --- /dev/null +++ b/src/core/crypto/encryption.ts @@ -0,0 +1,118 @@ +/** + * AES-256-GCM encryption and decryption using Web Crypto API + * Matches the Python implementation using AES-256-GCM with random nonces + */ + +import { getCrypto, arrayBufferToBase64, base64ToArrayBuffer, generateRandomBytes } from './utils'; + +export class AESEncryption { + private subtle: SubtleCrypto; + + constructor() { + this.subtle = getCrypto(); + } + + /** + * Generate a random 256-bit AES key + */ + async generateKey(): Promise { + return await this.subtle.generateKey( + { + name: 'AES-GCM', + length: 256, // 256-bit key + }, + true, // extractable + ['encrypt', 'decrypt'] + ); + } + + /** + * Encrypt data with AES-256-GCM + * @param data Data to encrypt + * @param key AES key + * @returns Object containing ciphertext and nonce + */ + async encrypt( + data: ArrayBuffer, + key: CryptoKey + ): Promise<{ ciphertext: ArrayBuffer; nonce: ArrayBuffer }> { + // Generate random 96-bit (12-byte) nonce + const nonce = generateRandomBytes(12); + + // Encrypt with AES-GCM + const ciphertext = await this.subtle.encrypt( + { + name: 'AES-GCM', + iv: nonce, + tagLength: 128, // 128-bit authentication tag + }, + key, + data + ); + + return { + ciphertext, + nonce: nonce.buffer, + }; + } + + /** + * Decrypt data with AES-256-GCM + * @param ciphertext Encrypted data + * @param nonce Nonce/IV used for encryption + * @param key AES key + * @returns Decrypted plaintext + */ + async decrypt( + ciphertext: ArrayBuffer, + nonce: ArrayBuffer, + key: CryptoKey + ): Promise { + try { + const plaintext = await this.subtle.decrypt( + { + name: 'AES-GCM', + iv: nonce, + tagLength: 128, + }, + key, + ciphertext + ); + + return plaintext; + } catch (error) { + throw new Error(`AES-GCM decryption failed: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + + /** + * Export AES key as raw bytes + * @param key CryptoKey to export + * @returns Raw key bytes + */ + async exportKey(key: CryptoKey): Promise { + return await this.subtle.exportKey('raw', key); + } + + /** + * Import AES key from raw bytes + * @param keyData Raw key bytes (must be 32 bytes for AES-256) + * @returns Imported CryptoKey + */ + async importKey(keyData: ArrayBuffer): Promise { + if (keyData.byteLength !== 32) { + throw new Error(`Invalid AES key length: expected 32 bytes, got ${keyData.byteLength}`); + } + + return await this.subtle.importKey( + 'raw', + keyData, + { + name: 'AES-GCM', + length: 256, + }, + true, + ['encrypt', 'decrypt'] + ); + } +} diff --git a/src/core/crypto/keys.ts b/src/core/crypto/keys.ts new file mode 100644 index 0000000..eccd640 --- /dev/null +++ b/src/core/crypto/keys.ts @@ -0,0 +1,189 @@ +/** + * Key management for RSA key pairs + * Handles key generation, loading, and persistence + * + * 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. + */ + +import { RSAOperations } from './rsa'; +import { KeyGenOptions, KeyPaths } from '../../types/client'; + +export class KeyManager { + private rsa: RSAOperations; + private publicKey?: CryptoKey; + private privateKey?: CryptoKey; + private publicKeyPem?: string; + + constructor() { + this.rsa = new RSAOperations(); + } + + /** + * Generate new RSA key pair + * @param options Key generation options + */ + async generateKeys(options: KeyGenOptions = {}): Promise { + const { + keySize = 4096, + saveToFile = false, + keyDir = 'client_keys', + password, + } = options; + + console.log(`Generating ${keySize}-bit RSA key pair...`); + + // Generate key pair + const keyPair = await this.rsa.generateKeyPair(keySize); + this.publicKey = keyPair.publicKey; + this.privateKey = keyPair.privateKey; + + // Export public key to PEM + this.publicKeyPem = await this.rsa.exportPublicKey(this.publicKey); + + console.log(`Generated ${keySize}-bit RSA key pair`); + + // Save to file if requested (Node.js only) + if (saveToFile) { + await this.saveKeys(keyDir, password); + } + } + + /** + * Load keys from files (Node.js only) + * @param paths Key file paths + * @param password Optional password for encrypted private key + */ + async loadKeys(paths: KeyPaths, password?: string): Promise { + // Check if we're in Node.js + if (typeof window !== 'undefined') { + throw new Error('File-based key loading is not supported in browsers. Use in-memory keys only.'); + } + + console.log('Loading keys from files...'); + + const fs = require('fs').promises; + const path = require('path'); + + // Load private key + const privateKeyPem = await fs.readFile(paths.privateKeyPath, 'utf-8'); + this.privateKey = await this.rsa.importPrivateKey(privateKeyPem, password); + + // Load or derive public key + if (paths.publicKeyPath) { + this.publicKeyPem = await fs.readFile(paths.publicKeyPath, 'utf-8'); + this.publicKey = await this.rsa.importPublicKey(this.publicKeyPem); + } else { + // Derive public key from private key's public component + // For now, we'll require the public key file + const publicKeyPath = path.join( + path.dirname(paths.privateKeyPath), + 'public_key.pem' + ); + this.publicKeyPem = await fs.readFile(publicKeyPath, 'utf-8'); + this.publicKey = await this.rsa.importPublicKey(this.publicKeyPem); + } + + console.log('Keys loaded successfully'); + } + + /** + * Save keys to files (Node.js only) + * @param directory Directory to save keys + * @param password Optional password to encrypt private key + */ + async saveKeys(directory: string, password?: string): Promise { + // Check if we're in Node.js + if (typeof window !== 'undefined') { + throw new Error('File-based key saving is not supported in browsers'); + } + + if (!this.privateKey || !this.publicKey) { + throw new Error('No keys to save. Generate or load keys first.'); + } + + const fs = require('fs').promises; + const path = require('path'); + + console.log(`Saving keys to ${directory}/...`); + + // Create directory if it doesn't exist + await fs.mkdir(directory, { recursive: true }); + + // Export and save private key + const privateKeyPem = await this.rsa.exportPrivateKey(this.privateKey, password); + const privateKeyPath = path.join(directory, 'private_key.pem'); + await fs.writeFile(privateKeyPath, privateKeyPem, 'utf-8'); + + // Set restrictive permissions on private key (Unix-like systems) + try { + await fs.chmod(privateKeyPath, 0o600); // Owner read/write only + console.log('Private key permissions set to 600 (owner-only access)'); + } catch (error) { + console.warn('Could not set private key permissions:', error); + } + + // Save public key + if (!this.publicKeyPem) { + this.publicKeyPem = await this.rsa.exportPublicKey(this.publicKey); + } + const publicKeyPath = path.join(directory, 'public_key.pem'); + await fs.writeFile(publicKeyPath, this.publicKeyPem, 'utf-8'); + + // Set permissions on public key + try { + await fs.chmod(publicKeyPath, 0o644); // Owner read/write, others read + console.log('Public key permissions set to 644'); + } catch (error) { + console.warn('Could not set public key permissions:', error); + } + + if (password) { + console.log('Private key encrypted with password'); + } else { + console.warn('Private key saved UNENCRYPTED (not recommended for production)'); + } + + console.log(`Keys saved to ${directory}/`); + } + + /** + * Get public key in PEM format + */ + async getPublicKeyPEM(): Promise { + if (!this.publicKeyPem) { + if (!this.publicKey) { + throw new Error('No public key available. Generate or load keys first.'); + } + this.publicKeyPem = await this.rsa.exportPublicKey(this.publicKey); + } + return this.publicKeyPem; + } + + /** + * Get private key (for internal use) + */ + getPrivateKey(): CryptoKey { + if (!this.privateKey) { + throw new Error('No private key available. Generate or load keys first.'); + } + return this.privateKey; + } + + /** + * Get public key (for internal use) + */ + getPublicKey(): CryptoKey { + if (!this.publicKey) { + throw new Error('No public key available. Generate or load keys first.'); + } + return this.publicKey; + } + + /** + * Check if keys are loaded + */ + hasKeys(): boolean { + return !!(this.privateKey && this.publicKey); + } +} diff --git a/src/core/crypto/rsa.ts b/src/core/crypto/rsa.ts new file mode 100644 index 0000000..fbf6668 --- /dev/null +++ b/src/core/crypto/rsa.ts @@ -0,0 +1,245 @@ +/** + * RSA-OAEP operations for key exchange + * Matches the Python implementation using RSA-OAEP with SHA-256 + */ + +import { getCrypto, pemToArrayBuffer, arrayBufferToPem, stringToArrayBuffer, arrayBufferToString } from './utils'; + +export class RSAOperations { + private subtle: SubtleCrypto; + + constructor() { + this.subtle = getCrypto(); + } + + /** + * Generate RSA key pair (2048 or 4096 bit) + * @param keySize Key size in bits (default: 4096) + */ + async generateKeyPair(keySize: 2048 | 4096 = 4096): Promise { + return await this.subtle.generateKey( + { + name: 'RSA-OAEP', + modulusLength: keySize, + publicExponent: new Uint8Array([1, 0, 1]), // 65537 + hash: 'SHA-256', + }, + true, // extractable + ['encrypt', 'decrypt'] + ); + } + + /** + * Encrypt AES key with RSA public key + * @param aesKey Raw AES key bytes + * @param publicKey RSA public key + * @returns Encrypted AES key + */ + async encryptKey(aesKey: ArrayBuffer, publicKey: CryptoKey): Promise { + return await this.subtle.encrypt( + { + name: 'RSA-OAEP', + }, + publicKey, + aesKey + ); + } + + /** + * Decrypt AES key with RSA private key + * @param encryptedKey Encrypted AES key + * @param privateKey RSA private key + * @returns Decrypted AES key (raw bytes) + */ + async decryptKey(encryptedKey: ArrayBuffer, privateKey: CryptoKey): Promise { + try { + return await this.subtle.decrypt( + { + name: 'RSA-OAEP', + }, + privateKey, + encryptedKey + ); + } catch (error) { + throw new Error(`RSA-OAEP decryption failed: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + + /** + * Export public key to PEM format (SPKI) + * @param publicKey RSA public key + * @returns PEM-encoded public key + */ + async exportPublicKey(publicKey: CryptoKey): Promise { + const exported = await this.subtle.exportKey('spki', publicKey); + return arrayBufferToPem(exported, 'PUBLIC'); + } + + /** + * Import public key from PEM format + * @param pem PEM-encoded public key + * @returns RSA public key + */ + async importPublicKey(pem: string): Promise { + const keyData = pemToArrayBuffer(pem, 'PUBLIC'); + + return await this.subtle.importKey( + 'spki', + keyData, + { + name: 'RSA-OAEP', + hash: 'SHA-256', + }, + true, + ['encrypt'] + ); + } + + /** + * Export private key to PEM format (PKCS8), optionally encrypted with password + * @param privateKey RSA private key + * @param password Optional password to encrypt the private key + * @returns PEM-encoded private key + */ + async exportPrivateKey(privateKey: CryptoKey, password?: string): Promise { + const exported = await this.subtle.exportKey('pkcs8', privateKey); + + if (password) { + // Encrypt the private key with password using PBKDF2 + AES-256-CBC + const encryptedKey = await this.encryptPrivateKeyWithPassword(exported, password); + return encryptedKey; + } + + return arrayBufferToPem(exported, 'PRIVATE'); + } + + /** + * Import private key from PEM format, optionally decrypting with password + * @param pem PEM-encoded private key + * @param password Optional password if private key is encrypted + * @returns RSA private key + */ + async importPrivateKey(pem: string, password?: string): Promise { + let keyData: ArrayBuffer; + + if (password) { + // Decrypt the private key with password + keyData = await this.decryptPrivateKeyWithPassword(pem, password); + } else { + keyData = pemToArrayBuffer(pem, 'PRIVATE'); + } + + return await this.subtle.importKey( + 'pkcs8', + keyData, + { + name: 'RSA-OAEP', + hash: 'SHA-256', + }, + true, + ['decrypt'] + ); + } + + /** + * Encrypt private key with password using PBKDF2 + AES-256-CBC + * @param keyData Private key data (PKCS8) + * @param password Password to encrypt with + * @returns PEM-encoded encrypted private key + */ + private async encryptPrivateKeyWithPassword(keyData: ArrayBuffer, password: string): Promise { + // Derive encryption key from password using PBKDF2 + const passwordKey = await this.subtle.importKey( + 'raw', + stringToArrayBuffer(password), + 'PBKDF2', + false, + ['deriveKey'] + ); + + const salt = crypto.getRandomValues(new Uint8Array(16)); + const derivedKey = await this.subtle.deriveKey( + { + name: 'PBKDF2', + salt: salt, + iterations: 100000, + hash: 'SHA-256', + }, + passwordKey, + { name: 'AES-CBC', length: 256 }, + false, + ['encrypt'] + ); + + // Encrypt private key with AES-256-CBC + const iv = crypto.getRandomValues(new Uint8Array(16)); + const encrypted = await this.subtle.encrypt( + { + name: 'AES-CBC', + iv: iv, + }, + derivedKey, + keyData + ); + + // Combine salt + iv + encrypted data + const combined = new Uint8Array(salt.length + iv.length + encrypted.byteLength); + combined.set(salt, 0); + combined.set(iv, salt.length); + combined.set(new Uint8Array(encrypted), salt.length + iv.length); + + return arrayBufferToPem(combined.buffer, 'PRIVATE'); + } + + /** + * Decrypt private key with password + * @param pem PEM-encoded encrypted private key + * @param password Password to decrypt with + * @returns Decrypted private key data (PKCS8) + */ + private async decryptPrivateKeyWithPassword(pem: string, password: string): Promise { + const combined = pemToArrayBuffer(pem, 'PRIVATE'); + const combinedArray = new Uint8Array(combined); + + // Extract salt, iv, and encrypted data + const salt = combinedArray.slice(0, 16); + const iv = combinedArray.slice(16, 32); + const encrypted = combinedArray.slice(32); + + // Derive decryption key from password + const passwordKey = await this.subtle.importKey( + 'raw', + stringToArrayBuffer(password), + 'PBKDF2', + false, + ['deriveKey'] + ); + + const derivedKey = await this.subtle.deriveKey( + { + name: 'PBKDF2', + salt: salt, + iterations: 100000, + hash: 'SHA-256', + }, + passwordKey, + { name: 'AES-CBC', length: 256 }, + false, + ['decrypt'] + ); + + // Decrypt private key + try { + return await this.subtle.decrypt( + { + name: 'AES-CBC', + iv: iv, + }, + derivedKey, + encrypted + ); + } catch (error) { + throw new Error('Failed to decrypt private key: invalid password or corrupted key'); + } + } +} diff --git a/src/core/crypto/utils.ts b/src/core/crypto/utils.ts new file mode 100644 index 0000000..4abee7f --- /dev/null +++ b/src/core/crypto/utils.ts @@ -0,0 +1,123 @@ +/** + * Cryptographic utility functions + * Provides platform-agnostic implementations for Base64, PEM conversion, and random bytes + */ + +/** + * Convert ArrayBuffer to Base64 string + */ +export function arrayBufferToBase64(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + let binary = ''; + for (let i = 0; i < bytes.byteLength; i++) { + binary += String.fromCharCode(bytes[i]); + } + + // Use btoa if available (browser), otherwise use Buffer (Node.js) + if (typeof btoa !== 'undefined') { + return btoa(binary); + } else { + return Buffer.from(bytes).toString('base64'); + } +} + +/** + * Convert Base64 string to ArrayBuffer + */ +export function base64ToArrayBuffer(base64: string): ArrayBuffer { + // Use atob if available (browser), otherwise use Buffer (Node.js) + let binary: string; + if (typeof atob !== 'undefined') { + binary = atob(base64); + } else { + binary = Buffer.from(base64, 'base64').toString('binary'); + } + + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes.buffer; +} + +/** + * Generate cryptographically secure random bytes + */ +export function generateRandomBytes(length: number): Uint8Array { + const bytes = new Uint8Array(length); + + // Use crypto.getRandomValues if available (browser/Node.js), otherwise use crypto.randomBytes (Node.js) + if (typeof crypto !== 'undefined' && crypto.getRandomValues) { + crypto.getRandomValues(bytes); + } else { + // Node.js fallback + const nodeCrypto = require('crypto'); + const randomBytes = nodeCrypto.randomBytes(length); + bytes.set(randomBytes); + } + + return bytes; +} + +/** + * Convert string to ArrayBuffer (UTF-8 encoding) + */ +export function stringToArrayBuffer(str: string): ArrayBuffer { + const encoder = new TextEncoder(); + return encoder.encode(str).buffer; +} + +/** + * Convert ArrayBuffer to string (UTF-8 decoding) + */ +export function arrayBufferToString(buffer: ArrayBuffer): string { + const decoder = new TextDecoder(); + return decoder.decode(buffer); +} + +/** + * Convert PEM format to ArrayBuffer + * @param pem PEM-encoded key (with header/footer) + * @param type Key type ('PUBLIC' or 'PRIVATE') + */ +export function pemToArrayBuffer(pem: string, type: 'PUBLIC' | 'PRIVATE'): ArrayBuffer { + // Remove header, footer, and whitespace + const header = `-----BEGIN ${type === 'PUBLIC' ? 'PUBLIC' : 'PRIVATE'} KEY-----`; + const footer = `-----END ${type === 'PUBLIC' ? 'PUBLIC' : 'PRIVATE'} KEY-----`; + + const pemContents = pem + .replace(header, '') + .replace(footer, '') + .replace(/\s/g, ''); + + return base64ToArrayBuffer(pemContents); +} + +/** + * Convert ArrayBuffer to PEM format + * @param buffer Key data as ArrayBuffer + * @param type Key type ('PUBLIC' or 'PRIVATE') + */ +export function arrayBufferToPem(buffer: ArrayBuffer, type: 'PUBLIC' | 'PRIVATE'): string { + const base64 = arrayBufferToBase64(buffer); + const header = `-----BEGIN ${type === 'PUBLIC' ? 'PUBLIC' : 'PRIVATE'} KEY-----`; + const footer = `-----END ${type === 'PUBLIC' ? 'PUBLIC' : 'PRIVATE'} KEY-----`; + + // Format with line breaks every 64 characters + const formatted = base64.match(/.{1,64}/g)?.join('\n') || base64; + + return `${header}\n${formatted}\n${footer}`; +} + +/** + * Get the Web Crypto API (works in both browser and Node.js) + */ +export function getCrypto(): SubtleCrypto { + if (typeof crypto !== 'undefined' && crypto.subtle) { + return crypto.subtle; + } else { + // Node.js + const nodeCrypto = require('crypto'); + return nodeCrypto.webcrypto.subtle; + } +} diff --git a/src/core/http/browser.ts b/src/core/http/browser.ts new file mode 100644 index 0000000..10a6610 --- /dev/null +++ b/src/core/http/browser.ts @@ -0,0 +1,94 @@ +/** + * Browser HTTP client using Fetch API + */ + +import { HttpClient, HttpResponse, HttpRequestOptions } from './client'; + +export class BrowserHttpClient implements HttpClient { + /** + * Send POST request + */ + async post(url: string, options: HttpRequestOptions): Promise { + const { headers = {}, body, timeout = 60000 } = options; + + // Create AbortController for timeout + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + + try { + const response = await fetch(url, { + method: 'POST', + headers: headers, + body: body, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + // Read response body as ArrayBuffer + const responseBody = await response.arrayBuffer(); + + // Convert headers to plain object + const responseHeaders: Record = {}; + response.headers.forEach((value, key) => { + responseHeaders[key] = value; + }); + + return { + statusCode: response.status, + headers: responseHeaders, + body: responseBody, + }; + } catch (error) { + clearTimeout(timeoutId); + + if (error instanceof Error && error.name === 'AbortError') { + throw new Error('Request timeout'); + } + throw error; + } + } + + /** + * Send GET request + */ + async get(url: string, options: Omit = {}): Promise { + const { headers = {}, timeout = 60000 } = options; + + // Create AbortController for timeout + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + + try { + const response = await fetch(url, { + method: 'GET', + headers: headers, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + // Read response body as ArrayBuffer + const responseBody = await response.arrayBuffer(); + + // Convert headers to plain object + const responseHeaders: Record = {}; + response.headers.forEach((value, key) => { + responseHeaders[key] = value; + }); + + return { + statusCode: response.status, + headers: responseHeaders, + body: responseBody, + }; + } catch (error) { + clearTimeout(timeoutId); + + if (error instanceof Error && error.name === 'AbortError') { + throw new Error('Request timeout'); + } + throw error; + } + } +} diff --git a/src/core/http/client.ts b/src/core/http/client.ts new file mode 100644 index 0000000..f2ad6c4 --- /dev/null +++ b/src/core/http/client.ts @@ -0,0 +1,36 @@ +/** + * HTTP client abstraction + * Provides a platform-agnostic interface for making HTTP requests + */ + +export interface HttpResponse { + statusCode: number; + headers: Record; + body: ArrayBuffer; +} + +export interface HttpRequestOptions { + headers?: Record; + body: ArrayBuffer | string; + timeout?: number; +} + +export interface HttpClient { + post(url: string, options: HttpRequestOptions): Promise; + get(url: string, options?: Omit): Promise; +} + +/** + * Create an HTTP client for the current platform + */ +export function createHttpClient(): HttpClient { + if (typeof window !== 'undefined') { + // Browser environment + const BrowserHttpClient = require('./browser').BrowserHttpClient; + return new BrowserHttpClient(); + } else { + // Node.js environment + const NodeHttpClient = require('./node').NodeHttpClient; + return new NodeHttpClient(); + } +} diff --git a/src/core/http/node.ts b/src/core/http/node.ts new file mode 100644 index 0000000..9579ab7 --- /dev/null +++ b/src/core/http/node.ts @@ -0,0 +1,143 @@ +/** + * Node.js HTTP client using native https module + */ + +import { HttpClient, HttpResponse, HttpRequestOptions } from './client'; +import * as https from 'https'; +import * as http from 'http'; +import { URL } from 'url'; + +export class NodeHttpClient implements HttpClient { + /** + * Send POST request + */ + async post(url: string, options: HttpRequestOptions): Promise { + const { headers = {}, body, timeout = 60000 } = options; + + return new Promise((resolve, reject) => { + const parsedUrl = new URL(url); + const isHttps = parsedUrl.protocol === 'https:'; + const httpModule = isHttps ? https : http; + + // Convert body to Buffer + const bodyBuffer = body instanceof ArrayBuffer + ? Buffer.from(body) + : Buffer.from(body, 'utf-8'); + + const requestOptions = { + hostname: parsedUrl.hostname, + port: parsedUrl.port || (isHttps ? 443 : 80), + path: parsedUrl.pathname + parsedUrl.search, + method: 'POST', + headers: { + ...headers, + 'Content-Length': bodyBuffer.length, + }, + timeout: timeout, + }; + + const req = httpModule.request(requestOptions, (res) => { + const chunks: Buffer[] = []; + + res.on('data', (chunk) => { + chunks.push(chunk); + }); + + res.on('end', () => { + const responseBody = Buffer.concat(chunks); + + // Convert headers to plain object + const responseHeaders: Record = {}; + Object.entries(res.headers).forEach(([key, value]) => { + if (value) { + responseHeaders[key] = Array.isArray(value) ? value[0] : value; + } + }); + + resolve({ + statusCode: res.statusCode || 0, + headers: responseHeaders, + body: responseBody.buffer.slice( + responseBody.byteOffset, + responseBody.byteOffset + responseBody.byteLength + ), + }); + }); + }); + + req.on('error', (error) => { + reject(error); + }); + + req.on('timeout', () => { + req.destroy(); + reject(new Error('Request timeout')); + }); + + req.write(bodyBuffer); + req.end(); + }); + } + + /** + * Send GET request + */ + async get(url: string, options: Omit = {}): Promise { + const { headers = {}, timeout = 60000 } = options; + + return new Promise((resolve, reject) => { + const parsedUrl = new URL(url); + const isHttps = parsedUrl.protocol === 'https:'; + const httpModule = isHttps ? https : http; + + const requestOptions = { + hostname: parsedUrl.hostname, + port: parsedUrl.port || (isHttps ? 443 : 80), + path: parsedUrl.pathname + parsedUrl.search, + method: 'GET', + headers: headers, + timeout: timeout, + }; + + const req = httpModule.request(requestOptions, (res) => { + const chunks: Buffer[] = []; + + res.on('data', (chunk) => { + chunks.push(chunk); + }); + + res.on('end', () => { + const responseBody = Buffer.concat(chunks); + + // Convert headers to plain object + const responseHeaders: Record = {}; + Object.entries(res.headers).forEach(([key, value]) => { + if (value) { + responseHeaders[key] = Array.isArray(value) ? value[0] : value; + } + }); + + resolve({ + statusCode: res.statusCode || 0, + headers: responseHeaders, + body: responseBody.buffer.slice( + responseBody.byteOffset, + responseBody.byteOffset + responseBody.byteLength + ), + }); + }); + }); + + req.on('error', (error) => { + reject(error); + }); + + req.on('timeout', () => { + req.destroy(); + reject(new Error('Request timeout')); + }); + + req.end(); + }); + } +} diff --git a/src/core/memory/browser.ts b/src/core/memory/browser.ts new file mode 100644 index 0000000..508033a --- /dev/null +++ b/src/core/memory/browser.ts @@ -0,0 +1,33 @@ +/** + * Browser secure memory implementation + * + * LIMITATIONS: + * - Cannot lock memory (no OS-level mlock in browsers) + * - JavaScript GC controls memory lifecycle + * - Best effort: immediate zeroing to minimize exposure time + */ + +import { SecureMemory, ProtectionInfo } from '../../types/crypto'; + +export class BrowserSecureMemory implements SecureMemory { + /** + * Zero memory immediately + * Note: This doesn't prevent JavaScript GC from moving/copying the data + */ + zeroMemory(data: ArrayBuffer): void { + const view = new Uint8Array(data); + view.fill(0); + } + + /** + * Get protection information + */ + getProtectionInfo(): ProtectionInfo { + return { + canLock: false, + isPlatformSecure: false, + method: 'zero-only', + details: 'Browser environment: memory locking not available. Using immediate zeroing only.', + }; + } +} diff --git a/src/core/memory/node.ts b/src/core/memory/node.ts new file mode 100644 index 0000000..1773901 --- /dev/null +++ b/src/core/memory/node.ts @@ -0,0 +1,41 @@ +/** + * Node.js secure memory implementation (pure JavaScript) + * + * LIMITATIONS: + * - This is a pure JavaScript implementation without native addons + * - Cannot lock memory (no mlock support without native addon) + * - JavaScript GC controls memory lifecycle + * - Best effort: immediate zeroing to minimize exposure time + * + * FUTURE ENHANCEMENT: + * A native addon can be implemented separately to provide true mlock support. + * See the native/ directory for an optional native implementation. + */ + +import { SecureMemory, ProtectionInfo } from '../../types/crypto'; + +export class NodeSecureMemory implements SecureMemory { + /** + * Zero memory immediately + * Note: This doesn't prevent JavaScript GC from moving/copying the data + */ + zeroMemory(data: ArrayBuffer): void { + const view = new Uint8Array(data); + view.fill(0); + } + + /** + * Get protection information + */ + getProtectionInfo(): ProtectionInfo { + return { + canLock: false, + isPlatformSecure: false, + method: 'zero-only', + details: + 'Node.js environment (pure JS): memory locking not available without native addon. ' + + 'Using immediate zeroing only. ' + + 'For enhanced security, consider implementing the optional native addon (see native/ directory).', + }; + } +} diff --git a/src/core/memory/secure.ts b/src/core/memory/secure.ts new file mode 100644 index 0000000..8ae115b --- /dev/null +++ b/src/core/memory/secure.ts @@ -0,0 +1,70 @@ +/** + * Secure memory interface and context manager + * + * IMPORTANT: This is a pure JavaScript implementation that provides memory zeroing only. + * OS-level memory locking (mlock) is NOT implemented in this version. + * + * For production use, consider implementing a native addon for true memory locking. + * See SECURITY.md for details on memory protection limitations. + */ + +import { ProtectionInfo } from '../../types/crypto'; + +export interface SecureMemory { + /** + * Zero memory (fill with zeros) + * Note: This is best-effort. JavaScript GC controls actual memory lifecycle. + */ + zeroMemory(data: ArrayBuffer): void; + + /** + * Get memory protection information + */ + getProtectionInfo(): ProtectionInfo; +} + +/** + * Secure byte context manager + * Ensures memory is zeroed even if an exception occurs (similar to Python's context manager) + */ +export class SecureByteContext { + private data: ArrayBuffer; + private secureMemory: SecureMemory; + private useSecure: boolean; + + constructor(data: ArrayBuffer, useSecure: boolean = true) { + this.data = data; + this.useSecure = useSecure; + this.secureMemory = createSecureMemory(); + } + + /** + * Use the secure data within a function + * Ensures memory is zeroed after use, even if an exception occurs + */ + async use(fn: (data: ArrayBuffer) => Promise): Promise { + try { + return await fn(this.data); + } finally { + // Always zero memory, even if exception occurred + if (this.useSecure) { + this.secureMemory.zeroMemory(this.data); + } + } + } +} + +/** + * Create a secure memory implementation for the current platform + */ +export function createSecureMemory(): SecureMemory { + if (typeof window !== 'undefined') { + // Browser environment + const BrowserSecureMemory = require('./browser').BrowserSecureMemory; + return new BrowserSecureMemory(); + } else { + // Node.js environment + const NodeSecureMemory = require('./node').NodeSecureMemory; + return new NodeSecureMemory(); + } +} diff --git a/src/errors/index.ts b/src/errors/index.ts new file mode 100644 index 0000000..3a34d59 --- /dev/null +++ b/src/errors/index.ts @@ -0,0 +1,70 @@ +/** + * Error classes matching OpenAI's error structure + */ + +export class APIError extends Error { + statusCode?: number; + errorDetails?: object; + + constructor(message: string, statusCode?: number, errorDetails?: object) { + super(message); + this.name = 'APIError'; + this.statusCode = statusCode; + this.errorDetails = errorDetails; + + // Maintains proper stack trace for where our error was thrown (only available on V8) + if (Error.captureStackTrace) { + Error.captureStackTrace(this, this.constructor); + } + } +} + +export class AuthenticationError extends APIError { + constructor(message: string, statusCode?: number, errorDetails?: object) { + super(message, statusCode, errorDetails); + this.name = 'AuthenticationError'; + } +} + +export class InvalidRequestError extends APIError { + constructor(message: string, statusCode?: number, errorDetails?: object) { + super(message, statusCode, errorDetails); + this.name = 'InvalidRequestError'; + } +} + +export class RateLimitError extends APIError { + constructor(message: string, statusCode?: number, errorDetails?: object) { + super(message, statusCode, errorDetails); + this.name = 'RateLimitError'; + } +} + +export class ServerError extends APIError { + constructor(message: string, statusCode?: number, errorDetails?: object) { + super(message, statusCode, errorDetails); + this.name = 'ServerError'; + } +} + +export class APIConnectionError extends Error { + constructor(message: string) { + super(message); + this.name = 'APIConnectionError'; + + if (Error.captureStackTrace) { + Error.captureStackTrace(this, this.constructor); + } + } +} + +export class SecurityError extends Error { + constructor(message: string) { + super(message); + this.name = 'SecurityError'; + + if (Error.captureStackTrace) { + Error.captureStackTrace(this, this.constructor); + } + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..ccc92c7 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,15 @@ +/** + * Main entry point for nomyo-js + * Universal exports for both Node.js and browser environments + */ + +export { SecureChatCompletion } from './api/SecureChatCompletion'; +export { SecureCompletionClient } from './core/SecureCompletionClient'; + +// Export types +export * from './types/api'; +export * from './types/client'; +export * from './types/crypto'; + +// Export errors +export * from './errors'; diff --git a/src/node.ts b/src/node.ts new file mode 100644 index 0000000..e907955 --- /dev/null +++ b/src/node.ts @@ -0,0 +1,6 @@ +/** + * Node.js-specific entry point + * Ensures Node.js-specific implementations are used + */ + +export * from './index'; diff --git a/src/types/api.ts b/src/types/api.ts new file mode 100644 index 0000000..27442ef --- /dev/null +++ b/src/types/api.ts @@ -0,0 +1,105 @@ +/** + * OpenAI-compatible API type definitions + * These types match the OpenAI Chat Completion API for full compatibility + */ + +export interface Message { + role: 'system' | 'user' | 'assistant' | 'tool'; + content?: string; + name?: string; + tool_calls?: ToolCall[]; + tool_call_id?: string; +} + +export interface ToolCall { + id: string; + type: 'function'; + function: { + name: string; + arguments: string; + }; +} + +export interface FunctionDefinition { + name: string; + description?: string; + parameters?: object; +} + +export interface Tool { + type: 'function'; + function: FunctionDefinition; +} + +export type ToolChoice = 'none' | 'auto' | 'required' | { type: 'function'; function: { name: string } }; + +export interface ChatCompletionRequest { + model: string; + messages: Message[]; + + // Optional parameters (matching OpenAI API) + temperature?: number; + top_p?: number; + n?: number; + stream?: boolean; + stop?: string | string[]; + max_tokens?: number; + presence_penalty?: number; + frequency_penalty?: number; + logit_bias?: Record; + user?: string; + + // Tool/Function calling + tools?: Tool[]; + tool_choice?: ToolChoice; + + // Additional parameters + [key: string]: unknown; +} + +export interface Usage { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; +} + +export interface Choice { + index: number; + message: Message; + finish_reason: 'stop' | 'length' | 'tool_calls' | 'content_filter' | null; + logprobs?: unknown; +} + +export interface ResponseMetadata { + payload_id: string; + processed_at: number; + is_encrypted: boolean; + encryption_algorithm: string; + response_status: string; +} + +export interface ChatCompletionResponse { + id: string; + object: 'chat.completion'; + created: number; + model: string; + choices: Choice[]; + usage?: Usage; + system_fingerprint?: string; + + // NOMYO-specific metadata + _metadata?: ResponseMetadata; +} + +// Streaming types (for future implementation) +export interface ChatCompletionChunk { + id: string; + object: 'chat.completion.chunk'; + created: number; + model: string; + choices: { + index: number; + delta: Partial; + finish_reason: string | null; + }[]; +} diff --git a/src/types/client.ts b/src/types/client.ts new file mode 100644 index 0000000..6f9accb --- /dev/null +++ b/src/types/client.ts @@ -0,0 +1,56 @@ +/** + * Client configuration types + */ + +export interface ClientConfig { + /** Base URL of the NOMYO router (e.g., https://api.nomyo.ai:12434) */ + routerUrl: string; + + /** Allow HTTP connections (ONLY for local development, never in production) */ + allowHttp?: boolean; + + /** Enable secure memory protection (zeroing) */ + secureMemory?: boolean; + + /** RSA key size in bits (2048 or 4096) */ + keySize?: 2048 | 4096; + + /** Optional API key for authentication */ + apiKey?: string; +} + +export interface KeyGenOptions { + /** RSA key size in bits */ + keySize?: 2048 | 4096; + + /** Save keys to file system (Node.js only) */ + saveToFile?: boolean; + + /** Directory to save keys (default: 'client_keys') */ + keyDir?: string; + + /** Password to encrypt private key (recommended for production) */ + password?: string; +} + +export interface KeyPaths { + /** Path to private key file */ + privateKeyPath: string; + + /** Path to public key file (optional, will derive from private key path) */ + publicKeyPath?: string; +} + +export interface ChatCompletionConfig { + /** Base URL of the NOMYO router */ + baseUrl?: string; + + /** Allow HTTP connections */ + allowHttp?: boolean; + + /** API key for authentication */ + apiKey?: string; + + /** Enable secure memory protection */ + secureMemory?: boolean; +} diff --git a/src/types/crypto.ts b/src/types/crypto.ts new file mode 100644 index 0000000..455ce4a --- /dev/null +++ b/src/types/crypto.ts @@ -0,0 +1,42 @@ +/** + * Cryptography-related types + */ + +export interface EncryptedPackage { + /** Encrypted payload data */ + encrypted_payload: string; + + /** Encrypted AES key (encrypted with server's RSA public key) */ + encrypted_aes_key: string; + + /** Client's public key in PEM format */ + client_public_key: string; + + /** Unique identifier for this encrypted package */ + payload_id: string; + + /** Nonce/IV used for AES encryption (base64 encoded) */ + nonce: string; +} + +export interface ProtectionInfo { + /** Whether memory can be locked (mlock) */ + canLock: boolean; + + /** Whether the platform provides secure memory protection */ + isPlatformSecure: boolean; + + /** Method used for memory protection */ + method: 'mlock' | 'zero-only' | 'none'; + + /** Additional information about protection status */ + details?: string; +} + +export interface SecureMemoryConfig { + /** Enable secure memory protection */ + enabled: boolean; + + /** Prefer native addon if available (Node.js only) */ + preferNative?: boolean; +} diff --git a/tests/build.txt b/tests/build.txt new file mode 100644 index 0000000..db41ae5 --- /dev/null +++ b/tests/build.txt @@ -0,0 +1,4 @@ +cd nomyo-js +npm install # Install dev dependencies +npm run build # Build Node.js + browser bundles +npm test # Run tests (when you create them) \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..562efd9 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,32 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "lib": [ + "ES2020", + "DOM" + ], + "declaration": true, + "declarationDir": "./dist/types", + "outDir": "./dist", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "moduleResolution": "node", + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "include": [ + "src/**/*" + ], + "exclude": [ + "node_modules", + "dist", + "tests" + ] +} \ No newline at end of file