- 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

View file

@ -3,7 +3,7 @@
* 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 { ChatCompletionRequest, ChatCompletionResponse } from '../types/api';
@ -28,22 +28,23 @@ export class SecureChatCompletion {
}
/**
* Create a chat completion (matches OpenAI API)
* @param request Chat completion request
* @returns Chat completion response
* Create a chat completion (matches OpenAI API).
*
* 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> {
// Generate unique payload ID
const payloadId = `openai-compat-${this.generatePayloadId()}`;
const payloadId = generateUUID();
// Extract API key from request or use instance key
const apiKey = (request as any).api_key || this.apiKey;
// Extract NOMYO-specific fields that must not go into the encrypted payload
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 payload = { ...request };
delete (payload as any).api_key;
const apiKey = api_key ?? this.apiKey;
// Validate required fields
if (!payload.model) {
throw new Error('Missing required field: model');
}
@ -51,14 +52,14 @@ export class SecureChatCompletion {
throw new Error('Missing or invalid required field: messages');
}
// Send secure request
const response = await this.client.sendSecureRequest(
payload,
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> {
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

@ -1,18 +1,27 @@
/**
* 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 {
SecurityError,
APIConnectionError,
APIError,
AuthenticationError,
InvalidRequestError,
RateLimitError,
ServerError,
ForbiddenError,
ServiceUnavailableError,
} from '../errors';
import {
arrayBufferToBase64,
base64ToArrayBuffer,
@ -20,6 +29,30 @@ import {
arrayBufferToString,
} 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 {
private routerUrl: string;
private allowHttp: boolean;
@ -29,6 +62,7 @@ export class SecureCompletionClient {
private rsa: RSAOperations;
private httpClient: HttpClient;
private secureMemoryImpl = createSecureMemory();
private readonly keySize: 2048 | 4096;
constructor(config: ClientConfig = { routerUrl: 'https://api.nomyo.ai:12434' }) {
const {
@ -38,7 +72,8 @@ export class SecureCompletionClient {
keySize = 4096,
} = config;
this.routerUrl = routerUrl.replace(/\/$/, ''); // Remove trailing slash
this.keySize = keySize;
this.routerUrl = routerUrl.replace(/\/$/, '');
this.allowHttp = allowHttp;
this.secureMemory = secureMemory;
@ -75,7 +110,7 @@ export class SecureCompletionClient {
password?: string;
} = {}): Promise<void> {
await this.keyManager.generateKeys({
keySize: 4096,
keySize: this.keySize,
...options,
});
}
@ -105,29 +140,25 @@ export class SecureCompletionClient {
// 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 fs = require('fs').promises as { access: (p: string) => Promise<void> };
const path = require('path') as { join: (...p: string[]) => string };
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
} catch (_error) {
console.log('No existing keys found, generating new keys...');
}
}
// Generate new keys
await this.generateKeys({
saveToFile: typeof window === 'undefined', // Only save in Node.js
saveToFile: typeof window === 'undefined',
keyDir: 'client_keys',
});
}
@ -138,7 +169,6 @@ export class SecureCompletionClient {
async fetchServerPublicKey(): Promise<string> {
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(
@ -162,7 +192,7 @@ export class SecureCompletionClient {
// Validate it's a valid PEM key
try {
await this.rsa.importPublicKey(serverPublicKey);
} catch (error) {
} catch (_error) {
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> {
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
const MAX_PAYLOAD_SIZE = 10 * 1024 * 1024; // 10MB
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) => {
@ -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> {
// 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
// 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 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),
ciphertext: arrayBufferToBase64(ciphertextOnly.buffer),
nonce: arrayBufferToBase64(nonce),
// Note: GCM tag is included in ciphertext in Web Crypto API
tag: arrayBufferToBase64(tag.buffer),
},
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);
@ -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...');
// Validate input
if (!encryptedResponse || encryptedResponse.byteLength === 0) {
throw new Error('Empty encrypted response');
}
// Parse encrypted package
let packageData: any;
let packageData: Record<string, unknown>;
try {
const packageJson = arrayBufferToString(encryptedResponse);
packageData = JSON.parse(packageJson);
} catch (error) {
packageData = JSON.parse(packageJson) as Record<string, unknown>;
} catch (_error) {
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'];
for (const field of requiredFields) {
if (!(field in packageData)) {
@ -297,47 +332,50 @@ export class SecureCompletionClient {
}
}
// Validate encrypted_payload structure
const payloadRequired = ['ciphertext', 'nonce'];
const encryptedPayload = packageData.encrypted_payload as Record<string, unknown>;
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) {
if (!(field in packageData.encrypted_payload)) {
if (!(field in encryptedPayload)) {
throw new Error(`Missing field in encrypted_payload: ${field}`);
}
}
// Decrypt AES key with private key
try {
const encryptedAesKey = base64ToArrayBuffer(packageData.encrypted_aes_key);
const encryptedAesKey = base64ToArrayBuffer(packageData.encrypted_aes_key as string);
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 ciphertext = base64ToArrayBuffer(encryptedPayload.ciphertext as string);
const nonce = base64ToArrayBuffer(encryptedPayload.nonce as string);
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);
return await plaintextContext.use(async (protectedPlaintext) => {
// Parse decrypted response
const responseJson = arrayBufferToString(protectedPlaintext);
return JSON.parse(responseJson);
return JSON.parse(responseJson) as Record<string, unknown>;
});
});
// Add metadata
if (!response._metadata) {
response._metadata = {};
}
const metadata = (response._metadata as Record<string, unknown>) ?? {};
response._metadata = {
...response._metadata,
...metadata,
payload_id: payloadId,
processed_at: packageData.processed_at,
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(
payload: object,
payloadId: string,
apiKey?: string
): Promise<object> {
apiKey?: string,
securityTier?: string
): Promise<Record<string, unknown>> {
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();
// Step 1: Encrypt the payload
const encryptedPayload = await this.encryptPayload(payload);
// Step 2: Prepare headers
const publicKeyPem = await this.keyManager.getPublicKeyPEM();
const headers: Record<string, string> = {
'X-Payload-ID': payloadId,
@ -376,33 +424,24 @@ export class SecureCompletionClient {
'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
if (securityTier) {
headers['X-Security-Tier'] = securityTier;
}
const url = `${this.routerUrl}/v1/chat/secure_completion`;
console.log(`Target URL: ${url}`);
let response: { statusCode: number; body: ArrayBuffer };
try {
const response = await this.httpClient.post(url, {
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') {
@ -412,43 +451,83 @@ export class SecureCompletionClient {
}
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 {
const {
AuthenticationError,
InvalidRequestError,
RateLimitError,
ServerError,
APIError,
} = require('../errors');
let errorData: any = {};
private buildErrorFromResponse(response: { statusCode: number; body: ArrayBuffer }): Error {
let errorData: Record<string, unknown> = {};
try {
const errorJson = arrayBufferToString(response.body);
errorData = JSON.parse(errorJson);
} catch (e) {
errorData = JSON.parse(errorJson) as Record<string, unknown>;
} catch (_e) {
// Ignore JSON parse errors
}
const detail = errorData.detail || 'Unknown error';
const detail = (errorData.detail as string | undefined) ?? '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);
return new AuthenticationError(
`Invalid API key or authentication failed: ${detail}`,
401,
errorData
);
case 403:
return new ForbiddenError(
`Forbidden: ${detail}`,
403,
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);
case 503:
return new ServiceUnavailableError(
`Service unavailable: ${detail}`,
503,
errorData
);
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');
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
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'
@ -84,6 +92,7 @@ export class KeyManager {
this.publicKey = await this.rsa.importPublicKey(this.publicKeyPem);
}
console.log(`Valid ${privAlgorithm.modulusLength}-bit RSA private key`);
console.log('Keys loaded successfully');
}

View file

@ -7,7 +7,8 @@
* - 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 {
/**

View file

@ -1,41 +1,105 @@
/**
* 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.
* Node.js secure memory implementation.
*
* When the optional native addon (nomyo-native) is installed and built,
* this implementation provides true OS-level memory locking (mlock) and
* secure zeroing via explicit_bzero / SecureZeroMemory.
*
* Without the native addon it falls back to pure-JS zeroing only.
*
* 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 {
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
* Note: This doesn't prevent JavaScript GC from moving/copying the data
* Zero memory.
* 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 {
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);
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 {
if (this.hasNative) {
return {
canLock: true,
isPlatformSecure: true,
method: 'mlock',
details:
'Node.js with nomyo-native addon: mlock + explicit_bzero/SecureZeroMemory available.',
};
}
return {
canLock: false,
isPlatformSecure: false,
method: 'zero-only',
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. ' +
'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
*/
// 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 {
statusCode?: number;
errorDetails?: object;
@ -12,9 +15,8 @@ export class APIError extends Error {
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);
if (captureStackTrace) {
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 {
constructor(message: string) {
super(message);
this.name = 'APIConnectionError';
if (Error.captureStackTrace) {
Error.captureStackTrace(this, this.constructor);
if (captureStackTrace) {
captureStackTrace(this, this.constructor);
}
}
}
@ -63,8 +79,8 @@ export class SecurityError extends Error {
super(message);
this.name = 'SecurityError';
if (Error.captureStackTrace) {
Error.captureStackTrace(this, this.constructor);
if (captureStackTrace) {
captureStackTrace(this, this.constructor);
}
}
}

View file

@ -2,21 +2,32 @@
* Cryptography-related types
*/
/** Wire format for encrypted payloads exchanged with the NOMYO router */
export interface EncryptedPackage {
/** Encrypted payload data */
encrypted_payload: string;
/** Protocol version */
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;
/** Client's public key in PEM format */
client_public_key: string;
/** Key wrapping algorithm identifier */
key_algorithm: string;
/** Unique identifier for this encrypted package */
payload_id: string;
/** Nonce/IV used for AES encryption (base64 encoded) */
nonce: string;
/** Payload encryption algorithm identifier */
payload_algorithm: string;
}
export interface ProtectionInfo {