fix:
- 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:
parent
0b09b9a9c3
commit
c7601b2270
17 changed files with 12600 additions and 164 deletions
|
|
@ -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}`;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue