commit 8acf584d28578455c8340d202394bdec335a3182 Author: Oracle Date: Tue Apr 21 17:24:11 2026 +0200 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..480bdf5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +target/ +!.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ +.kotlin + +### IntelliJ IDEA ### +.idea/modules.xml +.idea/jarRepositories.xml +.idea/compiler.xml +.idea/libraries/ +*.iws +*.iml +*.ipr + +### Eclipse ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ + +### Mac OS ### +.DS_Store \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..30cf57e --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/encodings.xml b/.idea/encodings.xml new file mode 100644 index 0000000..aa00ffa --- /dev/null +++ b/.idea/encodings.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..4f35448 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..8306744 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/TRANSLATION_REFERENCE.md b/TRANSLATION_REFERENCE.md new file mode 100644 index 0000000..213c271 --- /dev/null +++ b/TRANSLATION_REFERENCE.md @@ -0,0 +1,478 @@ +# NOMYO Python Client — Translation Reference + +> Target: Port this library to another language. Every class, method, signature, constant, wire format, and error mapping is documented below. + +--- + +## 1. Package Layout + +| File (relative to package root) | Purpose | +|---|---| +| `nomyo/__init__.py` | Public exports, version string | +| `nomyo/nomyo.py` | `SecureChatCompletion` — OpenAI-compatible entrypoint | +| `nomyo/SecureCompletionClient.py` | Key mgmt, hybrid encryption, HTTP roundtrip, retries | +| `nomyo/SecureMemory.py` | Cross-platform memory locking + secure zeroing (optional, platform-specific) | + +**Python version:** `>= 3.10` +**Build:** `hatchling` (pyproject.toml) +**Dependencies:** `anyio`, `certifi`, `cffi`, `cryptography`, `exceptiongroup`, `h11`, `httpcore`, `httpx`, `idna`, `pycparser`, `typing_extensions` + +--- + +## 2. Public API Surface (`__all__`) + +| Export | Type | Source file | +|---|---|---| +| `SecureChatCompletion` | class | `nomyo.py` | +| `SecurityError` | exception | `SecureCompletionClient.py` | +| `APIError` | exception (base) | `SecureCompletionClient.py` | +| `AuthenticationError` | exception (401) | `SecureCompletionClient.py` | +| `InvalidRequestError` | exception (400) | `SecureCompletionClient.py` | +| `APIConnectionError` | exception (network) | `SecureCompletionClient.py` | +| `ForbiddenError` | exception (403) | `SecureCompletionClient.py` | +| `RateLimitError` | exception (429) | `SecureCompletionClient.py` | +| `ServerError` | exception (500) | `SecureCompletionClient.py` | +| `ServiceUnavailableError` | exception (503) | `SecureCompletionClient.py` | +| `get_memory_protection_info` | function | `SecureMemory.py` | +| `disable_secure_memory` | function | `SecureMemory.py` | +| `enable_secure_memory` | function | `SecureMemory.py` | +| `secure_bytearray` | context manager | `SecureMemory.py` | +| `secure_bytes` | context manager (deprecated) | `SecureMemory.py` | +| `SecureBuffer` | class | `SecureMemory.py` | + +--- + +## 3. `SecureChatCompletion` (entrypoint) + +### Constructor + +```python +SecureChatCompletion( + base_url: str = "https://api.nomyo.ai", + allow_http: bool = False, + api_key: Optional[str] = None, + secure_memory: bool = True, + key_dir: Optional[str] = None, + max_retries: int = 2 +) +``` + +| Param | Default | Description | +|---|---|---| +| `base_url` | `"https://api.nomyo.ai"` | NOMYO Router base URL. HTTPS enforced unless `allow_http=True`. | +| `allow_http` | `False` | Permit `http://` URLs (dev only). | +| `api_key` | `None` | Bearer token for auth. Can also be passed per-call via `create()`. | +| `secure_memory` | `True` | Enable memory locking/zeroing. Warns if unavailable. | +| `key_dir` | `None` | Directory to persist RSA keys. `None` = ephemeral (in-memory only). | +| `max_retries` | `2` | Retries on 429/500/502/503/504 + network errors. Exponential backoff: 1s, 2s, 4s… | + +### `create(model, messages, **kwargs) -> Dict[str, Any]` + +Async method. Returns a **dict** (not an object). Same signature as `openai.ChatCompletion.create()`. + +| Param | Type | Required | Description | +|---|---|---|---| +| `model` | `str` | yes | Model identifier, e.g. `"Qwen/Qwen3-0.6B"` | +| `messages` | `List[Dict]` | yes | OpenAI-format messages: `[{"role": "user", "content": "..."}]` | +| `temperature` | `float` | no | 0–2 | +| `max_tokens` | `int` | no | | +| `top_p` | `float` | no | | +| `stop` | `str \| List[str]` | no | | +| `presence_penalty` | `float` | no | -2.0 to 2.0 | +| `frequency_penalty` | `float` | no | -2.0 to 2.0 | +| `n` | `int` | no | Number of completions | +| `best_of` | `int` | no | | +| `seed` | `int` | no | | +| `logit_bias` | `Dict[str, float]` | no | | +| `user` | `str` | no | | +| `tools` | `List[Dict]` | no | Tool definitions passed through to llama.cpp | +| `tool_choice` | `str` | no | `"auto"`, `"none"`, or specific tool name | +| `response_format` | `Dict` | no | `{"type": "json_object"}` or `{"type": "json_schema", ...}` | +| `stream` | `bool` | no | **NOT supported.** Server rejects with HTTP 400. Always use `False`. | +| `base_url` | `str` | no | Per-call override (creates temp client internally). | +| `security_tier` | `str` | no | `"standard"`, `"high"`, or `"maximum"`. Invalid values raise `ValueError`. | +| `api_key` | `str` | no | Per-call override of instance `api_key`. | + +**Return value:** `Dict[str, Any]` — OpenAI-compatible response dict (see §6.2). + +### `acreate(model, messages, **kwargs) -> Dict[str, Any]` + +Async alias for `create()`. Identical behavior. + +--- + +## 4. `SecureCompletionClient` (low-level) + +### Constructor + +```python +SecureCompletionClient( + router_url: str = "https://api.nomyo.ai", + allow_http: bool = False, + secure_memory: bool = True, + max_retries: int = 2 +) +``` + +Same semantics as `SecureChatCompletion` constructor (maps directly to inner client). + +### Instance attributes + +| Attribute | Type | Description | +|---|---|---| +| `router_url` | `str` | Base URL (trailing slash stripped). | +| `private_key` | `rsa.RSAPrivateKey \| None` | Loaded/generated RSA private key. | +| `public_key_pem` | `str \| None` | PEM-encoded public key string. | +| `key_size` | `int` | Always `4096`. | +| `allow_http` | `bool` | HTTP allowance flag. | +| `max_retries` | `int` | Retry count. | +| `_use_secure_memory` | `bool` | Whether secure memory ops are active. | + +### `generate_keys(save_to_file: bool = False, key_dir: str = "client_keys", password: Optional[str] = None) -> None` + +Generates a 4096-bit RSA key pair (public exponent `65537`). If `save_to_file=True`: +- Creates `key_dir/` (mode 755). +- Writes `private_key.pem` with mode `0o600`. +- Writes `public_key.pem` with mode `0o644`. +- If `password` is given, private key is encrypted with `BestAvailableEncryption`. + +### `load_keys(private_key_path: str, public_key_path: Optional[str] = None, password: Optional[str] = None) -> None` + +Loads an RSA private key from disk. If `public_key_path` is omitted, derives the public key from the loaded private key. Validates key size >= 2048 bits. + +### `fetch_server_public_key() -> str` (async) + +`GET {router_url}/pki/public_key` +- Returns server PEM public key as string. +- Validates it parses as a valid PEM public key. +- Raises `SecurityError` if URL is not HTTPS and `allow_http=False`. + +### `encrypt_payload(payload: Dict[str, Any]) -> bytes` (async) + +Encrypts a dict payload using hybrid encryption. Returns raw encrypted bytes (JSON package, serialized to bytes). + +**Encryption process:** +1. Serialize payload to JSON → `bytearray`. +2. Validate size <= 10 MB. +3. Generate 256-bit AES key via `secrets.token_bytes(32)` → `bytearray`. +4. If secure memory enabled: lock both payload and AES key in memory. +5. Call `_do_encrypt()` (see below). +6. Zero/destroy payload and AES key from memory on exit. + +### `_do_encrypt(payload_bytes: bytes \| bytearray, aes_key: bytes \| bytearray) -> bytes` (async) + +Core hybrid encryption routine. **This is the wire format constructor.** + +``` +1. nonce = secrets.token_bytes(12) # 96-bit GCM nonce +2. ciphertext = AES-256-GCM_encrypt(aes_key, nonce, payload_bytes) +3. tag = GCM_tag +4. server_pubkey = await fetch_server_public_key() +5. encrypted_aes_key = RSA-OAEP-SHA256_encrypt(server_pubkey, aes_key_bytes) +6. Build JSON package (see §6.1) +7. Return json.dumps(package).encode('utf-8') +``` + +### `decrypt_response(encrypted_response: bytes, payload_id: str) -> Dict[str, Any]` (async) + +Decrypts a server response. + +**Validation chain:** +1. Parse JSON. +2. Check `version == "1.0"` — raises `ValueError` if mismatch. +3. Check `algorithm == "hybrid-aes256-rsa4096"` — raises `ValueError` if mismatch. +4. Validate `encrypted_payload` has `ciphertext`, `nonce`, `tag`. +5. Require `self.private_key` is not `None`. +6. Decrypt AES key: `RSA-OAEP-SHA256_decrypt(private_key, encrypted_aes_key)`. +7. Decrypt payload: `AES-256-GCM_decrypt(aes_key, nonce, tag, ciphertext)`. +8. Parse decrypted bytes as JSON → response dict. +9. Attach `_metadata` if not present (see §6.2). + +Any decryption failure (except JSON parse errors) raises `SecurityError("Decryption failed: integrity check or authentication failed")`. + +### `send_secure_request(payload, payload_id, api_key=None, security_tier=None) -> Dict[str, Any]` (async) + +Full request lifecycle: encrypt → HTTP POST → retry → decrypt → return. + +**Request headers:** +``` +Content-Type: application/octet-stream +X-Payload-ID: {payload_id} +X-Public-Key: {url_encoded_pem_public_key} +Authorization: Bearer {api_key} (if api_key is provided) +X-Security-Tier: {tier} (if security_tier is provided) +``` + +**POST** to `{router_url}/v1/chat/secure_completion` with encrypted payload as body. + +**Retry logic:** +- Retryable status codes: `{429, 500, 502, 503, 504}`. +- Backoff: `2^(attempt-1)` seconds (1s, 2s, 4s…). +- Total attempts: `max_retries + 1`. +- Network errors also retry. +- Non-retryable exceptions propagate immediately. + +**Status → exception mapping:** + +| Status | Exception | +|---|---| +| 200 | Return decrypted response dict | +| 400 | `InvalidRequestError` | +| 401 | `AuthenticationError` | +| 403 | `ForbiddenError` | +| 404 | `APIError` | +| 429 | `RateLimitError` | +| 500 | `ServerError` | +| 503 | `ServiceUnavailableError` | +| 502/504 | `APIError` (retryable) | +| other | `APIError` (non-retryable) | +| network error | `APIConnectionError` | + +--- + +## 5. Encryption Wire Format + +The encrypted package is a JSON object sent as `application/octet-stream`: + +```json +{ + "version": "1.0", + "algorithm": "hybrid-aes256-rsa4096", + "encrypted_payload": { + "ciphertext": "", + "nonce": "", + "tag": "" + }, + "encrypted_aes_key": "", + "key_algorithm": "RSA-OAEP-SHA256", + "payload_algorithm": "AES-256-GCM" +} +``` + +| Field | Encoding | Description | +|---|---|---| +| `version` | string | Protocol version. **Never change** — used for downgrade detection. | +| `algorithm` | string | `"hybrid-aes256-rsa4096"`. **Never change** — used for downgrade detection. | +| `encrypted_payload.ciphertext` | base64 | AES-256-GCM encrypted payload. | +| `encrypted_payload.nonce` | base64 | 12-byte GCM nonce. | +| `encrypted_payload.tag` | base64 | 16-byte GCM authentication tag. | +| `encrypted_aes_key` | base64 | RSA-OAEP-SHA256 encrypted 32-byte AES key. | +| `key_algorithm` | string | `"RSA-OAEP-SHA256"` | +| `payload_algorithm` | string | `"AES-256-GCM"` | + +--- + +## 6. Data Structures + +### 6.1 Encrypted Request Payload (before encryption) + +The dict passed to `encrypt_payload()` has this structure: + +```json +{ + "model": "Qwen/Qwen3-0.6B", + "messages": [ + {"role": "user", "content": "Hello"} + ], + "temperature": 0.7, + "...": "any other OpenAI-compatible param" +} +``` + +**Important:** `api_key` is **never** included in the encrypted payload. It is sent only as the `Authorization: Bearer` HTTP header. + +### 6.2 Response Dict (after decryption) + +```json +{ + "id": "chatcmpl-123", + "object": "chat.completion", + "created": 1234567890, + "model": "Qwen/Qwen3-0.6B", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "The capital of France is Paris.", + "tool_calls": [...], + "reasoning_content": "..." + }, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 10, + "completion_tokens": 20, + "total_tokens": 30 + }, + "_metadata": { + "payload_id": "openai-compat-abc123", + "processed_at": 1765250382, + "is_encrypted": true, + "encryption_algorithm": "hybrid-aes256-rsa4096", + "security_tier": "standard", + "memory_protection": { + "platform": "linux", + "memory_locking": true, + "secure_zeroing": true, + "core_dump_prevention": true + }, + "cuda_device": { + "available": true, + "device_hash": "sha256_hex" + } + } +} +``` + +### 6.3 Security Tier Values + +| Value | Hardware | Use case | +|---|---|---| +| `"standard"` | GPU | General secure inference | +| `"high"` | CPU/GPU | Sensitive business data | +| `"maximum"` | CPU only | PHI, classified data | + +Sent as `X-Security-Tier` HTTP header. Invalid values raise `ValueError`. + +--- + +## 7. Error Class Hierarchy + +All errors are exceptions. `APIError` subclasses carry `status_code` and `error_details`. + +``` +Exception +└── APIError (base, has message/status_code/error_details) + ├── AuthenticationError (status_code=401) + ├── InvalidRequestError (status_code=400) + ├── RateLimitError (status_code=429) + ├── ForbiddenError (status_code=403) + ├── ServerError (status_code=500) + └── ServiceUnavailableError (status_code=503) + +Exception +└── SecurityError (crypto/key failure, no status_code) + +Exception +└── APIConnectionError (network failure, no status_code) +``` + +**APIError constructor:** +```python +APIError(message: str, status_code: Optional[int] = None, error_details: Optional[Dict] = None) +``` + +--- + +## 8. SecureMemory Module + +Optional, platform-specific. Fails gracefully if unavailable (e.g. Windows on some Python builds). + +### `SecureBuffer` class + +Wraps a `bytearray` with memory locking and guaranteed zeroing on exit. + +| Attribute/Method | Type | Description | +|---|---|---| +| `data` | `bytearray` | Underlying mutable buffer | +| `address` | `int` | Memory address (via ctypes) | +| `size` | `int` | Buffer size in bytes | +| `lock() -> bool` | method | Attempt memory lock | +| `unlock() -> bool` | method | Unlock memory | +| `zero()` | method | Securely zero contents | +| `__enter__` / `__exit__` | context mgr | Auto-lock on enter, auto-zero+unlock on exit | + +### `secure_bytearray(data: bytes \| bytearray, lock: bool = True) -> SecureBuffer` (context manager) + +Recommended secure handling. Converts input to `bytearray`, locks (best-effort), yields `SecureBuffer`. Always zeros on exit, even on exception. + +### `secure_bytes(data: bytes, lock: bool = True) -> SecureBuffer` (context manager, **deprecated**) + +Same as `secure_bytearray` but accepts immutable `bytes`. Emits deprecation warning. Original bytes cannot be zeroed. + +### `get_memory_protection_info() -> Dict[str, Any]` + +Returns protection capabilities: + +```json +{ + "enabled": true, + "platform": "linux", + "protection_level": "full", + "has_memory_locking": true, + "has_secure_zeroing": true, + "supports_full_protection": true, + "page_size": 4096 +} +``` + +`protection_level` values: `"full"`, `"zeroing_only"`, `"none"`. + +### `disable_secure_memory()` / `enable_secure_memory()` + +Globally disable/re-enable secure memory operations. + +--- + +## 9. Constants + +| Constant | Value | Location | Notes | +|---|---|---|---| +| Protocol version | `"1.0"` | `SecureCompletionClient.py` | **Never change** — downgrade detection | +| Algorithm string | `"hybrid-aes256-rsa4096"` | `SecureCompletionClient.py` | **Never change** — downgrade detection | +| RSA key size | `4096` | `SecureCompletionClient.py` | Fixed | +| RSA public exponent | `65537` | `SecureCompletionClient.py` | Fixed | +| AES key size | `32` bytes (256-bit) | `SecureCompletionClient.py` | Per-request ephemeral | +| GCM nonce size | `12` bytes (96-bit) | `SecureCompletionClient.py` | Per-request via `secrets.token_bytes` | +| Max payload size | `10 * 1024 * 1024` (10 MB) | `SecureCompletionClient.py` | DoS protection | +| Default max retries | `2` | Both client classes | Exponential backoff: 1s, 2s, 4s… | +| Private key file mode | `0o600` | `SecureCompletionClient.py` | Owner read/write only | +| Public key file mode | `0o644` | `SecureCompletionClient.py` | Owner rw, group/others r | +| Min RSA key size (validation) | `2048` | `SecureCompletionClient.py` | `_validate_rsa_key` | +| Valid security tiers | `["standard", "high", "maximum"]` | `SecureCompletionClient.py` | Case-sensitive | +| Retryable status codes | `{429, 500, 502, 503, 504}` | `SecureCompletionClient.py` | | +| Package version | `"0.2.7"` | `pyproject.toml` + `__init__.py` | Bump both | + +--- + +## 10. Endpoint URLs + +| Endpoint | Method | Purpose | +|---|---|---| +| `{router_url}/pki/public_key` | GET | Fetch server RSA public key | +| `{router_url}/v1/chat/secure_completion` | POST | Encrypted chat completion | + +--- + +## 11. Key Lifecycle + +1. **First `create()` call** → `_ensure_keys()` runs (async, double-checked locking via `asyncio.Lock`). +2. If `key_dir` is set: + - Try `load_keys()` from `{key_dir}/private_key.pem` + `{key_dir}/public_key.pem`. + - If that fails → `generate_keys(save_to_file=True, key_dir=key_dir)`. +3. If `key_dir` is `None` → `generate_keys()` (ephemeral, in-memory only). +4. Keys are reused across all subsequent calls until the client is discarded. + +--- + +## 12. HTTP Client Details + +- Uses `httpx.AsyncClient` with `timeout=60.0`. +- SSL verification enabled for HTTPS URLs; disabled for `http://`. +- Request body is raw bytes (not JSON) — `Content-Type: application/octet-stream`. +- Public key is URL-encoded in the `X-Public-Key` header. + +--- + +## 13. Memory Protection Platform Matrix + +| Platform | Locking | Zeroing | +|---|---|---| +| Linux | `mlock()` via `libc.so.6` | `memset()` via `libc.so.6` | +| Windows | `VirtualLock()` via `kernel32` | `RtlZeroMemory()` via `ntdll` + Python-level fallback | +| macOS | `mlock()` via `libc.dylib` | `memset()` via `libc.dylib` | +| Other | No lock | Python-level byte-by-byte zeroing | + +mlock may fail with `EPERM` (need `CAP_IPC_LOCK` or `ulimit -l` increase) — degrades to zeroing-only gracefully. diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..2fae3b5 --- /dev/null +++ b/pom.xml @@ -0,0 +1,42 @@ + + + 4.0.0 + + ai.nomyo + nomyo4J + 1.0 + + + 25 + 25 + UTF-8 + + + + + org.projectlombok + lombok + 1.18.44 + compile + + + org.junit.jupiter + junit-jupiter-engine + 6.0.3 + test + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.0 + + + + + \ No newline at end of file diff --git a/src/main/java/ai/nomyo/Constants.java b/src/main/java/ai/nomyo/Constants.java new file mode 100644 index 0000000..831f5c0 --- /dev/null +++ b/src/main/java/ai/nomyo/Constants.java @@ -0,0 +1,199 @@ +package ai.nomyo; + + +import java.util.Set; + +/** + * Constants used throughout the NOMYO Java client library. + * + *

