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
26
jest.config.js
Normal file
26
jest.config.js
Normal 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
28
native/binding.gyp
Normal 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
13
native/index.js
Normal 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
19
native/package.json
Normal 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
142
native/src/mlock.cc
Normal 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
11584
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
19
package.json
19
package.json
|
|
@ -46,20 +46,25 @@
|
|||
],
|
||||
"author": "",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {},
|
||||
"engines": {
|
||||
"node": ">=14.17.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-typescript": "^11.0.0",
|
||||
"@rollup/plugin-node-resolve": "^15.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",
|
||||
"rollup": "^4.0.0",
|
||||
"typescript": "^5.3.0",
|
||||
"jest": "^29.0.0",
|
||||
"karma": "^6.4.0",
|
||||
"node-addon-api": "^8.6.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": {
|
||||
"nomyo-native": "file:./native"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
142
tests/unit/crypto.test.ts
Normal file
142
tests/unit/crypto.test.ts
Normal 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
84
tests/unit/errors.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
221
tests/unit/secure_client.test.ts
Normal file
221
tests/unit/secure_client.test.ts
Normal 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);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue