- AES GCM protocol mismatch
- better, granular error handling
- UUID now uses crypto.randomUUID()
- added native mlock addon to improve security
- ZeroBuffer uses explicit_bzero now
- fixed imports

feat:
-  added unit tests
This commit is contained in:
Alpha Nerd 2026-03-04 11:30:44 +01:00
parent 0b09b9a9c3
commit c7601b2270
17 changed files with 12600 additions and 164 deletions

26
jest.config.js Normal file
View file

@ -0,0 +1,26 @@
/** @type {import('jest').Config} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: ['**/tests/unit/**/*.test.ts', '**/tests/integration/**/*.test.ts'],
transform: {
'^.+\\.tsx?$': ['ts-jest', {
tsconfig: {
// Reuse main tsconfig but include tests and relax for test env
target: 'ES2020',
module: 'commonjs',
lib: ['ES2020', 'DOM'],
strict: true,
esModuleInterop: true,
skipLibCheck: true,
moduleResolution: 'node',
resolveJsonModule: true,
allowSyntheticDefaultImports: true,
// Relax unused-var rules in tests
noUnusedLocals: false,
noUnusedParameters: false,
},
}],
},
testTimeout: 60000,
};

28
native/binding.gyp Normal file
View file

@ -0,0 +1,28 @@
{
"targets": [{
"target_name": "nomyo_native",
"sources": ["src/mlock.cc"],
"include_dirs": [
"<!@(node -p \"require('node-addon-api').include\")"
],
"defines": ["NAPI_DISABLE_CPP_EXCEPTIONS"],
"conditions": [
["OS=='linux'", {
"cflags!": ["-fno-exceptions"],
"cflags_cc!": ["-fno-exceptions"]
}],
["OS=='mac'", {
"xcode_settings": {
"GCC_ENABLE_CPP_EXCEPTIONS": "YES"
}
}],
["OS=='win'", {
"msvs_settings": {
"VCCLCompilerTool": {
"ExceptionHandling": 1
}
}
}]
]
}]
}

13
native/index.js Normal file
View file

@ -0,0 +1,13 @@
/**
* Native mlock addon entry point.
* Loaded via node-gyp-build which selects the correct pre-built binary for
* the current platform/Node.js version, or falls back to building from source.
*
* Returns null if the native addon is not available, allowing the JS layer
* to fall back to pure-JS zeroing.
*/
try {
module.exports = require('node-gyp-build')(__dirname);
} catch (_e) {
module.exports = null;
}

19
native/package.json Normal file
View file

@ -0,0 +1,19 @@
{
"name": "nomyo-native",
"version": "0.1.0",
"description": "Native mlock/munlock/secure-zero addon for nomyo-js (optional, Node.js only)",
"main": "index.js",
"license": "Apache-2.0",
"scripts": {
"build": "node-gyp build",
"rebuild": "node-gyp rebuild",
"install": "node-gyp-build"
},
"gypfile": true,
"dependencies": {
"node-gyp-build": "^4.8.0"
},
"devDependencies": {
"node-gyp": "^10.0.0"
}
}

142
native/src/mlock.cc Normal file
View file

@ -0,0 +1,142 @@
/**
* Native mlock/munlock/secure-zero addon for nomyo-js
*
* Provides OS-level memory locking (mlock) and secure zeroing to prevent
* sensitive key material from being swapped to disk.
*
* Platforms:
* Linux/macOS: mlock + explicit_bzero (or memset with compiler barrier)
* Windows: VirtualLock + SecureZeroMemory
*
* Note: mlock may fail without CAP_IPC_LOCK capability (Linux) or elevated
* privileges (Windows). The JS layer treats locking as best-effort.
*/
#include <napi.h>
#include <cstring>
#include <cstdint>
#ifdef _WIN32
#include <windows.h>
#else
#include <sys/mman.h>
#include <unistd.h>
#endif
// ---------------------------------------------------------------------------
// Secure zeroing — must not be optimized away by the compiler
// ---------------------------------------------------------------------------
static void secure_zero(void* ptr, size_t len) {
#if defined(_WIN32)
SecureZeroMemory(ptr, len);
#elif defined(__OpenBSD__) || defined(__FreeBSD__) || defined(__linux__) || defined(__APPLE__)
// explicit_bzero is available on Linux (glibc 2.25+), macOS 10.12+, BSDs.
// If unavailable, the volatile trick below still works.
explicit_bzero(ptr, len);
#else
volatile uint8_t* p = reinterpret_cast<volatile uint8_t*>(ptr);
for (size_t i = 0; i < len; ++i) {
p[i] = 0;
}
#endif
}
// ---------------------------------------------------------------------------
// mlockBuffer(buffer: Buffer) → boolean
// ---------------------------------------------------------------------------
Napi::Value MlockBuffer(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
if (info.Length() < 1 || !info[0].IsBuffer()) {
Napi::TypeError::New(env, "Expected a Buffer argument").ThrowAsJavaScriptException();
return env.Null();
}
auto buf = info[0].As<Napi::Buffer<uint8_t>>();
void* ptr = buf.Data();
size_t len = buf.Length();
if (len == 0) {
return Napi::Boolean::New(env, true);
}
#ifdef _WIN32
BOOL ok = VirtualLock(ptr, len);
return Napi::Boolean::New(env, ok != 0);
#else
int rc = mlock(ptr, len);
return Napi::Boolean::New(env, rc == 0);
#endif
}
// ---------------------------------------------------------------------------
// munlockBuffer(buffer: Buffer) → boolean
// ---------------------------------------------------------------------------
Napi::Value MunlockBuffer(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
if (info.Length() < 1 || !info[0].IsBuffer()) {
Napi::TypeError::New(env, "Expected a Buffer argument").ThrowAsJavaScriptException();
return env.Null();
}
auto buf = info[0].As<Napi::Buffer<uint8_t>>();
void* ptr = buf.Data();
size_t len = buf.Length();
if (len == 0) {
return Napi::Boolean::New(env, true);
}
#ifdef _WIN32
BOOL ok = VirtualUnlock(ptr, len);
return Napi::Boolean::New(env, ok != 0);
#else
int rc = munlock(ptr, len);
return Napi::Boolean::New(env, rc == 0);
#endif
}
// ---------------------------------------------------------------------------
// secureZeroBuffer(buffer: Buffer) → void
// ---------------------------------------------------------------------------
Napi::Value SecureZeroBuffer(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
if (info.Length() < 1 || !info[0].IsBuffer()) {
Napi::TypeError::New(env, "Expected a Buffer argument").ThrowAsJavaScriptException();
return env.Null();
}
auto buf = info[0].As<Napi::Buffer<uint8_t>>();
secure_zero(buf.Data(), buf.Length());
return env.Undefined();
}
// ---------------------------------------------------------------------------
// getPageSize() → number
// ---------------------------------------------------------------------------
Napi::Value GetPageSize(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
#ifdef _WIN32
SYSTEM_INFO si;
GetSystemInfo(&si);
return Napi::Number::New(env, static_cast<double>(si.dwPageSize));
#else
return Napi::Number::New(env, static_cast<double>(sysconf(_SC_PAGESIZE)));
#endif
}
// ---------------------------------------------------------------------------
// Module init
// ---------------------------------------------------------------------------
Napi::Object Init(Napi::Env env, Napi::Object exports) {
exports.Set("mlockBuffer", Napi::Function::New(env, MlockBuffer));
exports.Set("munlockBuffer", Napi::Function::New(env, MunlockBuffer));
exports.Set("secureZeroBuffer", Napi::Function::New(env, SecureZeroBuffer));
exports.Set("getPageSize", Napi::Function::New(env, GetPageSize));
return exports;
}
NODE_API_MODULE(nomyo_native, Init)

11584
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -46,18 +46,23 @@
], ],
"author": "", "author": "",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": {}, "engines": {
"node": ">=14.17.0"
},
"devDependencies": { "devDependencies": {
"@rollup/plugin-typescript": "^11.0.0",
"@rollup/plugin-node-resolve": "^15.0.0",
"@rollup/plugin-commonjs": "^25.0.0", "@rollup/plugin-commonjs": "^25.0.0",
"@rollup/plugin-node-resolve": "^15.0.0",
"@rollup/plugin-typescript": "^11.0.0",
"@types/jest": "^29.5.14",
"@types/node": "^20.0.0", "@types/node": "^20.0.0",
"rollup": "^4.0.0",
"typescript": "^5.3.0",
"jest": "^29.0.0", "jest": "^29.0.0",
"karma": "^6.4.0", "karma": "^6.4.0",
"node-addon-api": "^8.6.0",
"node-gyp": "^10.0.0", "node-gyp": "^10.0.0",
"node-gyp-build": "^4.8.0" "node-gyp-build": "^4.8.0",
"rollup": "^4.0.0",
"ts-jest": "^29.4.6",
"typescript": "^5.3.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"nomyo-native": "file:./native" "nomyo-native": "file:./native"

View file

@ -3,7 +3,7 @@
* Provides a drop-in replacement for OpenAI's ChatCompletion API with end-to-end encryption * Provides a drop-in replacement for OpenAI's ChatCompletion API with end-to-end encryption
*/ */
import { SecureCompletionClient } from '../core/SecureCompletionClient'; import { SecureCompletionClient, generateUUID } from '../core/SecureCompletionClient';
import { ChatCompletionConfig } from '../types/client'; import { ChatCompletionConfig } from '../types/client';
import { ChatCompletionRequest, ChatCompletionResponse } from '../types/api'; import { ChatCompletionRequest, ChatCompletionResponse } from '../types/api';
@ -28,22 +28,23 @@ export class SecureChatCompletion {
} }
/** /**
* Create a chat completion (matches OpenAI API) * Create a chat completion (matches OpenAI API).
* @param request Chat completion request *
* @returns Chat completion response * Supports additional NOMYO-specific fields:
* - `security_tier`: "standard" | "high" | "maximum" controls hardware routing
* - `api_key`: per-request API key override (takes precedence over constructor key)
*/ */
async create(request: ChatCompletionRequest): Promise<ChatCompletionResponse> { async create(request: ChatCompletionRequest): Promise<ChatCompletionResponse> {
// Generate unique payload ID const payloadId = generateUUID();
const payloadId = `openai-compat-${this.generatePayloadId()}`;
// Extract API key from request or use instance key // Extract NOMYO-specific fields that must not go into the encrypted payload
const apiKey = (request as any).api_key || this.apiKey; const { security_tier, api_key, ...payload } = request as ChatCompletionRequest & {
security_tier?: string;
api_key?: string;
};
// Remove api_key from payload if present (it's in headers) const apiKey = api_key ?? this.apiKey;
const payload = { ...request };
delete (payload as any).api_key;
// Validate required fields
if (!payload.model) { if (!payload.model) {
throw new Error('Missing required field: model'); throw new Error('Missing required field: model');
} }
@ -51,14 +52,14 @@ export class SecureChatCompletion {
throw new Error('Missing or invalid required field: messages'); throw new Error('Missing or invalid required field: messages');
} }
// Send secure request
const response = await this.client.sendSecureRequest( const response = await this.client.sendSecureRequest(
payload, payload,
payloadId, payloadId,
apiKey apiKey,
security_tier
); );
return response as ChatCompletionResponse; return response as unknown as ChatCompletionResponse;
} }
/** /**
@ -67,13 +68,4 @@ export class SecureChatCompletion {
async acreate(request: ChatCompletionRequest): Promise<ChatCompletionResponse> { async acreate(request: ChatCompletionRequest): Promise<ChatCompletionResponse> {
return this.create(request); 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}`;
}
} }

View file

@ -6,13 +6,22 @@
*/ */
import { ClientConfig } from '../types/client'; import { ClientConfig } from '../types/client';
import { EncryptedPackage } from '../types/crypto';
import { KeyManager } from './crypto/keys'; import { KeyManager } from './crypto/keys';
import { AESEncryption } from './crypto/encryption'; import { AESEncryption } from './crypto/encryption';
import { RSAOperations } from './crypto/rsa'; import { RSAOperations } from './crypto/rsa';
import { createHttpClient, HttpClient } from './http/client'; import { createHttpClient, HttpClient } from './http/client';
import { createSecureMemory, SecureByteContext } from './memory/secure'; import { createSecureMemory, SecureByteContext } from './memory/secure';
import { SecurityError, APIConnectionError } from '../errors'; import {
SecurityError,
APIConnectionError,
APIError,
AuthenticationError,
InvalidRequestError,
RateLimitError,
ServerError,
ForbiddenError,
ServiceUnavailableError,
} from '../errors';
import { import {
arrayBufferToBase64, arrayBufferToBase64,
base64ToArrayBuffer, base64ToArrayBuffer,
@ -20,6 +29,30 @@ import {
arrayBufferToString, arrayBufferToString,
} from './crypto/utils'; } from './crypto/utils';
const VALID_SECURITY_TIERS = ['standard', 'high', 'maximum'] as const;
function generateUUID(): string {
if (typeof crypto !== 'undefined' && typeof (crypto as Crypto & { randomUUID?: () => string }).randomUUID === 'function') {
return (crypto as Crypto & { randomUUID: () => string }).randomUUID();
}
// Node.js fallback
try {
const nodeCrypto = require('crypto') as { randomUUID?: () => string };
if (nodeCrypto.randomUUID) {
return nodeCrypto.randomUUID();
}
} catch (_e) { /* not in Node.js */ }
// Last-resort fallback (not RFC-compliant but collision-resistant enough)
const bytes = new Uint8Array(16);
if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
crypto.getRandomValues(bytes);
}
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
export class SecureCompletionClient { export class SecureCompletionClient {
private routerUrl: string; private routerUrl: string;
private allowHttp: boolean; private allowHttp: boolean;
@ -29,6 +62,7 @@ export class SecureCompletionClient {
private rsa: RSAOperations; private rsa: RSAOperations;
private httpClient: HttpClient; private httpClient: HttpClient;
private secureMemoryImpl = createSecureMemory(); private secureMemoryImpl = createSecureMemory();
private readonly keySize: 2048 | 4096;
constructor(config: ClientConfig = { routerUrl: 'https://api.nomyo.ai:12434' }) { constructor(config: ClientConfig = { routerUrl: 'https://api.nomyo.ai:12434' }) {
const { const {
@ -38,7 +72,8 @@ export class SecureCompletionClient {
keySize = 4096, keySize = 4096,
} = config; } = config;
this.routerUrl = routerUrl.replace(/\/$/, ''); // Remove trailing slash this.keySize = keySize;
this.routerUrl = routerUrl.replace(/\/$/, '');
this.allowHttp = allowHttp; this.allowHttp = allowHttp;
this.secureMemory = secureMemory; this.secureMemory = secureMemory;
@ -75,7 +110,7 @@ export class SecureCompletionClient {
password?: string; password?: string;
} = {}): Promise<void> { } = {}): Promise<void> {
await this.keyManager.generateKeys({ await this.keyManager.generateKeys({
keySize: 4096, keySize: this.keySize,
...options, ...options,
}); });
} }
@ -105,29 +140,25 @@ export class SecureCompletionClient {
// Try to load keys from default location (Node.js only) // Try to load keys from default location (Node.js only)
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
try { try {
const fs = require('fs').promises; const fs = require('fs').promises as { access: (p: string) => Promise<void> };
const path = require('path'); const path = require('path') as { join: (...p: string[]) => string };
const privateKeyPath = path.join('client_keys', 'private_key.pem'); const privateKeyPath = path.join('client_keys', 'private_key.pem');
const publicKeyPath = path.join('client_keys', 'public_key.pem'); const publicKeyPath = path.join('client_keys', 'public_key.pem');
// Check if keys exist
await fs.access(privateKeyPath); await fs.access(privateKeyPath);
await fs.access(publicKeyPath); await fs.access(publicKeyPath);
// Load keys
await this.loadKeys(privateKeyPath, publicKeyPath); await this.loadKeys(privateKeyPath, publicKeyPath);
console.log('Loaded existing keys from client_keys/'); console.log('Loaded existing keys from client_keys/');
return; return;
} catch (error) { } catch (_error) {
// Keys don't exist, generate new ones
console.log('No existing keys found, generating new keys...'); console.log('No existing keys found, generating new keys...');
} }
} }
// Generate new keys
await this.generateKeys({ await this.generateKeys({
saveToFile: typeof window === 'undefined', // Only save in Node.js saveToFile: typeof window === 'undefined',
keyDir: 'client_keys', keyDir: 'client_keys',
}); });
} }
@ -138,7 +169,6 @@ export class SecureCompletionClient {
async fetchServerPublicKey(): Promise<string> { async fetchServerPublicKey(): Promise<string> {
console.log("Fetching server's public key..."); console.log("Fetching server's public key...");
// Security check: Ensure HTTPS is used unless HTTP explicitly allowed
if (!this.routerUrl.startsWith('https://')) { if (!this.routerUrl.startsWith('https://')) {
if (!this.allowHttp) { if (!this.allowHttp) {
throw new SecurityError( throw new SecurityError(
@ -162,7 +192,7 @@ export class SecureCompletionClient {
// Validate it's a valid PEM key // Validate it's a valid PEM key
try { try {
await this.rsa.importPublicKey(serverPublicKey); await this.rsa.importPublicKey(serverPublicKey);
} catch (error) { } catch (_error) {
throw new Error('Server returned invalid public key format'); throw new Error('Server returned invalid public key format');
} }
@ -188,32 +218,33 @@ export class SecureCompletionClient {
} }
/** /**
* Encrypt a payload using hybrid encryption (AES-256-GCM + RSA-OAEP) * Encrypt a payload using hybrid encryption (AES-256-GCM + RSA-OAEP).
*
* The encrypted package format matches the Python server's expected format:
* - ciphertext: AES-GCM ciphertext WITHOUT auth tag
* - nonce: 12-byte GCM nonce
* - tag: 16-byte GCM auth tag (split from Web Crypto output)
* - 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...'); console.log('Encrypting payload...');
// Validate payload
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');
} }
// Ensure keys are loaded
await this.ensureKeys(); await this.ensureKeys();
// Serialize payload to JSON
const payloadJson = JSON.stringify(payload); const payloadJson = JSON.stringify(payload);
const payloadBytes = stringToArrayBuffer(payloadJson); const payloadBytes = stringToArrayBuffer(payloadJson);
// Validate payload size (prevent DoS) const MAX_PAYLOAD_SIZE = 10 * 1024 * 1024; // 10MB
const MAX_PAYLOAD_SIZE = 10 * 1024 * 1024; // 10MB limit
if (payloadBytes.byteLength > MAX_PAYLOAD_SIZE) { if (payloadBytes.byteLength > MAX_PAYLOAD_SIZE) {
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`); console.log(`Payload size: ${payloadBytes.byteLength} bytes`);
// Use secure memory context if enabled
if (this.secureMemory) { if (this.secureMemory) {
const context = new SecureByteContext(payloadBytes, true); const context = new SecureByteContext(payloadBytes, true);
return await context.use(async (protectedPayload) => { return await context.use(async (protectedPayload) => {
@ -225,41 +256,44 @@ export class SecureCompletionClient {
} }
/** /**
* Perform the actual encryption (separated for secure memory context) * Perform the actual encryption.
*
* Web Crypto AES-GCM encrypt returns ciphertext || tag (last 16 bytes).
* We split the tag out to match Python's cryptography library format
* which sends ciphertext and tag as separate fields.
*/ */
private async performEncryption(payloadBytes: ArrayBuffer): Promise<ArrayBuffer> { private async performEncryption(payloadBytes: ArrayBuffer): Promise<ArrayBuffer> {
// Generate AES key
const aesKey = await this.aes.generateKey(); const aesKey = await this.aes.generateKey();
const aesKeyBytes = await this.aes.exportKey(aesKey); const aesKeyBytes = await this.aes.exportKey(aesKey);
// Protect AES key in memory
const aesContext = new SecureByteContext(aesKeyBytes, this.secureMemory); const aesContext = new SecureByteContext(aesKeyBytes, this.secureMemory);
return await aesContext.use(async (protectedAesKey) => { return await aesContext.use(async (protectedAesKey) => {
// Encrypt payload with AES-GCM
const { ciphertext, nonce } = await this.aes.encrypt(payloadBytes, aesKey); const { ciphertext, nonce } = await this.aes.encrypt(payloadBytes, aesKey);
// Fetch server's public key // Web Crypto appends 16-byte GCM auth tag to ciphertext.
// Split it to match Python's format (separate ciphertext and tag fields).
const TAG_LENGTH = 16;
const ciphertextBytes = new Uint8Array(ciphertext);
const ciphertextOnly = ciphertextBytes.slice(0, ciphertextBytes.length - TAG_LENGTH);
const tag = ciphertextBytes.slice(ciphertextBytes.length - TAG_LENGTH);
const serverPublicKeyPem = await this.fetchServerPublicKey(); const serverPublicKeyPem = await this.fetchServerPublicKey();
const serverPublicKey = await this.rsa.importPublicKey(serverPublicKeyPem); const serverPublicKey = await this.rsa.importPublicKey(serverPublicKeyPem);
// Encrypt AES key with server's RSA public key
const encryptedAesKey = await this.rsa.encryptKey(protectedAesKey, serverPublicKey); const encryptedAesKey = await this.rsa.encryptKey(protectedAesKey, serverPublicKey);
// Create encrypted package (matching Python format)
const encryptedPackage = { const encryptedPackage = {
version: '1.0', version: '1.0',
algorithm: 'hybrid-aes256-rsa4096', algorithm: 'hybrid-aes256-rsa4096',
encrypted_payload: { encrypted_payload: {
ciphertext: arrayBufferToBase64(ciphertext), ciphertext: arrayBufferToBase64(ciphertextOnly.buffer),
nonce: arrayBufferToBase64(nonce), nonce: arrayBufferToBase64(nonce),
// Note: GCM tag is included in ciphertext in Web Crypto API tag: arrayBufferToBase64(tag.buffer),
}, },
encrypted_aes_key: arrayBufferToBase64(encryptedAesKey), encrypted_aes_key: arrayBufferToBase64(encryptedAesKey),
key_algorithm: 'RSA-OAEP-SHA256', key_algorithm: 'RSA-OAEP-SHA256',
payload_algorithm: 'AES-256-GCM', payload_algorithm: 'AES-256-GCM',
}; };
// Serialize package to JSON
const packageJson = JSON.stringify(encryptedPackage); const packageJson = JSON.stringify(encryptedPackage);
const packageBytes = stringToArrayBuffer(packageJson); const packageBytes = stringToArrayBuffer(packageJson);
@ -270,26 +304,27 @@ export class SecureCompletionClient {
} }
/** /**
* Decrypt a response from the secure endpoint * Decrypt a response from the secure endpoint.
*
* The server (Python) sends ciphertext and tag as separate fields.
* Web Crypto AES-GCM decrypt expects ciphertext || tag concatenated.
*/ */
async decryptResponse(encryptedResponse: ArrayBuffer, payloadId: string): Promise<object> { async decryptResponse(encryptedResponse: ArrayBuffer, payloadId: string): Promise<Record<string, unknown>> {
console.log('Decrypting response...'); console.log('Decrypting response...');
// Validate input
if (!encryptedResponse || encryptedResponse.byteLength === 0) { if (!encryptedResponse || encryptedResponse.byteLength === 0) {
throw new Error('Empty encrypted response'); throw new Error('Empty encrypted response');
} }
// Parse encrypted package let packageData: Record<string, unknown>;
let packageData: any;
try { try {
const packageJson = arrayBufferToString(encryptedResponse); const packageJson = arrayBufferToString(encryptedResponse);
packageData = JSON.parse(packageJson); packageData = JSON.parse(packageJson) as Record<string, unknown>;
} catch (error) { } catch (_error) {
throw new Error('Invalid encrypted package format: malformed JSON'); throw new Error('Invalid encrypted package format: malformed JSON');
} }
// Validate package structure // Validate top-level structure
const requiredFields = ['version', 'algorithm', 'encrypted_payload', 'encrypted_aes_key']; const requiredFields = ['version', 'algorithm', 'encrypted_payload', 'encrypted_aes_key'];
for (const field of requiredFields) { for (const field of requiredFields) {
if (!(field in packageData)) { if (!(field in packageData)) {
@ -297,47 +332,50 @@ export class SecureCompletionClient {
} }
} }
// Validate encrypted_payload structure const encryptedPayload = packageData.encrypted_payload as Record<string, unknown>;
const payloadRequired = ['ciphertext', 'nonce']; if (typeof encryptedPayload !== 'object' || encryptedPayload === null) {
throw new Error('Invalid encrypted_payload: must be an object');
}
// All three fields required to match Python server format
const payloadRequired = ['ciphertext', 'nonce', 'tag'];
for (const field of payloadRequired) { for (const field of payloadRequired) {
if (!(field in packageData.encrypted_payload)) { if (!(field in encryptedPayload)) {
throw new Error(`Missing field in encrypted_payload: ${field}`); throw new Error(`Missing field in encrypted_payload: ${field}`);
} }
} }
// Decrypt AES key with private key
try { try {
const encryptedAesKey = base64ToArrayBuffer(packageData.encrypted_aes_key); const encryptedAesKey = base64ToArrayBuffer(packageData.encrypted_aes_key as string);
const privateKey = this.keyManager.getPrivateKey(); const privateKey = this.keyManager.getPrivateKey();
const aesKeyBytes = await this.rsa.decryptKey(encryptedAesKey, privateKey); const aesKeyBytes = await this.rsa.decryptKey(encryptedAesKey, privateKey);
// Use secure memory context for AES key
const aesContext = new SecureByteContext(aesKeyBytes, this.secureMemory); const aesContext = new SecureByteContext(aesKeyBytes, this.secureMemory);
const response = await aesContext.use(async (protectedAesKey) => { const response = await aesContext.use(async (protectedAesKey) => {
// Import AES key
const aesKey = await this.aes.importKey(protectedAesKey); const aesKey = await this.aes.importKey(protectedAesKey);
// Decrypt payload with AES-GCM const ciphertext = base64ToArrayBuffer(encryptedPayload.ciphertext as string);
const ciphertext = base64ToArrayBuffer(packageData.encrypted_payload.ciphertext); const nonce = base64ToArrayBuffer(encryptedPayload.nonce as string);
const nonce = base64ToArrayBuffer(packageData.encrypted_payload.nonce); const tag = base64ToArrayBuffer(encryptedPayload.tag as string);
const plaintext = await this.aes.decrypt(ciphertext, nonce, aesKey); // Concatenate ciphertext + tag for Web Crypto (expects them joined)
const combined = new Uint8Array(ciphertext.byteLength + tag.byteLength);
combined.set(new Uint8Array(ciphertext), 0);
combined.set(new Uint8Array(tag), ciphertext.byteLength);
const plaintext = await this.aes.decrypt(combined.buffer, nonce, aesKey);
// Use secure memory for plaintext
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) => {
// Parse decrypted response
const responseJson = arrayBufferToString(protectedPlaintext); const responseJson = arrayBufferToString(protectedPlaintext);
return JSON.parse(responseJson); return JSON.parse(responseJson) as Record<string, unknown>;
}); });
}); });
// Add metadata // Add metadata
if (!response._metadata) { const metadata = (response._metadata as Record<string, unknown>) ?? {};
response._metadata = {};
}
response._metadata = { response._metadata = {
...response._metadata, ...metadata,
payload_id: payloadId, payload_id: payloadId,
processed_at: packageData.processed_at, processed_at: packageData.processed_at,
is_encrypted: true, is_encrypted: true,
@ -353,22 +391,32 @@ export class SecureCompletionClient {
} }
/** /**
* Send a secure chat completion request to the router * Send a secure chat completion request to the router.
*
* @param securityTier Optional routing tier: "standard" | "high" | "maximum"
*/ */
async sendSecureRequest( async sendSecureRequest(
payload: object, payload: object,
payloadId: string, payloadId: string,
apiKey?: string apiKey?: string,
): Promise<object> { securityTier?: string
): Promise<Record<string, unknown>> {
console.log('Sending secure chat completion request...'); console.log('Sending secure chat completion request...');
// Ensure keys are loaded // Validate security tier
if (securityTier !== undefined) {
if (!(VALID_SECURITY_TIERS as readonly string[]).includes(securityTier)) {
throw new Error(
`Invalid securityTier: '${securityTier}'. ` +
`Must be one of: ${VALID_SECURITY_TIERS.join(', ')}`
);
}
}
await this.ensureKeys(); await this.ensureKeys();
// Step 1: Encrypt the payload
const encryptedPayload = await this.encryptPayload(payload); const encryptedPayload = await this.encryptPayload(payload);
// Step 2: Prepare headers
const publicKeyPem = await this.keyManager.getPublicKeyPEM(); const publicKeyPem = await this.keyManager.getPublicKeyPEM();
const headers: Record<string, string> = { const headers: Record<string, string> = {
'X-Payload-ID': payloadId, 'X-Payload-ID': payloadId,
@ -376,33 +424,24 @@ export class SecureCompletionClient {
'Content-Type': 'application/octet-stream', 'Content-Type': 'application/octet-stream',
}; };
// Add Authorization header if api_key is provided
if (apiKey) { if (apiKey) {
headers['Authorization'] = `Bearer ${apiKey}`; headers['Authorization'] = `Bearer ${apiKey}`;
} }
// Step 3: Send request to router if (securityTier) {
headers['X-Security-Tier'] = securityTier;
}
const url = `${this.routerUrl}/v1/chat/secure_completion`; const url = `${this.routerUrl}/v1/chat/secure_completion`;
console.log(`Target URL: ${url}`); console.log(`Target URL: ${url}`);
let response: { statusCode: number; body: ArrayBuffer };
try { try {
const response = await this.httpClient.post(url, { response = await this.httpClient.post(url, {
headers, headers,
body: encryptedPayload, body: encryptedPayload,
timeout: 60000, 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) { } catch (error) {
if (error instanceof Error) { if (error instanceof Error) {
if (error.message === 'Request timeout') { if (error.message === 'Request timeout') {
@ -412,43 +451,83 @@ export class SecureCompletionClient {
} }
throw error; throw error;
} }
console.log(`HTTP Status: ${response.statusCode}`);
if (response.statusCode === 200) {
return await this.decryptResponse(response.body, payloadId);
}
// Map HTTP error status codes to typed errors
throw this.buildErrorFromResponse(response);
} }
/** /**
* Handle error HTTP responses * Build a typed error from an HTTP error response
*/ */
private handleErrorResponse(response: { statusCode: number; body: ArrayBuffer }): Error { private buildErrorFromResponse(response: { statusCode: number; body: ArrayBuffer }): Error {
const { let errorData: Record<string, unknown> = {};
AuthenticationError,
InvalidRequestError,
RateLimitError,
ServerError,
APIError,
} = require('../errors');
let errorData: any = {};
try { try {
const errorJson = arrayBufferToString(response.body); const errorJson = arrayBufferToString(response.body);
errorData = JSON.parse(errorJson); errorData = JSON.parse(errorJson) as Record<string, unknown>;
} catch (e) { } catch (_e) {
// Ignore JSON parse errors // Ignore JSON parse errors
} }
const detail = errorData.detail || 'Unknown error'; const detail = (errorData.detail as string | undefined) ?? 'Unknown error';
switch (response.statusCode) { switch (response.statusCode) {
case 400: case 400:
return new InvalidRequestError(`Bad request: ${detail}`, 400, errorData); return new InvalidRequestError(`Bad request: ${detail}`, 400, errorData);
case 401: case 401:
return new AuthenticationError(`Invalid API key or authentication failed: ${detail}`, 401, errorData); return new AuthenticationError(
`Invalid API key or authentication failed: ${detail}`,
401,
errorData
);
case 403:
return new ForbiddenError(
`Forbidden: ${detail}`,
403,
errorData
);
case 404: case 404:
return new APIError(`Endpoint not found: ${detail}`, 404, errorData); return new APIError(`Endpoint not found: ${detail}`, 404, errorData);
case 429: case 429:
return new RateLimitError(`Rate limit exceeded: ${detail}`, 429, errorData); return new RateLimitError(`Rate limit exceeded: ${detail}`, 429, errorData);
case 500: case 500:
return new ServerError(`Server error: ${detail}`, 500, errorData); return new ServerError(`Server error: ${detail}`, 500, errorData);
case 503:
return new ServiceUnavailableError(
`Service unavailable: ${detail}`,
503,
errorData
);
default: default:
return new APIError(`Unexpected status code: ${response.statusCode}`, response.statusCode, errorData); return new APIError(
`Unexpected status code: ${response.statusCode} ${detail}`,
response.statusCode,
errorData
);
} }
} }
/**
* Validate RSA key size (minimum 2048 bits)
*/
validateRsaKeySize(key: CryptoKey, keyType: 'private' | 'public' = 'private'): void {
const algorithm = key.algorithm as RsaHashedKeyAlgorithm;
const MIN_KEY_SIZE = 2048;
if (algorithm.modulusLength < MIN_KEY_SIZE) {
throw new Error(
`Key size ${algorithm.modulusLength} is too small. ` +
`Minimum recommended size is ${MIN_KEY_SIZE} bits.`
);
}
console.log(`Valid ${algorithm.modulusLength}-bit RSA ${keyType} key`);
}
} }
export { generateUUID };

View file

@ -69,13 +69,21 @@ export class KeyManager {
const privateKeyPem = await fs.readFile(paths.privateKeyPath, 'utf-8'); const privateKeyPem = await fs.readFile(paths.privateKeyPath, 'utf-8');
this.privateKey = await this.rsa.importPrivateKey(privateKeyPem, password); this.privateKey = await this.rsa.importPrivateKey(privateKeyPem, password);
// Validate private key size (minimum 2048 bits)
const privAlgorithm = this.privateKey.algorithm as RsaHashedKeyAlgorithm;
const MIN_KEY_SIZE = 2048;
if (privAlgorithm.modulusLength < MIN_KEY_SIZE) {
throw new Error(
`Private key size ${privAlgorithm.modulusLength} is too small. ` +
`Minimum recommended size is ${MIN_KEY_SIZE} bits.`
);
}
// Load or derive public key // Load or derive public key
if (paths.publicKeyPath) { if (paths.publicKeyPath) {
this.publicKeyPem = await fs.readFile(paths.publicKeyPath, 'utf-8'); this.publicKeyPem = await fs.readFile(paths.publicKeyPath, 'utf-8');
this.publicKey = await this.rsa.importPublicKey(this.publicKeyPem); this.publicKey = await this.rsa.importPublicKey(this.publicKeyPem);
} else { } else {
// Derive public key from private key's public component
// For now, we'll require the public key file
const publicKeyPath = path.join( const publicKeyPath = path.join(
path.dirname(paths.privateKeyPath), path.dirname(paths.privateKeyPath),
'public_key.pem' 'public_key.pem'
@ -84,6 +92,7 @@ export class KeyManager {
this.publicKey = await this.rsa.importPublicKey(this.publicKeyPem); this.publicKey = await this.rsa.importPublicKey(this.publicKeyPem);
} }
console.log(`Valid ${privAlgorithm.modulusLength}-bit RSA private key`);
console.log('Keys loaded successfully'); console.log('Keys loaded successfully');
} }

View file

@ -7,7 +7,8 @@
* - Best effort: immediate zeroing to minimize exposure time * - Best effort: immediate zeroing to minimize exposure time
*/ */
import { SecureMemory, ProtectionInfo } from '../../types/crypto'; import { SecureMemory } from './secure';
import { ProtectionInfo } from '../../types/crypto';
export class BrowserSecureMemory implements SecureMemory { export class BrowserSecureMemory implements SecureMemory {
/** /**

View file

@ -1,41 +1,105 @@
/** /**
* Node.js secure memory implementation (pure JavaScript) * Node.js secure memory implementation.
* *
* LIMITATIONS: * When the optional native addon (nomyo-native) is installed and built,
* - This is a pure JavaScript implementation without native addons * this implementation provides true OS-level memory locking (mlock) and
* - Cannot lock memory (no mlock support without native addon) * secure zeroing via explicit_bzero / SecureZeroMemory.
* - JavaScript GC controls memory lifecycle
* - Best effort: immediate zeroing to minimize exposure time
* *
* FUTURE ENHANCEMENT: * Without the native addon it falls back to pure-JS zeroing only.
* A native addon can be implemented separately to provide true mlock support. *
* See the native/ directory for an optional native implementation. * To build the native addon:
* cd native && npm install && npm run build
*/ */
import { SecureMemory, ProtectionInfo } from '../../types/crypto'; import { SecureMemory } from './secure';
import { ProtectionInfo } from '../../types/crypto';
interface NativeAddon {
mlockBuffer(buf: Buffer): boolean;
munlockBuffer(buf: Buffer): boolean;
secureZeroBuffer(buf: Buffer): void;
getPageSize(): number;
}
// Try to load the optional native addon once at module init time.
let nativeAddon: NativeAddon | null = null;
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const loaded = require('nomyo-native') as NativeAddon | null;
if (loaded && typeof loaded.mlockBuffer === 'function') {
nativeAddon = loaded;
}
} catch (_e) {
// Native addon not installed — degrade gracefully
}
export class NodeSecureMemory implements SecureMemory { export class NodeSecureMemory implements SecureMemory {
private readonly hasNative: boolean;
constructor() {
this.hasNative = nativeAddon !== null;
if (this.hasNative) {
console.log('nomyo-native addon loaded: mlock + secure-zero available');
}
}
/** /**
* Zero memory immediately * Zero memory.
* Note: This doesn't prevent JavaScript GC from moving/copying the data * With native addon: uses explicit_bzero / SecureZeroMemory (not optimized away).
* Without native addon: fills ArrayBuffer with zeros via Uint8Array (best effort).
*/ */
zeroMemory(data: ArrayBuffer): void { zeroMemory(data: ArrayBuffer): void {
if (this.hasNative && nativeAddon) {
// Buffer.from(arrayBuffer) shares the underlying memory (no copy)
const buf = Buffer.from(data);
nativeAddon.secureZeroBuffer(buf);
}
// Always also zero via JS for defence-in-depth
const view = new Uint8Array(data); const view = new Uint8Array(data);
view.fill(0); view.fill(0);
} }
/** /**
* Get protection information * Attempt to lock an ArrayBuffer in physical memory (prevent swapping).
* Best-effort: fails gracefully without CAP_IPC_LOCK / elevated privileges.
*/
lockMemory(data: ArrayBuffer): boolean {
if (!this.hasNative || !nativeAddon) return false;
const buf = Buffer.from(data);
return nativeAddon.mlockBuffer(buf);
}
/**
* Unlock previously locked memory.
*/
unlockMemory(data: ArrayBuffer): boolean {
if (!this.hasNative || !nativeAddon) return false;
const buf = Buffer.from(data);
return nativeAddon.munlockBuffer(buf);
}
/**
* Get memory protection information.
*/ */
getProtectionInfo(): ProtectionInfo { getProtectionInfo(): ProtectionInfo {
if (this.hasNative) {
return {
canLock: true,
isPlatformSecure: true,
method: 'mlock',
details:
'Node.js with nomyo-native addon: mlock + explicit_bzero/SecureZeroMemory available.',
};
}
return { return {
canLock: false, canLock: false,
isPlatformSecure: false, isPlatformSecure: false,
method: 'zero-only', method: 'zero-only',
details: details:
'Node.js environment (pure JS): memory locking not available without native addon. ' + 'Node.js (pure JS): memory locking not available without native addon. ' +
'Using immediate zeroing only. ' + 'Using immediate zeroing only. ' +
'For enhanced security, consider implementing the optional native addon (see native/ directory).', 'Build the optional native addon for mlock support: cd native && npm install && npm run build',
}; };
} }
} }

View file

@ -2,6 +2,9 @@
* Error classes matching OpenAI's error structure * Error classes matching OpenAI's error structure
*/ */
// V8-specific stack trace capture (not in standard TS types)
const captureStackTrace = (Error as unknown as { captureStackTrace?: (t: object, c: Function) => void }).captureStackTrace;
export class APIError extends Error { export class APIError extends Error {
statusCode?: number; statusCode?: number;
errorDetails?: object; errorDetails?: object;
@ -12,9 +15,8 @@ export class APIError extends Error {
this.statusCode = statusCode; this.statusCode = statusCode;
this.errorDetails = errorDetails; this.errorDetails = errorDetails;
// Maintains proper stack trace for where our error was thrown (only available on V8) if (captureStackTrace) {
if (Error.captureStackTrace) { captureStackTrace(this, this.constructor);
Error.captureStackTrace(this, this.constructor);
} }
} }
} }
@ -47,13 +49,27 @@ export class ServerError extends APIError {
} }
} }
export class ForbiddenError extends APIError {
constructor(message: string, statusCode?: number, errorDetails?: object) {
super(message, statusCode, errorDetails);
this.name = 'ForbiddenError';
}
}
export class ServiceUnavailableError extends APIError {
constructor(message: string, statusCode?: number, errorDetails?: object) {
super(message, statusCode, errorDetails);
this.name = 'ServiceUnavailableError';
}
}
export class APIConnectionError extends Error { export class APIConnectionError extends Error {
constructor(message: string) { constructor(message: string) {
super(message); super(message);
this.name = 'APIConnectionError'; this.name = 'APIConnectionError';
if (Error.captureStackTrace) { if (captureStackTrace) {
Error.captureStackTrace(this, this.constructor); captureStackTrace(this, this.constructor);
} }
} }
} }
@ -63,8 +79,8 @@ export class SecurityError extends Error {
super(message); super(message);
this.name = 'SecurityError'; this.name = 'SecurityError';
if (Error.captureStackTrace) { if (captureStackTrace) {
Error.captureStackTrace(this, this.constructor); captureStackTrace(this, this.constructor);
} }
} }
} }

View file

@ -2,21 +2,32 @@
* Cryptography-related types * Cryptography-related types
*/ */
/** Wire format for encrypted payloads exchanged with the NOMYO router */
export interface EncryptedPackage { export interface EncryptedPackage {
/** Encrypted payload data */ /** Protocol version */
encrypted_payload: string; version: string;
/** Encrypted AES key (encrypted with server's RSA public key) */ /** Algorithm identifier, e.g. "hybrid-aes256-rsa4096" */
algorithm: string;
/** AES-256-GCM encrypted payload fields */
encrypted_payload: {
/** Base64-encoded AES-GCM ciphertext (WITHOUT auth tag) */
ciphertext: string;
/** Base64-encoded 12-byte GCM nonce */
nonce: string;
/** Base64-encoded 16-byte GCM auth tag */
tag: string;
};
/** Base64-encoded AES key encrypted with RSA-OAEP */
encrypted_aes_key: string; encrypted_aes_key: string;
/** Client's public key in PEM format */ /** Key wrapping algorithm identifier */
client_public_key: string; key_algorithm: string;
/** Unique identifier for this encrypted package */ /** Payload encryption algorithm identifier */
payload_id: string; payload_algorithm: string;
/** Nonce/IV used for AES encryption (base64 encoded) */
nonce: string;
} }
export interface ProtectionInfo { export interface ProtectionInfo {

142
tests/unit/crypto.test.ts Normal file
View file

@ -0,0 +1,142 @@
/**
* Unit tests for crypto primitives (AES-256-GCM, RSA-OAEP)
*/
import { AESEncryption } from '../../src/core/crypto/encryption';
import { RSAOperations } from '../../src/core/crypto/rsa';
import { arrayBufferToBase64, base64ToArrayBuffer, stringToArrayBuffer, arrayBufferToString } from '../../src/core/crypto/utils';
describe('AESEncryption', () => {
let aes: AESEncryption;
beforeEach(() => {
aes = new AESEncryption();
});
test('generateKey produces 256-bit key', async () => {
const key = await aes.generateKey();
expect(key.type).toBe('secret');
expect((key.algorithm as AesKeyAlgorithm).length).toBe(256);
});
test('encrypt/decrypt roundtrip', async () => {
const key = await aes.generateKey();
const plaintext = stringToArrayBuffer('hello secure world');
const { ciphertext, nonce } = await aes.encrypt(plaintext, key);
expect(ciphertext.byteLength).toBeGreaterThan(0);
expect(nonce.byteLength).toBe(12);
// Web Crypto appends 16-byte tag — decrypt should succeed
const decrypted = await aes.decrypt(ciphertext, nonce, key);
expect(arrayBufferToString(decrypted)).toBe('hello secure world');
});
test('decrypt fails with wrong key', 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();
});
test('exportKey / importKey roundtrip', async () => {
const key = await aes.generateKey();
const exported = await aes.exportKey(key);
expect(exported.byteLength).toBe(32); // 256-bit
const imported = await aes.importKey(exported);
const plaintext = stringToArrayBuffer('roundtrip test');
const { ciphertext, nonce } = await aes.encrypt(plaintext, imported);
const decrypted = await aes.decrypt(ciphertext, nonce, imported);
expect(arrayBufferToString(decrypted)).toBe('roundtrip test');
});
test('GCM tag split/join compatibility with Python format', async () => {
// Simulate what SecureCompletionClient.performEncryption does:
// split the 16-byte tag from Web Crypto output, then re-join for decrypt
const key = await aes.generateKey();
const plaintext = stringToArrayBuffer('tag split test');
const { ciphertext, nonce } = await aes.encrypt(plaintext, key);
const TAG_LENGTH = 16;
const ciphertextBytes = new Uint8Array(ciphertext);
const ciphertextOnly = ciphertextBytes.slice(0, ciphertextBytes.length - TAG_LENGTH);
const tag = ciphertextBytes.slice(ciphertextBytes.length - TAG_LENGTH);
// Re-join before decrypt (as in decryptResponse)
const combined = new Uint8Array(ciphertextOnly.length + tag.length);
combined.set(ciphertextOnly, 0);
combined.set(tag, ciphertextOnly.length);
const decrypted = await aes.decrypt(combined.buffer, nonce, key);
expect(arrayBufferToString(decrypted)).toBe('tag split test');
});
});
describe('RSAOperations', () => {
let rsa: RSAOperations;
beforeEach(() => {
rsa = new RSAOperations();
});
test('generateKeyPair produces usable 2048-bit keys', async () => {
const kp = await rsa.generateKeyPair(2048);
expect(kp.publicKey.type).toBe('public');
expect(kp.privateKey.type).toBe('private');
expect((kp.publicKey.algorithm as RsaHashedKeyAlgorithm).modulusLength).toBe(2048);
}, 30000);
test('encrypt/decrypt AES key roundtrip', async () => {
const kp = 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, kp.publicKey);
const decrypted = await rsa.decryptKey(encrypted, kp.privateKey);
expect(arrayBufferToBase64(decrypted)).toBe(arrayBufferToBase64(aesKeyBytes));
}, 30000);
test('exportPublicKey / importPublicKey roundtrip', async () => {
const kp = await rsa.generateKeyPair(2048);
const pem = await rsa.exportPublicKey(kp.publicKey);
expect(pem).toContain('-----BEGIN PUBLIC KEY-----');
const imported = await rsa.importPublicKey(pem);
expect(imported.type).toBe('public');
}, 30000);
test('exportPrivateKey / importPrivateKey roundtrip (no password)', async () => {
const kp = await rsa.generateKeyPair(2048);
const pem = await rsa.exportPrivateKey(kp.privateKey);
expect(pem).toContain('-----BEGIN PRIVATE KEY-----');
const imported = await rsa.importPrivateKey(pem);
expect(imported.type).toBe('private');
}, 30000);
test('importPrivateKey fails with wrong password', async () => {
const kp = await rsa.generateKeyPair(2048);
const pem = await rsa.exportPrivateKey(kp.privateKey, 'correct-password');
await expect(rsa.importPrivateKey(pem, 'wrong-password')).rejects.toThrow();
}, 30000);
});
describe('Base64 utilities', () => {
test('arrayBufferToBase64 / base64ToArrayBuffer roundtrip', () => {
const original = new Uint8Array([0, 1, 127, 128, 255]);
const b64 = arrayBufferToBase64(original.buffer);
const restored = new Uint8Array(base64ToArrayBuffer(b64));
expect(Array.from(restored)).toEqual([0, 1, 127, 128, 255]);
});
test('stringToArrayBuffer / arrayBufferToString roundtrip', () => {
const text = 'Hello, 世界! 🔐';
const buf = stringToArrayBuffer(text);
expect(arrayBufferToString(buf)).toBe(text);
});
});

84
tests/unit/errors.test.ts Normal file
View file

@ -0,0 +1,84 @@
/**
* Unit tests for error classes
*/
import {
APIError,
AuthenticationError,
InvalidRequestError,
RateLimitError,
ServerError,
ForbiddenError,
ServiceUnavailableError,
APIConnectionError,
SecurityError,
} from '../../src/errors';
describe('Error classes', () => {
test('APIError has correct properties', () => {
const err = new APIError('test', 400, { detail: 'bad' });
expect(err.message).toBe('test');
expect(err.statusCode).toBe(400);
expect(err.errorDetails).toEqual({ detail: 'bad' });
expect(err.name).toBe('APIError');
expect(err).toBeInstanceOf(Error);
});
test('AuthenticationError extends APIError', () => {
const err = new AuthenticationError('unauthorized', 401);
expect(err.name).toBe('AuthenticationError');
expect(err.statusCode).toBe(401);
expect(err).toBeInstanceOf(APIError);
});
test('InvalidRequestError extends APIError', () => {
const err = new InvalidRequestError('bad request', 400);
expect(err.name).toBe('InvalidRequestError');
expect(err.statusCode).toBe(400);
expect(err).toBeInstanceOf(APIError);
});
test('RateLimitError extends APIError', () => {
const err = new RateLimitError('rate limit', 429);
expect(err.name).toBe('RateLimitError');
expect(err.statusCode).toBe(429);
expect(err).toBeInstanceOf(APIError);
});
test('ServerError extends APIError', () => {
const err = new ServerError('server error', 500);
expect(err.name).toBe('ServerError');
expect(err.statusCode).toBe(500);
expect(err).toBeInstanceOf(APIError);
});
test('ForbiddenError extends APIError', () => {
const err = new ForbiddenError('forbidden', 403);
expect(err.name).toBe('ForbiddenError');
expect(err.statusCode).toBe(403);
expect(err).toBeInstanceOf(APIError);
});
test('ServiceUnavailableError extends APIError', () => {
const err = new ServiceUnavailableError('unavailable', 503);
expect(err.name).toBe('ServiceUnavailableError');
expect(err.statusCode).toBe(503);
expect(err).toBeInstanceOf(APIError);
});
test('APIConnectionError is standalone', () => {
const err = new APIConnectionError('connection failed');
expect(err.name).toBe('APIConnectionError');
expect(err.message).toBe('connection failed');
expect(err).toBeInstanceOf(Error);
expect(err).not.toBeInstanceOf(APIError);
});
test('SecurityError is standalone', () => {
const err = new SecurityError('security violation');
expect(err.name).toBe('SecurityError');
expect(err.message).toBe('security violation');
expect(err).toBeInstanceOf(Error);
expect(err).not.toBeInstanceOf(APIError);
});
});

View file

@ -0,0 +1,221 @@
/**
* Unit tests for SecureCompletionClient (with mocked HTTP)
*/
import { SecureCompletionClient } from '../../src/core/SecureCompletionClient';
import {
SecurityError,
APIConnectionError,
AuthenticationError,
ForbiddenError,
ServiceUnavailableError,
RateLimitError,
} from '../../src/errors';
import { stringToArrayBuffer } from '../../src/core/crypto/utils';
// ---- helpers ---------------------------------------------------------------
function makeJsonResponse(statusCode: number, body: object): { statusCode: number; headers: Record<string, string>; body: ArrayBuffer } {
return {
statusCode,
headers: { 'content-type': 'application/json' },
body: stringToArrayBuffer(JSON.stringify(body)),
};
}
function mockHttpClient(handler: (url: string, opts: unknown) => Promise<{ statusCode: number; headers: Record<string, string>; body: ArrayBuffer }>) {
return {
post: jest.fn((url: string, opts: unknown) => handler(url, opts)),
get: jest.fn((url: string, _opts?: unknown) => handler(url, _opts)),
};
}
// ---------------------------------------------------------------------------
describe('SecureCompletionClient constructor', () => {
test('warns about HTTP if allowHttp is false', () => {
const spy = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
new SecureCompletionClient({ routerUrl: 'http://localhost:1234' });
expect(spy).toHaveBeenCalledWith(expect.stringContaining('INSECURE'));
spy.mockRestore();
});
test('does not warn when allowHttp is true', () => {
const spy = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
new SecureCompletionClient({ routerUrl: 'http://localhost:1234', allowHttp: true });
// Only the HTTP-mode log should appear, not a warning
expect(spy).not.toHaveBeenCalledWith(expect.stringContaining('Man-in-the-middle'));
spy.mockRestore();
});
test('removes trailing slash from routerUrl', () => {
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');
});
});
describe('SecureCompletionClient.fetchServerPublicKey', () => {
test('throws SecurityError over HTTP without allowHttp', async () => {
const client = new SecureCompletionClient({
routerUrl: 'http://localhost:1234',
allowHttp: false,
});
// Suppress console.warn from constructor
jest.spyOn(console, 'warn').mockImplementation(() => undefined);
await expect(client.fetchServerPublicKey()).rejects.toBeInstanceOf(SecurityError);
});
});
describe('SecureCompletionClient.sendSecureRequest — security tier validation', () => {
test('throws for invalid security tier', async () => {
const client = new SecureCompletionClient({
routerUrl: 'https://api.example.com:12434',
});
await expect(
client.sendSecureRequest({}, 'test-id', undefined, 'ultra')
).rejects.toThrow("Invalid securityTier: 'ultra'");
});
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({
routerUrl: 'https://api.example.com:12434',
});
for (const tier of ['standard', 'high', 'maximum']) {
// Should not throw a tier validation error (will throw something else)
await expect(
client.sendSecureRequest({}, 'test-id', undefined, tier)
).rejects.not.toThrow("Invalid securityTier");
}
});
});
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) {
const client = new SecureCompletionClient({
routerUrl: 'https://api.example.com:12434',
});
// Inject mocked HTTP client
const http = mockHttpClient(async (url: string) => {
if (url.includes('/pki/public_key')) {
// Should not be reached in error tests
throw new Error('unexpected pki call');
}
return makeJsonResponse(statusCode, body);
});
(client as unknown as { httpClient: typeof http }).httpClient = http;
return client;
}
test('401 → AuthenticationError', async () => {
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();
// Mock encryptPayload to skip actual encryption
jest.spyOn(client as unknown as { encryptPayload: () => Promise<ArrayBuffer> }, 'encryptPayload')
.mockResolvedValue(new ArrayBuffer(8));
await expect(
client.sendSecureRequest({ model: 'test', messages: [] }, 'id-1')
).rejects.toBeInstanceOf(AuthenticationError);
}, 30000);
test('403 → ForbiddenError', async () => {
const client = await clientWithMockedHttp(403, { detail: 'not allowed' });
await (client as unknown as { generateKeys: () => Promise<void> }).generateKeys();
jest.spyOn(client as unknown as { encryptPayload: () => Promise<ArrayBuffer> }, 'encryptPayload')
.mockResolvedValue(new ArrayBuffer(8));
await expect(
client.sendSecureRequest({ model: 'test', messages: [] }, 'id-1')
).rejects.toBeInstanceOf(ForbiddenError);
}, 30000);
test('429 → RateLimitError', async () => {
const client = await clientWithMockedHttp(429, { detail: 'too many' });
await (client as unknown as { generateKeys: () => Promise<void> }).generateKeys();
jest.spyOn(client as unknown as { encryptPayload: () => Promise<ArrayBuffer> }, 'encryptPayload')
.mockResolvedValue(new ArrayBuffer(8));
await expect(
client.sendSecureRequest({ model: 'test', messages: [] }, 'id-1')
).rejects.toBeInstanceOf(RateLimitError);
}, 30000);
test('503 → ServiceUnavailableError', async () => {
const client = await clientWithMockedHttp(503, { detail: 'down' });
await (client as unknown as { generateKeys: () => Promise<void> }).generateKeys();
jest.spyOn(client as unknown as { encryptPayload: () => Promise<ArrayBuffer> }, 'encryptPayload')
.mockResolvedValue(new ArrayBuffer(8));
await expect(
client.sendSecureRequest({ model: 'test', messages: [] }, 'id-1')
).rejects.toBeInstanceOf(ServiceUnavailableError);
}, 30000);
test('network error → APIConnectionError (not wrapping typed errors)', async () => {
const client = new SecureCompletionClient({
routerUrl: 'https://api.example.com:12434',
});
const http = mockHttpClient(async () => { throw new Error('ECONNREFUSED'); });
(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: () => Promise<ArrayBuffer> }, 'encryptPayload')
.mockResolvedValue(new ArrayBuffer(8));
await expect(
client.sendSecureRequest({ model: 'test', messages: [] }, 'id-1')
).rejects.toBeInstanceOf(APIConnectionError);
}, 30000);
});
describe('SecureCompletionClient encrypt/decrypt roundtrip', () => {
test('encryptPayload + decryptResponse roundtrip', async () => {
// Use two clients: one for "client", one to simulate "server"
const clientA = new SecureCompletionClient({ routerUrl: 'https://x', allowHttp: true });
const clientB = new SecureCompletionClient({ routerUrl: 'https://x', allowHttp: true });
await (clientA 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' }] };
// 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();
// Mock fetchServerPublicKey on clientA to return clientB's public key
jest.spyOn(clientA as unknown as { fetchServerPublicKey: () => Promise<string> }, 'fetchServerPublicKey')
.mockResolvedValue(serverPublicKeyPem);
const encrypted = await clientA.encryptPayload(payload);
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));
expect(pkg.version).toBe('1.0');
expect(pkg.algorithm).toBe('hybrid-aes256-rsa4096');
expect(pkg.encrypted_payload.ciphertext).toBeTruthy();
expect(pkg.encrypted_payload.nonce).toBeTruthy();
expect(pkg.encrypted_payload.tag).toBeTruthy(); // tag must be present
expect(pkg.encrypted_aes_key).toBeTruthy();
}, 60000);
});