These values correspond to the documented constants in the Python + * reference. Protocol version and algorithm strings are immutable and + * must never be changed — they are used for downgrade detection.

+ */ +public final class Constants { + + private Constants() { + // Utility class — prevents instantiation + } + + // ── Protocol Constants ────────────────────────────────────────── + + /** + * Protocol version string. Never change — used for downgrade detection. + */ + public static final String PROTOCOL_VERSION = "1.0"; + + /** + * Hybrid encryption algorithm identifier. Never change — used for downgrade detection. + */ + public static final String HYBRID_ALGORITHM = "hybrid-aes256-rsa4096"; + + /** + * RSA-OAEP-SHA256 key wrapping algorithm identifier. + */ + public static final String KEY_WRAP_ALGORITHM = "RSA-OAEP-SHA256"; + + /** + * AES-256-GCM payload encryption algorithm identifier. + */ + public static final String PAYLOAD_ALGORITHM = "AES-256-GCM"; + + // ── Cryptographic Constants ───────────────────────────────────── + + /** + * RSA key size in bits. Fixed at 4096. + */ + public static final int RSA_KEY_SIZE = 4096; + + /** + * RSA public exponent. Fixed at 65537. + */ + public static final int RSA_PUBLIC_EXPONENT = 65537; + + /** + * AES key size in bytes (256-bit). Per-request ephemeral. + */ + public static final int AES_KEY_SIZE = 32; + + /** + * GCM nonce size in bytes (96-bit). Per-request. + */ + public static final int GCM_NONCE_SIZE = 12; + + /** + * GCM authentication tag size in bytes. + */ + public static final int GCM_TAG_SIZE = 16; + + /** + * Minimum RSA key size for validation (bits). + */ + public static final int MIN_RSA_KEY_SIZE = 2048; + + // ── Payload Limits ────────────────────────────────────────────── + + /** + * Maximum payload size in bytes (10 MB). Used for DoS protection. + */ + public static final long MAX_PAYLOAD_SIZE = 10L * 1024 * 1024; + + // ── HTTP / Retry Constants ────────────────────────────────────── + + /** + * Default HTTP request timeout in seconds. + */ + public static final int DEFAULT_TIMEOUT_SECONDS = 60; + + /** + * Default number of retries on retryable errors. + * Exponential backoff: 1s, 2s, 4s… + */ + public static final int DEFAULT_MAX_RETRIES = 2; + + /** + * Set of HTTP status codes that are eligible for retry. + */ + public static final Set RETRYABLE_STATUS_CODES = Set.of(429, 500, 502, 503, 504); + + // ── File Permission Constants ─────────────────────────────────── + + /** + * File permission for private key files (owner read/write only). + */ + public static final String PRIVATE_KEY_FILE_MODE = "rw-------"; + + /** + * File permission for public key files (owner rw, group/others r). + */ + public static final String PUBLIC_KEY_FILE_MODE = "rw-r--r--"; + + // ── Security Tier Constants ───────────────────────────────────── + + /** + * Valid security tier values. Case-sensitive. + */ + public static final Set VALID_SECURITY_TIERS = Set.of("standard", "high", "maximum"); + + /** + * Standard security tier — GPU general secure inference. + */ + public static final String SECURITY_TIER_STANDARD = "standard"; + + /** + * High security tier — CPU/GPU for sensitive business data. + */ + public static final String SECURITY_TIER_HIGH = "high"; + + /** + * Maximum security tier — CPU only for PHI/classified data. + */ + public static final String SECURITY_TIER_MAXIMUM = "maximum"; + + // ── Endpoint Paths ────────────────────────────────────────────── + + /** + * PKI public key endpoint path. + */ + public static final String PKI_PUBLIC_KEY_PATH = "/pki/public_key"; + + /** + * Secure chat completion endpoint path. + */ + public static final String SECURE_COMPLETION_PATH = "/v1/chat/secure_completion"; + + // ── HTTP Headers ──────────────────────────────────────────────── + + /** + * Content-Type for encrypted payloads. + */ + public static final String CONTENT_TYPE_OCTET_STREAM = "application/octet-stream"; + + /** + * HTTP header name for payload ID. + */ + public static final String HEADER_PAYLOAD_ID = "X-Payload-ID"; + + /** + * HTTP header name for client public key. + */ + public static final String HEADER_PUBLIC_KEY = "X-Public-Key"; + + /** + * HTTP header name for security tier. + */ + public static final String HEADER_SECURITY_TIER = "X-Security-Tier"; + + /** + * HTTP header prefix for Bearer token authorization. + */ + public static final String AUTHORIZATION_BEARER_PREFIX = "Bearer "; + + // ── Default Values ────────────────────────────────────────────── + + /** + * Default NOMYO router base URL. + */ + public static final String DEFAULT_BASE_URL = "https://api.nomyo.ai"; + + /** + * Default key directory name for persisted keys. + */ + public static final String DEFAULT_KEY_DIR = "client_keys"; + + /** + * Default private key file name. + */ + public static final String DEFAULT_PRIVATE_KEY_FILE = "private_key.pem"; + + /** + * Default public key file name. + */ + public static final String DEFAULT_PUBLIC_KEY_FILE = "public_key.pem"; + + // ── Memory Protection Constants ───────────────────────────────── + + /** + * Page size used for memory locking calculations (typically 4096 bytes). + */ + public static final int PAGE_SIZE = 4096; +} diff --git a/src/main/java/ai/nomyo/Main.java b/src/main/java/ai/nomyo/Main.java new file mode 100644 index 0000000..26a9c4d --- /dev/null +++ b/src/main/java/ai/nomyo/Main.java @@ -0,0 +1,27 @@ +package ai.nomyo; + +import ai.nomyo.errors.SecurityError; + +/** + * @author NieGestorben + * Copyright© (c) 2026, All Rights Reserved. + */ +public class Main { + + static void main() { + SecureCompletionClient secureCompletionClient = new SecureCompletionClient(); + //secureCompletionClient.generateKeys(true, "client_keys", "pokemon"); + secureCompletionClient.loadKeys("client_keys/private_key.pem", "pokemon"); + + try { + secureCompletionClient.validateRsaKey(secureCompletionClient.getPrivateKey()); + } catch (SecurityError e) { + System.out.println("RSA Key is to short!"); + return; + } + + System.out.println("RSA Key has correct length!"); + + } + +} diff --git a/src/main/java/ai/nomyo/SecureChatCompletion.java b/src/main/java/ai/nomyo/SecureChatCompletion.java new file mode 100644 index 0000000..4823961 --- /dev/null +++ b/src/main/java/ai/nomyo/SecureChatCompletion.java @@ -0,0 +1,188 @@ +package ai.nomyo; + +import ai.nomyo.errors.*; +import lombok.Getter; + +import java.util.List; +import java.util.Map; + +/** + * High-level OpenAI-compatible entrypoint for the NOMYO secure API. + * + *

This class provides a familiar API surface matching {@code openai.ChatCompletion.create()}. + * All requests are automatically encrypted using hybrid AES-256-GCM + RSA-4096 encryption + * before being sent to the NOMYO router.

+ * + *

Usage

+ *
{@code
+ * SecureChatCompletion client = new SecureChatCompletion(
+ *     "https://api.nomyo.ai",
+ *     false,
+ *     "your-api-key",
+ *     true,
+ *     "/path/to/keys",
+ *     2
+ * );
+ *
+ * Map response = client.create(
+ *     "Qwen/Qwen3-0.6B",
+ *     List.of(Map.of("role", "user", "content", "Hello, world!"))
+ * );
+ * }
+ * + *

Streaming

+ *

Streaming is not supported. The server rejects streaming requests with HTTP 400. + * Always use {@code stream=false} (the default).

+ * + *

Security Tiers

+ *

The {@code security_tier} parameter controls the hardware isolation level:

+ *
    + *
  • {@code "standard"} — GPU inference (general secure inference)
  • + *
  • {@code "high"} — CPU/GPU (sensitive business data)
  • + *
  • {@code "maximum"} — CPU only (PHI, classified data)
  • + *
+ * + *

Key Persistence

+ *

Set {@code keyDir} to a directory path to persist RSA keys to disk. + * Keys are generated on first use and reused across all calls. + * Set {@code keyDir} to {@code null} for ephemeral keys (in-memory only, lost on restart).

+ */ +@Getter +public class SecureChatCompletion { + + private final SecureCompletionClient client; + private final String apiKey; + private final String keyDir; + + /** + * Constructs a {@code SecureChatCompletion} with default settings. + * + *

Uses the default NOMYO router URL ({@code https://api.nomyo.ai}), + * HTTPS-only, secure memory enabled, ephemeral keys, and 2 retries.

+ */ + public SecureChatCompletion() { + this(Constants.DEFAULT_BASE_URL, false, null, true, null, Constants.DEFAULT_MAX_RETRIES); + } + + /** + * Constructs a {@code SecureChatCompletion} with the specified settings. + * + * @param baseUrl NOMYO Router base URL (HTTPS enforced unless {@code allowHttp} is {@code true}) + * @param allowHttp permit {@code http://} URLs (development only) + * @param apiKey Bearer token for authentication (can also be passed per-call via {@link #create}) + * @param secureMemory enable memory locking/zeroing (warns if unavailable) + * @param keyDir directory to persist RSA keys; {@code null} = ephemeral (in-memory only) + * @param maxRetries retries on 429/500/502/503/504 + network errors (exponential backoff: 1s, 2s, 4s…) + */ + public SecureChatCompletion( + String baseUrl, + boolean allowHttp, + String apiKey, + boolean secureMemory, + String keyDir, + int maxRetries + ) { + this.client = new SecureCompletionClient(baseUrl, allowHttp, secureMemory, maxRetries); + this.apiKey = apiKey; + this.keyDir = keyDir; + } + + /** + * Creates a chat completion with the specified parameters. + * + *

This is the main entrypoint, with the same signature as + * {@code openai.ChatCompletion.create()}. Returns a map (not an object) + * containing the OpenAI-compatible response.

+ * + *

Parameters

+ * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + *
ParamTypeRequiredDescription
{@code model}{@code String}yesModel identifier, e.g. "Qwen/Qwen3-0.6B"
{@code messages}{@code List}yesOpenAI-format messages
{@code temperature}{@code Double}no0–2
{@code maxTokens}{@code Integer}noMaximum tokens in response
{@code topP}{@code Double}noTop-p sampling parameter
{@code stop}{@code String | List}noStop sequences
{@code presencePenalty}{@code Double}no-2.0 to 2.0
{@code frequencyPenalty}{@code Double}no-2.0 to 2.0
{@code n}{@code Integer}noNumber of completions
{@code bestOf}{@code Integer}no
{@code seed}{@code Integer}noReproducibility seed
{@code logitBias}{@code Map}noToken bias map
{@code user}{@code String}noEnd-user identifier
{@code tools}{@code List}noTool definitions passed through to llama.cpp
{@code toolChoice}{@code String}no"auto", "none", or specific tool name
{@code responseFormat}{@code Map}no{"type": "json_object"} or {"type": "json_schema", ...}
{@code stream}{@code Boolean}noNOT supported. Server rejects with HTTP 400. Always use {@code false}.
{@code baseUrl}{@code String}noPer-call override (creates temp client internally)
{@code securityTier}{@code String}no"standard", "high", or "maximum". Invalid values raise {@code ValueError}.
{@code apiKey}{@code String}noPer-call override of instance {@code apiKey}.
+ * + * @param model model identifier (required) + * @param messages OpenAI-format message list (required) + * @param kwargs additional OpenAI-compatible parameters + * @return OpenAI-compatible response map (see §6.2 of reference docs) + * @throws SecurityError if encryption/decryption fails + * @throws APIConnectionError if a network error occurs + * @throws InvalidRequestError if the API returns 400 + * @throws AuthenticationError if the API returns 401 + * @throws ForbiddenError if the API returns 403 + * @throws RateLimitError if the API returns 429 + * @throws ServerError if the API returns 500 + * @throws ServiceUnavailableError if the API returns 503 + * @throws APIError for other errors + */ + public Map create(String model, List> messages, Map kwargs) { + // Build payload from model, messages, and kwargs + // Validate stream is false + // Validate securityTier if provided + // Use per-call api_key override if provided, else instance apiKey + // Create temp client if baseUrl override provided + // Send secure request + // Return decrypted response map + return null; + } + + /** + * Creates a chat completion with the specified model and messages. + * Convenience variant with no additional parameters. + * + * @param model model identifier (required) + * @param messages OpenAI-format message list (required) + * @return OpenAI-compatible response map + */ + public Map create(String model, List> messages) { + return create(model, messages, null); + } + + /** + * Async alias for {@link #create(String, List, Map)}. Identical behavior. + * + * @param model model identifier (required) + * @param messages OpenAI-format message list (required) + * @param kwargs additional OpenAI-compatible parameters + * @return OpenAI-compatible response map + */ + public Map acreate(String model, List> messages, Map kwargs) { + return create(model, messages, kwargs); + } + + /** + * Async alias for {@link #create(String, List)}. Identical behavior. + * + * @param model model identifier (required) + * @param messages OpenAI-format message list (required) + * @return OpenAI-compatible response map + */ + public Map acreate(String model, List> messages) { + return create(model, messages); + } + + /** + * Closes the client and releases any resources. + */ + public void close() { + client.close(); + } +} diff --git a/src/main/java/ai/nomyo/SecureCompletionClient.java b/src/main/java/ai/nomyo/SecureCompletionClient.java new file mode 100644 index 0000000..4eee37e --- /dev/null +++ b/src/main/java/ai/nomyo/SecureCompletionClient.java @@ -0,0 +1,513 @@ +package ai.nomyo; + +import ai.nomyo.errors.*; +import ai.nomyo.util.PEMConverter; +import ai.nomyo.util.Pass2Key; +import lombok.Getter; + +import javax.crypto.*; +import java.io.*; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermission; +import java.nio.file.attribute.PosixFilePermissions; +import java.security.*; +import java.security.spec.PKCS8EncodedKeySpec; +import java.security.spec.RSAKeyGenParameterSpec; +import java.io.UnsupportedEncodingException; +import java.util.Arrays; +import java.util.Map; +import java.util.Scanner; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.locks.ReentrantLock; + +/** + * Low-level secure completion client for the NOMYO API. + * + *

This class handles key management, hybrid encryption, HTTP communication + * with retry logic, and response decryption. It is the core of the NOMYO + * Java client and is used internally by {@link SecureChatCompletion}.

+ * + *

Encryption Wire Format

+ *

Encrypted payloads use hybrid encryption (AES-256-GCM + RSA-4096-OAEP-SHA256):

+ *
    + *
  • A per-request 256-bit AES key encrypts the payload via AES-256-GCM
  • + *
  • The AES key is encrypted via RSA-4096-OAEP-SHA256 using the server's public key
  • + *
  • The result is a JSON package with base64-encoded ciphertext, nonce, tag, and encrypted AES key
  • + *
+ * + *

Key Lifecycle

+ *

Client RSA keys are generated on first use (if not loaded from disk) and + * reused across all subsequent calls until the client is discarded. Keys can + * be persisted to disk via {@link #generateKeys(boolean, String, String)} or + * loaded from disk via {@link #loadKeys(String, String, String)}.

+ */ +public class SecureCompletionClient { + + // ── Instance Attributes ───────────────────────────────────────── + + /** + * Base URL of the NOMYO router (trailing slash stripped). + */ + @Getter + private final String routerUrl; + + /** + * Whether HTTP (non-HTTPS) URLs are permitted. + */ + @Getter + private final boolean allowHttp; + + /** + * RSA key size in bits. Always {@link Constants#RSA_KEY_SIZE}. + */ + @Getter + private final int keySize; + + /** + * Maximum number of retries for retryable errors. + */ + @Getter + private final int maxRetries; + + /** + * Whether secure memory operations are active. + */ + @Getter + private final boolean useSecureMemory; + + /** + * Lock for double-checked key initialization. + */ + private final ReentrantLock keyInitLock = new ReentrantLock(); + + /** + * RSA private key, or {@code null} if not yet loaded/generated. + */ + @Getter + private PrivateKey privateKey; + + // ── Internal State ────────────────────────────────────────────── + + /** + * PEM-encoded public key string, or {@code null} if not yet loaded/generated. + */ + @Getter + private String publicPemKey; + + /** + * Whether keys have been initialized. + */ + private volatile boolean keysInitialized = false; + + /** + * Constructs a {@code SecureCompletionClient} with default settings. + */ + public SecureCompletionClient() { + this(Constants.DEFAULT_BASE_URL, false, true, Constants.DEFAULT_MAX_RETRIES); + } + + /** + * Constructs a {@code SecureCompletionClient} with the specified settings. + * + * @param routerUrl the NOMYO router base URL + * @param allowHttp whether to permit HTTP (non-HTTPS) URLs + * @param secureMemory whether to enable memory locking/zeroing + * @param maxRetries number of retries on retryable errors + */ + public SecureCompletionClient(String routerUrl, boolean allowHttp, boolean secureMemory, int maxRetries) { + this.routerUrl = routerUrl != null ? routerUrl.replaceAll("/+$", "") : Constants.DEFAULT_BASE_URL; + this.allowHttp = allowHttp; + this.useSecureMemory = secureMemory; + this.keySize = Constants.RSA_KEY_SIZE; + this.maxRetries = maxRetries; + } + + // ── Key Management ────────────────────────────────────────────── + + private static String getEncryptedPrivateKeyFromFile(String privateKeyPath) { + File myObj = new File(privateKeyPath); + + StringBuilder builder = new StringBuilder(); + + try (Scanner myReader = new Scanner(myObj)) { + while (myReader.hasNextLine()) { + builder.append(myReader.nextLine()); + } + } catch (FileNotFoundException e) { + throw new RuntimeException("Tried to load private key from disk but no file found" + e.getMessage()); + } + + return builder.toString(); + } + + /** + * Generates a new 4096-bit RSA key pair. + * + *

The public exponent is fixed at 65537. The generated key pair + * is stored in memory. Use {@code saveToDir} to persist to disk.

+ * + * @param saveToFile whether to save the keys to disk + * @param keyDir directory to save keys (ignored if {@code saveToFile} is {@code false}) + * @param password optional password to encrypt the private key file + */ + public void generateKeys(boolean saveToFile, String keyDir, String password) { + try { + KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); + generator.initialize(new RSAKeyGenParameterSpec(Constants.RSA_KEY_SIZE, BigInteger.valueOf(Constants.RSA_PUBLIC_EXPONENT))); + + KeyPair pair = generator.generateKeyPair(); + + String privatePem = PEMConverter.toPEM(pair.getPrivate().getEncoded(), true); + String publicPem = PEMConverter.toPEM(pair.getPublic().getEncoded(), false); + + if (saveToFile) { + File keyFolder = new File(keyDir); + if (!keyFolder.exists() && !keyFolder.mkdirs()) { + throw new IOException("Failed to create key directory: " + keyDir); + } + + Path privateKeyPath = Path.of(keyDir, Constants.DEFAULT_PRIVATE_KEY_FILE); + if (!Files.exists(privateKeyPath)) { + Set filePermissions = PosixFilePermissions.fromString(Constants.PRIVATE_KEY_FILE_MODE); + Files.createFile(privateKeyPath, PosixFilePermissions.asFileAttribute(filePermissions)); + + try (FileWriter fileWriter = new FileWriter(privateKeyPath.toFile())) { + if (password == null || password.isEmpty()) { + System.out.println("WARNING: Saving keys in plaintext!"); + } else { + try { + privatePem = Pass2Key.encrypt("AES/GCM/NoPadding", privatePem, password); + } catch (NoSuchPaddingException | IllegalBlockSizeException | BadPaddingException | + InvalidKeyException e) { + throw new RuntimeException(e); + } + + } + + fileWriter.write(privatePem); + fileWriter.flush(); + } + } + + Path publicKeyPath = Path.of(keyDir, Constants.DEFAULT_PUBLIC_KEY_FILE); + if (!Files.exists(publicKeyPath)) { + Set publicPermissions = PosixFilePermissions.fromString(Constants.PUBLIC_KEY_FILE_MODE); + Files.createFile(publicKeyPath, PosixFilePermissions.asFileAttribute(publicPermissions)); + + try (FileWriter fileWriter = new FileWriter(publicKeyPath.toFile())) { + fileWriter.write(publicPem); + fileWriter.flush(); + } + } + } + + this.privateKey = pair.getPrivate(); + this.publicPemKey = publicPem; + + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("RSA not available: " + e.getMessage(), e); + } catch (InvalidAlgorithmParameterException e) { + throw new RuntimeException(e); + } catch (IOException e) { + throw new RuntimeException("Failed to save keys: " + e.getMessage(), e); + } + } + + /** + * Generates a new 4096-bit RSA key pair and saves to the default directory. + * + * @param saveToFile whether to save the keys to disk + */ + public void generateKeys(boolean saveToFile) { + generateKeys(saveToFile, Constants.DEFAULT_KEY_DIR, null); + } + + /** + * Loads an RSA private key from disk. + * + *

If {@code publicPemKeyPath} is {@code null}, the public key is + * derived from the loaded private key. Validates that the key size + * is at least {@link Constants#MIN_RSA_KEY_SIZE} bits.

+ * + * @param privateKeyPath path to the private key PEM file + * @param publicPemKeyPath optional path to the public key PEM file + * @param password optional password for the encrypted private key + */ + public void loadKeys(String privateKeyPath, String publicPemKeyPath, String password) { + if (password != null && !password.isEmpty()) { + String cipherText = getEncryptedPrivateKeyFromFile(privateKeyPath); + + try { + cipherText = Pass2Key.decrypt("AES/GCM/NoPadding", cipherText, password); + } catch (NoSuchPaddingException | NoSuchAlgorithmException + | BadPaddingException | IllegalBlockSizeException | InvalidAlgorithmParameterException | + InvalidKeyException e) { + System.out.println("Wrong password!"); + } + + try { + this.privateKey = Pass2Key.convertStringToPrivateKey(cipherText); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + } + + /** + * Loads an RSA private key from disk, deriving the public key. + * + * @param privateKeyPath path to the private key PEM file + * @param password optional password for the encrypted private key + */ + public void loadKeys(String privateKeyPath, String password) { + loadKeys(privateKeyPath, null, password); + } + + // ── Server Key Fetching ───────────────────────────────────────── + + /** + * Fetches the server's RSA public key from the PKI endpoint. + * + *

Performs a GET request to {@code {routerUrl}/pki/public_key} + * and returns the server PEM public key as a string. Validates that + * the response parses as a valid PEM public key.

+ * + * @return the server's PEM-encoded public key string + * @throws SecurityError if the URL is not HTTPS and {@code allowHttp} is {@code false}, + * or if the response does not contain a valid PEM public key + */ + public CompletableFuture fetchServerPublicKey() { + throw new UnsupportedOperationException("Not yet implemented"); + } + + // ── Encryption ────────────────────────────────────────────────── + + /** + * Encrypts a payload dict using hybrid encryption. + * + *

Serializes the payload to JSON, then encrypts it using: + *

    + *
  1. A per-request 256-bit AES key (AES-256-GCM)
  2. + *
  3. RSA-OAEP-SHA256 wrapping of the AES key with the server's public key
  4. + *
+ *

+ * + * @param payload the payload to encrypt (OpenAI-compatible chat parameters) + * @return raw encrypted bytes (JSON package serialized to bytes) + * @throws SecurityError if encryption fails or keys are not loaded + */ + public CompletableFuture encryptPayload(Map payload) { + throw new UnsupportedOperationException("Not yet implemented"); + } + + /** + * Core hybrid encryption routine. + */ + public CompletableFuture doEncrypt(byte[] payloadBytes, byte[] aesKey) { + throw new UnsupportedOperationException("Not yet implemented"); + } + + // ── Decryption ────────────────────────────────────────────────── + + /** + * Decrypts a server response. + */ + public CompletableFuture> decryptResponse(byte[] encryptedResponse, String payloadId) { + throw new UnsupportedOperationException("Not yet implemented"); + } + + // ── Secure Request Lifecycle ──────────────────────────────────── + + /** + * Full request lifecycle: encrypt → HTTP POST → retry → decrypt → return. + * + *

Request Headers

+ *
+     * Content-Type: application/octet-stream
+     * X-Payload-ID: {payloadId}
+     * X-Public-Key: {urlEncodedPublicPemKey}
+     * Authorization: Bearer {apiKey}            (if apiKey is provided)
+     * X-Security-Tier: {tier}                   (if securityTier is provided)
+     * 
+ * + *

POST

+ * {@code {routerUrl}/v1/chat/secure_completion} with encrypted payload as body. + * + *

Retry Logic

+ *
    + *
  • Retryable status codes: {@code {429, 500, 502, 503, 504}}
  • + *
  • Backoff: {@code 2^(attempt-1)} seconds (1s, 2s, 4s…)
  • + *
  • Total attempts: {@code maxRetries + 1}
  • + *
  • Network errors also retry
  • + *
  • Non-retryable exceptions propagate immediately
  • + *
+ * + *

Status → Exception Mapping

+ * + * + * + * + * + * + * + * + * + * + * + * + * + *
StatusResult
200Return decrypted response map
400{@code InvalidRequestError}
401{@code AuthenticationError}
403{@code ForbiddenError}
404{@code APIError}
429{@code RateLimitError}
500{@code ServerError}
503{@code ServiceUnavailableError}
502/504{@code APIError} (retryable)
other{@code APIError} (non-retryable)
network error{@code APIConnectionError}
+ * + * @param payload the payload to send (OpenAI-compatible chat parameters) + * @param payloadId unique payload identifier + * @param apiKey optional API key for authentication + * @param securityTier optional security tier ({@code "standard"}, {@code "high"}, or {@code "maximum"}) + * @return the decrypted response map + * @throws SecurityError if encryption/decryption fails + * @throws APIConnectionError if a network error occurs + * @throws InvalidRequestError if the API returns 400 + * @throws AuthenticationError if the API returns 401 + * @throws ForbiddenError if the API returns 403 + * @throws RateLimitError if the API returns 429 + * @throws ServerError if the API returns 500 + * @throws ServiceUnavailableError if the API returns 503 + * @throws APIError for other non-retryable errors + */ + public CompletableFuture> sendSecureRequest( + Map payload, + String payloadId, + String apiKey, + String securityTier + ) { + throw new UnsupportedOperationException("Not yet implemented"); + } + + /** + * Sends a secure request without a security tier. + * + * @param payload the payload to send + * @param payloadId unique payload identifier + * @param apiKey optional API key for authentication + * @return the decrypted response map + */ + public CompletableFuture> sendSecureRequest( + Map payload, + String payloadId, + String apiKey + ) { + return sendSecureRequest(payload, payloadId, apiKey, null); + } + + /** + * Sends a secure request with no API key or security tier. + * + * @param payload the payload to send + * @param payloadId unique payload identifier + * @return the decrypted response map + */ + public CompletableFuture> sendSecureRequest( + Map payload, + String payloadId + ) { + return sendSecureRequest(payload, payloadId, null, null); + } + + // ── Key Initialization ────────────────────────────────────────── + + /** + * Ensures RSA keys are loaded or generated. + * + *

Uses double-checked locking via {@link ReentrantLock} to ensure + * thread-safe initialization. If {@code keyDir} is set, attempts to + * load keys from disk first; if that fails, generates new keys.

+ * + * @param keyDir directory to persist keys, or {@code null} for ephemeral + */ + public void ensureKeys(String keyDir) { + if (keysInitialized) return; + keyInitLock.lock(); + try { + if (keysInitialized) return; + // TODO: implement key loading/generation + keysInitialized = true; + } finally { + keyInitLock.unlock(); + } + } + + // ── Key Validation ────────────────────────────────────────────── + + /** + * Validates that an RSA key meets the minimum size requirement. + * + * @param key the RSA key to validate + * @throws SecurityError if the key size is less than {@link Constants#MIN_RSA_KEY_SIZE} bits + */ + public void validateRsaKey(PrivateKey key) throws SecurityError { + if (key == null) { + throw new SecurityError("RSA key is null"); + } + int keySize = key.getEncoded() != null ? key.getEncoded().length * 8 : 0; + + System.out.println("Keysize: " + keySize); + + if (keySize < Constants.MIN_RSA_KEY_SIZE) { + throw new SecurityError( + "RSA key size " + keySize + " bits is below minimum " + Constants.MIN_RSA_KEY_SIZE + " bits" + ); + } + } + + // ── HTTP Status → Exception Mapping ───────────────────────────── + + /** + * Maps an HTTP status code to the appropriate exception. + */ + public Exception mapHttpStatus(int statusCode, String responseBody) { + switch (statusCode) { + case 200: + return null; + case 400: + return new InvalidRequestError("Invalid request: " + (responseBody != null ? responseBody : "no body")); + case 401: + return new AuthenticationError("Authentication failed: " + (responseBody != null ? responseBody : "no body")); + case 403: + return new ForbiddenError("Access forbidden: " + (responseBody != null ? responseBody : "no body")); + case 404: + return new APIError("Not found: " + (responseBody != null ? responseBody : "no body")); + case 429: + return new RateLimitError("Rate limit exceeded: " + (responseBody != null ? responseBody : "no body")); + case 500: + return new ServerError("Internal server error: " + (responseBody != null ? responseBody : "no body")); + case 503: + return new ServiceUnavailableError("Service unavailable: " + (responseBody != null ? responseBody : "no body")); + case 502: + case 504: + return new APIError("Gateway error: " + (responseBody != null ? responseBody : "no body")); + default: + return new APIError("Unexpected status " + statusCode + ": " + (responseBody != null ? responseBody : "no body")); + } + } + + // ── URL Encoding ──────────────────────────────────────────────── + + /** + * URL-encodes a public key PEM string for use in the {@code X-Public-Key} header. + */ + public String urlEncodePublicKey(String pemKey) { + return java.net.URLEncoder.encode(pemKey, StandardCharsets.UTF_8); + } + + // ── Getters ───────────────────────────────────────────────────── + + /** + * Closes the client and releases any resources. + */ + public void close() { + throw new UnsupportedOperationException("Not yet implemented"); + } +} diff --git a/src/main/java/ai/nomyo/SecureMemory.java b/src/main/java/ai/nomyo/SecureMemory.java new file mode 100644 index 0000000..e16d7fc --- /dev/null +++ b/src/main/java/ai/nomyo/SecureMemory.java @@ -0,0 +1,237 @@ +package ai.nomyo; + +import lombok.Getter; +import lombok.Setter; + +import java.lang.foreign.Arena; +import java.lang.foreign.MemorySegment; +import java.util.Map; + +/** + * Cross-platform memory locking and secure zeroing utilities. + * + *

This module provides optional memory protection for sensitive + * cryptographic buffers. It fails gracefully if memory locking is + * unavailable on the current platform (e.g., Windows on some JVM + * configurations).

+ * + *

Protection Levels

+ *
    + *
  • "full" — Memory locking and secure zeroing both available
  • + *
  • "zeroing_only" — Only secure zeroing available
  • + *
  • "none" — No memory protection available
  • + *
+ * + *

Usage

+ *
{@code
+ * try (SecureBuffer buf = SecureMemory.secureBytearray(sensitiveData)) {
+ *     // buf is locked and ready to use
+ *     process(buf.getData());
+ * }
+ * // buf is automatically zeroed and unlocked on exit
+ * }
+ */ +public final class SecureMemory { + + @Getter + @Setter + private static volatile boolean secureMemoryEnabled = true; + + @Getter + private static final boolean HAS_MEMORY_LOCKING; + @Getter + private static final boolean HAS_SECURE_ZEROING; + + static { + boolean locking = false; + boolean zeroing = false; + try { + locking = initMemoryLocking(); + zeroing = true; // Secure zeroing is always available at the JVM level + } catch (Throwable t) { + // Degrade gracefully + } + + HAS_MEMORY_LOCKING = locking; + HAS_SECURE_ZEROING = zeroing; + } + + private static boolean initMemoryLocking() { + // FFM doesn't support memory locking at this point in time + // TODO: Bypass this with native libraries + return false; + } + + /** + * Wraps a byte array with memory locking and guaranteed zeroing on exit. + * + *

Implements {@link AutoCloseable} for use with try-with-resources. + * The buffer is automatically zeroed and unlocked when closed, even + * if an exception occurs.

+ */ + public static class SecureBuffer implements AutoCloseable { + + private final Arena arena; + + @Getter + private final MemorySegment data; + + @Getter + private final long size; + + @Getter + private final long address; + private boolean locked; + private boolean closed; + + /** + * Creates a new SecureBuffer wrapping the given data. + * + * @param data the byte array to wrap + * @param lock whether to attempt memory locking + */ + public SecureBuffer(byte[] data, boolean lock) { + this.arena = Arena.ofConfined(); + this.data = data != null ? this.arena.allocate(data.length) : MemorySegment.NULL; + + if (data != null) { + this.data.asByteBuffer().put(data); + } + + this.size = this.data.byteSize(); + this.address = this.data.address(); + + this.locked = false; + this.closed = false; + + if (lock && SecureMemory.secureMemoryEnabled) { + this.locked = lock(); + } + } + + /** + * Attempts to lock the buffer in memory, preventing swapping to disk. + * + * @return {@code true} if locking succeeded, {@code false} otherwise + */ + public boolean lock() { + //data = data.asReadOnly(); + return false; + } + + /** + * Unlocks the buffer, allowing it to be swapped to disk. + * + * @return {@code true} if unlocking succeeded, {@code false} otherwise + */ + public boolean unlock() { + if (!locked) return false; + locked = false; + return false; + } + + /** + * Securely zeros the buffer contents. + */ + public void zero() { + if (data != null) { + data.fill((byte) 0); + } + } + + @Override + public void close() { + if (closed) return; + + zero(); + unlock(); + + arena.close(); + closed = true; + } + } + + /** + * Creates a SecureBuffer for the given data with memory locking. + * + *

This is the recommended way to handle sensitive data. The returned + * buffer should be used within a try-with-resources block to ensure + * secure zeroing on exit.

+ * + * @param data the sensitive data bytes + * @param lock whether to attempt memory locking + * @return a new SecureBuffer + */ + public static SecureBuffer secureByteArray(byte[] data, boolean lock) { + return new SecureBuffer(data, lock); + } + + /** + * Creates a SecureBuffer for the given data with memory locking. + * Convenience variant that always attempts locking. + * + * @param data the sensitive data bytes + * @return a new SecureBuffer + */ + public static SecureBuffer secureByteArray(byte[] data) { + return secureByteArray(data, true); + } + + /** + * Creates a SecureBuffer for the given data with memory locking. + * + *

Deprecated: Use {@link #secureByteArray(byte[])} instead. + * This method exists for compatibility with the Python reference.

+ * + * @param data the sensitive data bytes + * @param lock whether to attempt memory locking + * @return a new SecureBuffer + * @deprecated Use {@link #secureByteArray(byte[])} instead + */ + @Deprecated + public static SecureBuffer secureBytes(byte[] data, boolean lock) { + return new SecureBuffer(data, lock); + } + + /** + * Creates a SecureBuffer for the given data with memory locking. + * + *

Deprecated: Use {@link #secureByteArray(byte[])} instead. + * This method exists for compatibility with the Python reference.

+ * + * @param data the sensitive data bytes + * @return a new SecureBuffer + * @deprecated Use {@link #secureByteArray(byte[])} instead + */ + @Deprecated + public static SecureBuffer secureBytes(byte[] data) { + return secureBytes(data, true); + } + + /** + * Returns information about the current memory protection capabilities. + * + * @return a map of protection capabilities + */ + public static Map getMemoryProtectionInfo() { + String protectionLevel; + if (HAS_MEMORY_LOCKING && HAS_SECURE_ZEROING) { + protectionLevel = "full"; + } else if (HAS_SECURE_ZEROING) { + protectionLevel = "zeroing_only"; + } else { + protectionLevel = "none"; + } + + boolean supportsFull = HAS_MEMORY_LOCKING && HAS_SECURE_ZEROING && secureMemoryEnabled; + + return Map.of( + "enabled", secureMemoryEnabled, + "protection_level", protectionLevel, + "has_memory_locking", HAS_MEMORY_LOCKING, + "has_secure_zeroing", HAS_SECURE_ZEROING, + "supports_full_protection", supportsFull, + "page_size", Constants.PAGE_SIZE + ); + } +} diff --git a/src/main/java/ai/nomyo/errors/APIConnectionError.java b/src/main/java/ai/nomyo/errors/APIConnectionError.java new file mode 100644 index 0000000..f1a6d02 --- /dev/null +++ b/src/main/java/ai/nomyo/errors/APIConnectionError.java @@ -0,0 +1,41 @@ +package ai.nomyo.errors; + +/** + * Exception thrown when a network failure occurs during communication + * with the NOMYO API server. + * + *

This includes connection timeouts, DNS resolution failures, + * TLS handshake failures, and other network-level errors. Unlike + * {@link APIError}, this exception does not carry an HTTP status code.

+ */ +public class APIConnectionError extends Exception { + + /** + * Constructs an {@code APIConnectionError} with the specified detail message. + * + * @param message the detail message + */ + public APIConnectionError(String message) { + super(message); + } + + /** + * Constructs an {@code APIConnectionError} with the specified detail message + * and cause. + * + * @param message the detail message + * @param cause the cause of this exception, or {@code null} + */ + public APIConnectionError(String message, Throwable cause) { + super(message, cause); + } + + /** + * Constructs an {@code APIConnectionError} with the specified cause. + * + * @param cause the cause of this exception + */ + public APIConnectionError(Throwable cause) { + super(cause); + } +} diff --git a/src/main/java/ai/nomyo/errors/APIError.java b/src/main/java/ai/nomyo/errors/APIError.java new file mode 100644 index 0000000..df4dc36 --- /dev/null +++ b/src/main/java/ai/nomyo/errors/APIError.java @@ -0,0 +1,59 @@ +package ai.nomyo.errors; + +import java.util.Collections; +import java.util.Map; + +/** + * Base exception for all NOMYO API errors. + * + *

All API error subclasses carry a {@code status_code} and optional + * {@code error_details} from the server response.

+ */ +public class APIError extends Exception { + + private final Integer statusCode; + private final Map errorDetails; + + /** + * Constructs an {@code APIError} with the specified detail message, + * status code, and error details. + * + * @param message the detail message + * @param statusCode the HTTP status code, or {@code null} if not applicable + * @param errorDetails additional error details from the server, or {@code null} + */ + public APIError(String message, Integer statusCode, Map errorDetails) { + super(message); + this.statusCode = statusCode; + this.errorDetails = errorDetails != null + ? Collections.unmodifiableMap(errorDetails) + : Collections.emptyMap(); + } + + /** + * Constructs an {@code APIError} with the specified detail message. + * + * @param message the detail message + */ + public APIError(String message) { + this(message, null, null); + } + + /** + * Returns the HTTP status code associated with this error, or {@code null}. + * + * @return the status code, or {@code null} + */ + public Integer getStatusCode() { + return statusCode; + } + + /** + * Returns an unmodifiable map of additional error details from the server. + * + * @return the error details map (never {@code null}) + */ + public Map getErrorDetails() { + return errorDetails; + } +} diff --git a/src/main/java/ai/nomyo/errors/AuthenticationError.java b/src/main/java/ai/nomyo/errors/AuthenticationError.java new file mode 100644 index 0000000..a219789 --- /dev/null +++ b/src/main/java/ai/nomyo/errors/AuthenticationError.java @@ -0,0 +1,41 @@ +package ai.nomyo.errors; + +import java.util.Map; + +/** + * Exception thrown when the API returns a 401 (Unauthorized) status. + */ +public class AuthenticationError extends APIError { + + /** + * Constructs an {@code AuthenticationError} with the specified detail message, + * status code, and error details. + * + * @param message the detail message + * @param statusCode the HTTP status code (must be 401) + * @param errorDetails additional error details from the server, or {@code null} + */ + public AuthenticationError(String message, Integer statusCode, Map errorDetails) { + super(message, statusCode, errorDetails); + } + + /** + * Constructs an {@code AuthenticationError} with the specified detail message. + * + * @param message the detail message + */ + public AuthenticationError(String message) { + super(message, 401, null); + } + + /** + * Constructs an {@code AuthenticationError} with the specified detail message + * and error details. + * + * @param message the detail message + * @param errorDetails additional error details from the server, or {@code null} + */ + public AuthenticationError(String message, Map errorDetails) { + super(message, 401, errorDetails); + } +} diff --git a/src/main/java/ai/nomyo/errors/ForbiddenError.java b/src/main/java/ai/nomyo/errors/ForbiddenError.java new file mode 100644 index 0000000..7d9125b --- /dev/null +++ b/src/main/java/ai/nomyo/errors/ForbiddenError.java @@ -0,0 +1,41 @@ +package ai.nomyo.errors; + +import java.util.Map; + +/** + * Exception thrown when the API returns a 403 (Forbidden) status. + */ +public class ForbiddenError extends APIError { + + /** + * Constructs a {@code ForbiddenError} with the specified detail message, + * status code, and error details. + * + * @param message the detail message + * @param statusCode the HTTP status code (must be 403) + * @param errorDetails additional error details from the server, or {@code null} + */ + public ForbiddenError(String message, Integer statusCode, Map errorDetails) { + super(message, statusCode, errorDetails); + } + + /** + * Constructs a {@code ForbiddenError} with the specified detail message. + * + * @param message the detail message + */ + public ForbiddenError(String message) { + super(message, 403, null); + } + + /** + * Constructs a {@code ForbiddenError} with the specified detail message + * and error details. + * + * @param message the detail message + * @param errorDetails additional error details from the server, or {@code null} + */ + public ForbiddenError(String message, Map errorDetails) { + super(message, 403, errorDetails); + } +} diff --git a/src/main/java/ai/nomyo/errors/InvalidRequestError.java b/src/main/java/ai/nomyo/errors/InvalidRequestError.java new file mode 100644 index 0000000..357b2b8 --- /dev/null +++ b/src/main/java/ai/nomyo/errors/InvalidRequestError.java @@ -0,0 +1,41 @@ +package ai.nomyo.errors; + +import java.util.Map; + +/** + * Exception thrown when the API returns a 400 (Bad Request) status. + */ +public class InvalidRequestError extends APIError { + + /** + * Constructs an {@code InvalidRequestError} with the specified detail message, + * status code, and error details. + * + * @param message the detail message + * @param statusCode the HTTP status code (must be 400) + * @param errorDetails additional error details from the server, or {@code null} + */ + public InvalidRequestError(String message, Integer statusCode, Map errorDetails) { + super(message, statusCode, errorDetails); + } + + /** + * Constructs an {@code InvalidRequestError} with the specified detail message. + * + * @param message the detail message + */ + public InvalidRequestError(String message) { + super(message, 400, null); + } + + /** + * Constructs an {@code InvalidRequestError} with the specified detail message + * and error details. + * + * @param message the detail message + * @param errorDetails additional error details from the server, or {@code null} + */ + public InvalidRequestError(String message, Map errorDetails) { + super(message, 400, errorDetails); + } +} diff --git a/src/main/java/ai/nomyo/errors/RateLimitError.java b/src/main/java/ai/nomyo/errors/RateLimitError.java new file mode 100644 index 0000000..b56da8d --- /dev/null +++ b/src/main/java/ai/nomyo/errors/RateLimitError.java @@ -0,0 +1,41 @@ +package ai.nomyo.errors; + +import java.util.Map; + +/** + * Exception thrown when the API returns a 429 (Too Many Requests) status. + */ +public class RateLimitError extends APIError { + + /** + * Constructs a {@code RateLimitError} with the specified detail message, + * status code, and error details. + * + * @param message the detail message + * @param statusCode the HTTP status code (must be 429) + * @param errorDetails additional error details from the server, or {@code null} + */ + public RateLimitError(String message, Integer statusCode, Map errorDetails) { + super(message, statusCode, errorDetails); + } + + /** + * Constructs a {@code RateLimitError} with the specified detail message. + * + * @param message the detail message + */ + public RateLimitError(String message) { + super(message, 429, null); + } + + /** + * Constructs a {@code RateLimitError} with the specified detail message + * and error details. + * + * @param message the detail message + * @param errorDetails additional error details from the server, or {@code null} + */ + public RateLimitError(String message, Map errorDetails) { + super(message, 429, errorDetails); + } +} diff --git a/src/main/java/ai/nomyo/errors/SecurityError.java b/src/main/java/ai/nomyo/errors/SecurityError.java new file mode 100644 index 0000000..8dd550e --- /dev/null +++ b/src/main/java/ai/nomyo/errors/SecurityError.java @@ -0,0 +1,34 @@ +package ai.nomyo.errors; + +/** + * Exception thrown for cryptographic and key-related failures. + * + *

Unlike {@link APIError}, this exception carries no HTTP status code. + * It is used for issues such as invalid key sizes, encryption/decryption + * failures, and missing keys.

+ * + *

Any decryption failure (except JSON parse errors) raises + * {@code SecurityError("Decryption failed: integrity check or authentication failed")}.

+ */ +public class SecurityError extends Exception { + + /** + * Constructs a {@code SecurityError} with the specified detail message. + * + * @param message the detail message + */ + public SecurityError(String message) { + super(message); + } + + /** + * Constructs a {@code SecurityError} with the specified detail message + * and cause. + * + * @param message the detail message + * @param cause the underlying cause, or {@code null} + */ + public SecurityError(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/ai/nomyo/errors/ServerError.java b/src/main/java/ai/nomyo/errors/ServerError.java new file mode 100644 index 0000000..55415c1 --- /dev/null +++ b/src/main/java/ai/nomyo/errors/ServerError.java @@ -0,0 +1,41 @@ +package ai.nomyo.errors; + +import java.util.Map; + +/** + * Exception thrown when the API returns a 500 (Internal Server Error) status. + */ +public class ServerError extends APIError { + + /** + * Constructs a {@code ServerError} with the specified detail message, + * status code, and error details. + * + * @param message the detail message + * @param statusCode the HTTP status code (must be 500) + * @param errorDetails additional error details from the server, or {@code null} + */ + public ServerError(String message, Integer statusCode, Map errorDetails) { + super(message, statusCode, errorDetails); + } + + /** + * Constructs a {@code ServerError} with the specified detail message. + * + * @param message the detail message + */ + public ServerError(String message) { + super(message, 500, null); + } + + /** + * Constructs a {@code ServerError} with the specified detail message + * and error details. + * + * @param message the detail message + * @param errorDetails additional error details from the server, or {@code null} + */ + public ServerError(String message, Map errorDetails) { + super(message, 500, errorDetails); + } +} diff --git a/src/main/java/ai/nomyo/errors/ServiceUnavailableError.java b/src/main/java/ai/nomyo/errors/ServiceUnavailableError.java new file mode 100644 index 0000000..4784fdd --- /dev/null +++ b/src/main/java/ai/nomyo/errors/ServiceUnavailableError.java @@ -0,0 +1,41 @@ +package ai.nomyo.errors; + +import java.util.Map; + +/** + * Exception thrown when the API returns a 503 (Service Unavailable) status. + */ +public class ServiceUnavailableError extends APIError { + + /** + * Constructs a {@code ServiceUnavailableError} with the specified detail + * message, status code, and error details. + * + * @param message the detail message + * @param statusCode the HTTP status code (must be 503) + * @param errorDetails additional error details from the server, or {@code null} + */ + public ServiceUnavailableError(String message, Integer statusCode, Map errorDetails) { + super(message, statusCode, errorDetails); + } + + /** + * Constructs a {@code ServiceUnavailableError} with the specified detail message. + * + * @param message the detail message + */ + public ServiceUnavailableError(String message) { + super(message, 503, null); + } + + /** + * Constructs a {@code ServiceUnavailableError} with the specified detail message + * and error details. + * + * @param message the detail message + * @param errorDetails additional error details from the server, or {@code null} + */ + public ServiceUnavailableError(String message, Map errorDetails) { + super(message, 503, errorDetails); + } +} diff --git a/src/main/java/ai/nomyo/util/PEMConverter.java b/src/main/java/ai/nomyo/util/PEMConverter.java new file mode 100644 index 0000000..fd9a3c3 --- /dev/null +++ b/src/main/java/ai/nomyo/util/PEMConverter.java @@ -0,0 +1,24 @@ +package ai.nomyo.util; + +import java.util.Base64; + +/** + * @author NieGestorben + * Copyright© (c) 2026, All Rights Reserved. + */ +public class PEMConverter { + + public static String toPEM(byte[] keyData, boolean privateKey) { + String publicKeyContent = Base64.getEncoder().encodeToString(keyData); + StringBuilder publicKeyFormatted = new StringBuilder(privateKey ? "-----BEGIN PRIVATE KEY-----" : "-----BEGIN PUBLIC KEY-----"); + publicKeyFormatted.append(System.lineSeparator()); + for (final String row : Splitter.fixedLengthString(64, publicKeyContent)) { + publicKeyFormatted.append(row); + publicKeyFormatted.append(System.lineSeparator()); + } + + publicKeyFormatted.append(privateKey ? "-----END PRIVATE KEY-----" : "-----END PUBLIC KEY-----"); + + return publicKeyFormatted.toString(); + } +} diff --git a/src/main/java/ai/nomyo/util/Pass2Key.java b/src/main/java/ai/nomyo/util/Pass2Key.java new file mode 100644 index 0000000..a986e4f --- /dev/null +++ b/src/main/java/ai/nomyo/util/Pass2Key.java @@ -0,0 +1,202 @@ +package ai.nomyo.util; + +import javax.crypto.BadPaddingException; +import javax.crypto.Cipher; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.SecretKey; +import javax.crypto.SecretKeyFactory; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.PBEKeySpec; +import javax.crypto.spec.SecretKeySpec; +import java.nio.ByteBuffer; +import java.security.*; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.KeySpec; +import java.security.spec.PKCS8EncodedKeySpec; +import java.util.Base64; + +/** + * Password-based encryption utility using PBKDF2 key derivation and AES-GCM encryption. + * + *

The encrypted output format stores salt + IV + ciphertext in that order, + * all base64-encoded. This ensures the salt and IV are persisted alongside + * the ciphertext for decryption.

+ * + *

Binary Layout

+ *
+ * [ 16 bytes salt ][ 12 bytes IV ][ variable bytes ciphertext ]
+ * 
+ */ +public final class Pass2Key { + + private static final SecureRandom RANDOM = new SecureRandom(); + private static final int SALT_LENGTH = 16; + private static final int GCM_IV_LENGTH = 12; + private static final int GCM_TAG_LENGTH = 128; + private static final int ITERATION_COUNT = 65536; + + private Pass2Key() {} + + /** + * Encrypts the given plaintext using the specified algorithm and password. + * + * @param algorithm the cipher algorithm (e.g. {@code "AES/GCM/NoPadding"}) + * @param input the plaintext to encrypt + * @param password the password used to derive the encryption key + * @return base64-encoded ciphertext including salt and IV + */ + public static String encrypt(String algorithm, String input, String password) + throws NoSuchPaddingException, NoSuchAlgorithmException, + InvalidAlgorithmParameterException, InvalidKeyException, + BadPaddingException, IllegalBlockSizeException { + + byte[] salt = generateRandomBytes(SALT_LENGTH); + SecretKey key = deriveKey(password, salt); + + byte[] payload; + if (isGcmMode(algorithm)) { + byte[] iv = generateRandomBytes(GCM_IV_LENGTH); + GCMParameterSpec spec = new GCMParameterSpec(GCM_TAG_LENGTH, iv); + byte[] ciphertext = encryptWithCipher(algorithm, key, spec, input); + payload = assemblePayloadGcm(salt, iv, ciphertext); + } else { + byte[] ciphertext = encryptWithCipher(algorithm, key, input); + payload = assemblePayloadSalt(salt, ciphertext); + } + + return Base64.getEncoder().encodeToString(payload); + } + + /** + * Decrypts the given base64-encoded ciphertext using the specified algorithm and password. + * + * @param algorithm the cipher algorithm (e.g. {@code "AES/GCM/NoPadding"}) + * @param cipherText the base64-encoded ciphertext (with embedded salt and IV) + * @param password the password used to derive the decryption key + * @return the decrypted plaintext + */ + public static String decrypt(String algorithm, String cipherText, String password) + throws NoSuchPaddingException, NoSuchAlgorithmException, + InvalidAlgorithmParameterException, InvalidKeyException, + BadPaddingException, IllegalBlockSizeException { + + byte[] decoded = Base64.getDecoder().decode(cipherText); + + byte[] salt = new byte[SALT_LENGTH]; + System.arraycopy(decoded, 0, salt, 0, SALT_LENGTH); + SecretKey key = deriveKey(password, salt); + + if (isGcmMode(algorithm)) { + byte[] iv = new byte[GCM_IV_LENGTH]; + System.arraycopy(decoded, SALT_LENGTH, iv, 0, GCM_IV_LENGTH); + GCMParameterSpec spec = new GCMParameterSpec(GCM_TAG_LENGTH, iv); + byte[] ciphertext = copyFrom(decoded, SALT_LENGTH + GCM_IV_LENGTH); + return decryptWithCipher(algorithm, key, spec, ciphertext); + } else { + byte[] ciphertext = copyFrom(decoded, SALT_LENGTH); + return decryptWithCipher(algorithm, key, ciphertext); + } + } + + // ── Key Derivation ──────────────────────────────────────────────── + + private static SecretKey deriveKey(String password, byte[] salt) { + try { + SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256"); + KeySpec spec = new PBEKeySpec(password.toCharArray(), salt, ITERATION_COUNT, 256); + return new SecretKeySpec(factory.generateSecret(spec).getEncoded(), "AES"); + } catch (InvalidKeySpecException | NoSuchAlgorithmException e) { + throw new RuntimeException("Could not derive key: " + e.getMessage()); + } + } + + // ── Cipher Operations ───────────────────────────────────────────── + + private static byte[] encryptWithCipher(String algorithm, SecretKey key, + GCMParameterSpec spec, String input) + throws NoSuchPaddingException, NoSuchAlgorithmException, + InvalidAlgorithmParameterException, InvalidKeyException, + BadPaddingException, IllegalBlockSizeException { + Cipher cipher = Cipher.getInstance(algorithm); + cipher.init(Cipher.ENCRYPT_MODE, key, spec); + return cipher.doFinal(input.getBytes()); + } + + private static byte[] encryptWithCipher(String algorithm, SecretKey key, String input) + throws NoSuchPaddingException, NoSuchAlgorithmException, + InvalidAlgorithmParameterException, InvalidKeyException, + BadPaddingException, IllegalBlockSizeException { + Cipher cipher = Cipher.getInstance(algorithm); + cipher.init(Cipher.ENCRYPT_MODE, key); + return cipher.doFinal(input.getBytes()); + } + + private static String decryptWithCipher(String algorithm, SecretKey key, + GCMParameterSpec spec, byte[] ciphertext) + throws NoSuchPaddingException, NoSuchAlgorithmException, + InvalidAlgorithmParameterException, InvalidKeyException, + BadPaddingException, IllegalBlockSizeException { + Cipher cipher = Cipher.getInstance(algorithm); + cipher.init(Cipher.DECRYPT_MODE, key, spec); + byte[] plaintext = cipher.doFinal(ciphertext); + return new String(plaintext); + } + + private static String decryptWithCipher(String algorithm, SecretKey key, byte[] ciphertext) + throws NoSuchPaddingException, NoSuchAlgorithmException, + InvalidAlgorithmParameterException, InvalidKeyException, + BadPaddingException, IllegalBlockSizeException { + Cipher cipher = Cipher.getInstance(algorithm); + cipher.init(Cipher.DECRYPT_MODE, key); + byte[] plaintext = cipher.doFinal(ciphertext); + return new String(plaintext); + } + + // ── Helpers ─────────────────────────────────────────────────────── + + private static boolean isGcmMode(String algorithm) { + return algorithm.contains("GCM"); + } + + private static byte[] generateRandomBytes(int length) { + byte[] bytes = new byte[length]; + RANDOM.nextBytes(bytes); + return bytes; + } + + private static byte[] copyFrom(byte[] source, int offset) { + return java.util.Arrays.copyOfRange(source, offset, source.length); + } + + private static byte[] assemblePayloadGcm(byte[] salt, byte[] iv, byte[] ciphertext) { + ByteBuffer buffer = ByteBuffer.allocate(salt.length + iv.length + ciphertext.length); + buffer.put(salt).put(iv).put(ciphertext); + return buffer.array(); + } + + private static byte[] assemblePayloadSalt(byte[] salt, byte[] ciphertext) { + ByteBuffer buffer = ByteBuffer.allocate(salt.length + ciphertext.length); + buffer.put(salt).put(ciphertext); + return buffer.array(); + } + + public static PrivateKey convertStringToPrivateKey(String privateKeyString) throws Exception { + // Remove any header and footer information if present + privateKeyString = privateKeyString.replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replaceAll("\\s+", ""); + + // Decode the Base64-encoded private key string + byte[] decodedKey = Base64.getDecoder().decode(privateKeyString); + + // Create a PKCS8EncodedKeySpec object + PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(decodedKey); + + // Get an instance of the KeyFactory for the desired algorithm (e.g., RSA) + KeyFactory keyFactory = KeyFactory.getInstance("RSA"); + + // Generate the private key object + return keyFactory.generatePrivate(keySpec); + } +} diff --git a/src/main/java/ai/nomyo/util/Splitter.java b/src/main/java/ai/nomyo/util/Splitter.java new file mode 100644 index 0000000..47f4dec --- /dev/null +++ b/src/main/java/ai/nomyo/util/Splitter.java @@ -0,0 +1,37 @@ +package ai.nomyo.util; + +import java.util.ArrayList; +import java.util.List; + +/** + * @author NieGestorben + * Copyright© (c) 2026, All Rights Reserved. + */ +public class Splitter { + + public static List fixedLengthString(int length, String toSplit) { + List returnList = new ArrayList<>(); + + int remaining = toSplit.length(); + + while (remaining > 0) { + int currentIndex = toSplit.length() - remaining; + + int endIndex = toSplit.length() - remaining + length; + + // If there are not enough characters left to create a new substring of the given length, create one with the remaining characters + if (remaining < length) { + endIndex = toSplit.length(); + } + + String split = toSplit.substring(currentIndex, endIndex); + + returnList.add(split); + + remaining -= length; + } + + return returnList; + } + +}