Initial commit
This commit is contained in:
commit
8acf584d28
24 changed files with 2408 additions and 0 deletions
39
.gitignore
vendored
Normal file
39
.gitignore
vendored
Normal file
|
|
@ -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
|
||||||
10
.idea/.gitignore
generated
vendored
Normal file
10
.idea/.gitignore
generated
vendored
Normal file
|
|
@ -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
|
||||||
7
.idea/encodings.xml
generated
Normal file
7
.idea/encodings.xml
generated
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="Encoding">
|
||||||
|
<file url="file://$PROJECT_DIR$/src/main/java" charset="UTF-8" />
|
||||||
|
<file url="file://$PROJECT_DIR$/src/main/resources" charset="UTF-8" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
18
.idea/misc.xml
generated
Normal file
18
.idea/misc.xml
generated
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="DiscordProjectSettings">
|
||||||
|
<option name="show" value="ASK" />
|
||||||
|
<option name="description" value="" />
|
||||||
|
</component>
|
||||||
|
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||||
|
<component name="MavenProjectsManager">
|
||||||
|
<option name="originalFiles">
|
||||||
|
<list>
|
||||||
|
<option value="$PROJECT_DIR$/pom.xml" />
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
<component name="ProjectRootManager" version="2" languageLevel="JDK_1_8" project-jdk-name="temurin-25" project-jdk-type="JavaSDK">
|
||||||
|
<output url="file://$PROJECT_DIR$/out" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
7
.idea/vcs.xml
generated
Normal file
7
.idea/vcs.xml
generated
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="" vcs="Git" />
|
||||||
|
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
478
TRANSLATION_REFERENCE.md
Normal file
478
TRANSLATION_REFERENCE.md
Normal file
|
|
@ -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": "<base64>",
|
||||||
|
"nonce": "<base64>",
|
||||||
|
"tag": "<base64>"
|
||||||
|
},
|
||||||
|
"encrypted_aes_key": "<base64>",
|
||||||
|
"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.
|
||||||
42
pom.xml
Normal file
42
pom.xml
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<groupId>ai.nomyo</groupId>
|
||||||
|
<artifactId>nomyo4J</artifactId>
|
||||||
|
<version>1.0</version>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<maven.compiler.source>25</maven.compiler.source>
|
||||||
|
<maven.compiler.target>25</maven.compiler.target>
|
||||||
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
<version>1.18.44</version>
|
||||||
|
<scope>compile</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.junit.jupiter</groupId>
|
||||||
|
<artifactId>junit-jupiter-engine</artifactId>
|
||||||
|
<version>6.0.3</version>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-surefire-plugin</artifactId>
|
||||||
|
<version>3.5.0</version>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
|
||||||
|
</project>
|
||||||
199
src/main/java/ai/nomyo/Constants.java
Normal file
199
src/main/java/ai/nomyo/Constants.java
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
package ai.nomyo;
|
||||||
|
|
||||||
|
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constants used throughout the NOMYO Java client library.
|
||||||
|
*
|
||||||
|
* <p>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.</p>
|
||||||
|
*/
|
||||||
|
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<Integer> 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<String> 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;
|
||||||
|
}
|
||||||
27
src/main/java/ai/nomyo/Main.java
Normal file
27
src/main/java/ai/nomyo/Main.java
Normal file
|
|
@ -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!");
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
188
src/main/java/ai/nomyo/SecureChatCompletion.java
Normal file
188
src/main/java/ai/nomyo/SecureChatCompletion.java
Normal file
|
|
@ -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.
|
||||||
|
*
|
||||||
|
* <p>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.</p>
|
||||||
|
*
|
||||||
|
* <h3>Usage</h3>
|
||||||
|
* <pre>{@code
|
||||||
|
* SecureChatCompletion client = new SecureChatCompletion(
|
||||||
|
* "https://api.nomyo.ai",
|
||||||
|
* false,
|
||||||
|
* "your-api-key",
|
||||||
|
* true,
|
||||||
|
* "/path/to/keys",
|
||||||
|
* 2
|
||||||
|
* );
|
||||||
|
*
|
||||||
|
* Map<String, Object> response = client.create(
|
||||||
|
* "Qwen/Qwen3-0.6B",
|
||||||
|
* List.of(Map.of("role", "user", "content", "Hello, world!"))
|
||||||
|
* );
|
||||||
|
* }</pre>
|
||||||
|
*
|
||||||
|
* <h3>Streaming</h3>
|
||||||
|
* <p>Streaming is <b>not supported</b>. The server rejects streaming requests with HTTP 400.
|
||||||
|
* Always use {@code stream=false} (the default).</p>
|
||||||
|
*
|
||||||
|
* <h3>Security Tiers</h3>
|
||||||
|
* <p>The {@code security_tier} parameter controls the hardware isolation level:</p>
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code "standard"} — GPU inference (general secure inference)</li>
|
||||||
|
* <li>{@code "high"} — CPU/GPU (sensitive business data)</li>
|
||||||
|
* <li>{@code "maximum"} — CPU only (PHI, classified data)</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <h3>Key Persistence</h3>
|
||||||
|
* <p>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).</p>
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
public class SecureChatCompletion {
|
||||||
|
|
||||||
|
private final SecureCompletionClient client;
|
||||||
|
private final String apiKey;
|
||||||
|
private final String keyDir;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs a {@code SecureChatCompletion} with default settings.
|
||||||
|
*
|
||||||
|
* <p>Uses the default NOMYO router URL ({@code https://api.nomyo.ai}),
|
||||||
|
* HTTPS-only, secure memory enabled, ephemeral keys, and 2 retries.</p>
|
||||||
|
*/
|
||||||
|
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.
|
||||||
|
*
|
||||||
|
* <p>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.</p>
|
||||||
|
*
|
||||||
|
* <h3>Parameters</h3>
|
||||||
|
* <table>
|
||||||
|
* <tr><th>Param</th><th>Type</th><th>Required</th><th>Description</th></tr>
|
||||||
|
* <tr><td>{@code model}</td><td>{@code String}</td><td>yes</td><td>Model identifier, e.g. "Qwen/Qwen3-0.6B"</td></tr>
|
||||||
|
* <tr><td>{@code messages}</td><td>{@code List<Map>}</td><td>yes</td><td>OpenAI-format messages</td></tr>
|
||||||
|
* <tr><td>{@code temperature}</td><td>{@code Double}</td><td>no</td><td>0–2</td></tr>
|
||||||
|
* <tr><td>{@code maxTokens}</td><td>{@code Integer}</td><td>no</td><td>Maximum tokens in response</td></tr>
|
||||||
|
* <tr><td>{@code topP}</td><td>{@code Double}</td><td>no</td><td>Top-p sampling parameter</td></tr>
|
||||||
|
* <tr><td>{@code stop}</td><td>{@code String | List<String>}</td><td>no</td><td>Stop sequences</td></tr>
|
||||||
|
* <tr><td>{@code presencePenalty}</td><td>{@code Double}</td><td>no</td><td>-2.0 to 2.0</td></tr>
|
||||||
|
* <tr><td>{@code frequencyPenalty}</td><td>{@code Double}</td><td>no</td><td>-2.0 to 2.0</td></tr>
|
||||||
|
* <tr><td>{@code n}</td><td>{@code Integer}</td><td>no</td><td>Number of completions</td></tr>
|
||||||
|
* <tr><td>{@code bestOf}</td><td>{@code Integer}</td><td>no</td><td></td></tr>
|
||||||
|
* <tr><td>{@code seed}</td><td>{@code Integer}</td><td>no</td><td>Reproducibility seed</td></tr>
|
||||||
|
* <tr><td>{@code logitBias}</td><td>{@code Map<String, Double>}</td><td>no</td><td>Token bias map</td></tr>
|
||||||
|
* <tr><td>{@code user}</td><td>{@code String}</td><td>no</td><td>End-user identifier</td></tr>
|
||||||
|
* <tr><td>{@code tools}</td><td>{@code List<Map>}</td><td>no</td><td>Tool definitions passed through to llama.cpp</td></tr>
|
||||||
|
* <tr><td>{@code toolChoice}</td><td>{@code String}</td><td>no</td><td>"auto", "none", or specific tool name</td></tr>
|
||||||
|
* <tr><td>{@code responseFormat}</td><td>{@code Map}</td><td>no</td><td>{"type": "json_object"} or {"type": "json_schema", ...}</td></tr>
|
||||||
|
* <tr><td>{@code stream}</td><td>{@code Boolean}</td><td>no</td><td><b>NOT supported.</b> Server rejects with HTTP 400. Always use {@code false}.</td></tr>
|
||||||
|
* <tr><td>{@code baseUrl}</td><td>{@code String}</td><td>no</td><td>Per-call override (creates temp client internally)</td></tr>
|
||||||
|
* <tr><td>{@code securityTier}</td><td>{@code String}</td><td>no</td><td>"standard", "high", or "maximum". Invalid values raise {@code ValueError}.</td></tr>
|
||||||
|
* <tr><td>{@code apiKey}</td><td>{@code String}</td><td>no</td><td>Per-call override of instance {@code apiKey}.</td></tr>
|
||||||
|
* </table>
|
||||||
|
*
|
||||||
|
* @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<String, Object> create(String model, List<Map<String, Object>> messages, Map<String, Object> 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<String, Object> create(String model, List<Map<String, Object>> 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<String, Object> acreate(String model, List<Map<String, Object>> messages, Map<String, Object> 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<String, Object> acreate(String model, List<Map<String, Object>> messages) {
|
||||||
|
return create(model, messages);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closes the client and releases any resources.
|
||||||
|
*/
|
||||||
|
public void close() {
|
||||||
|
client.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
513
src/main/java/ai/nomyo/SecureCompletionClient.java
Normal file
513
src/main/java/ai/nomyo/SecureCompletionClient.java
Normal file
|
|
@ -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.
|
||||||
|
*
|
||||||
|
* <p>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}.</p>
|
||||||
|
*
|
||||||
|
* <h3>Encryption Wire Format</h3>
|
||||||
|
* <p>Encrypted payloads use hybrid encryption (AES-256-GCM + RSA-4096-OAEP-SHA256):</p>
|
||||||
|
* <ul>
|
||||||
|
* <li>A per-request 256-bit AES key encrypts the payload via AES-256-GCM</li>
|
||||||
|
* <li>The AES key is encrypted via RSA-4096-OAEP-SHA256 using the server's public key</li>
|
||||||
|
* <li>The result is a JSON package with base64-encoded ciphertext, nonce, tag, and encrypted AES key</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <h3>Key Lifecycle</h3>
|
||||||
|
* <p>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)}.</p>
|
||||||
|
*/
|
||||||
|
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.
|
||||||
|
*
|
||||||
|
* <p>The public exponent is fixed at 65537. The generated key pair
|
||||||
|
* is stored in memory. Use {@code saveToDir} to persist to disk.</p>
|
||||||
|
*
|
||||||
|
* @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<PosixFilePermission> 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<PosixFilePermission> 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.
|
||||||
|
*
|
||||||
|
* <p>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.</p>
|
||||||
|
*
|
||||||
|
* @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.
|
||||||
|
*
|
||||||
|
* <p>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.</p>
|
||||||
|
*
|
||||||
|
* @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<String> fetchServerPublicKey() {
|
||||||
|
throw new UnsupportedOperationException("Not yet implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Encryption ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypts a payload dict using hybrid encryption.
|
||||||
|
*
|
||||||
|
* <p>Serializes the payload to JSON, then encrypts it using:
|
||||||
|
* <ol>
|
||||||
|
* <li>A per-request 256-bit AES key (AES-256-GCM)</li>
|
||||||
|
* <li>RSA-OAEP-SHA256 wrapping of the AES key with the server's public key</li>
|
||||||
|
* </ol>
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* @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<byte[]> encryptPayload(Map<String, Object> payload) {
|
||||||
|
throw new UnsupportedOperationException("Not yet implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core hybrid encryption routine.
|
||||||
|
*/
|
||||||
|
public CompletableFuture<byte[]> doEncrypt(byte[] payloadBytes, byte[] aesKey) {
|
||||||
|
throw new UnsupportedOperationException("Not yet implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Decryption ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypts a server response.
|
||||||
|
*/
|
||||||
|
public CompletableFuture<Map<String, Object>> decryptResponse(byte[] encryptedResponse, String payloadId) {
|
||||||
|
throw new UnsupportedOperationException("Not yet implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Secure Request Lifecycle ────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full request lifecycle: encrypt → HTTP POST → retry → decrypt → return.
|
||||||
|
*
|
||||||
|
* <h3>Request Headers</h3>
|
||||||
|
* <pre>
|
||||||
|
* 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)
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
|
* <h3>POST</h3>
|
||||||
|
* {@code {routerUrl}/v1/chat/secure_completion} with encrypted payload as body.
|
||||||
|
*
|
||||||
|
* <h3>Retry Logic</h3>
|
||||||
|
* <ul>
|
||||||
|
* <li>Retryable status codes: {@code {429, 500, 502, 503, 504}}</li>
|
||||||
|
* <li>Backoff: {@code 2^(attempt-1)} seconds (1s, 2s, 4s…)</li>
|
||||||
|
* <li>Total attempts: {@code maxRetries + 1}</li>
|
||||||
|
* <li>Network errors also retry</li>
|
||||||
|
* <li>Non-retryable exceptions propagate immediately</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <h3>Status → Exception Mapping</h3>
|
||||||
|
* <table>
|
||||||
|
* <tr><th>Status</th><th>Result</th></tr>
|
||||||
|
* <tr><td>200</td><td>Return decrypted response map</td></tr>
|
||||||
|
* <tr><td>400</td><td>{@code InvalidRequestError}</td></tr>
|
||||||
|
* <tr><td>401</td><td>{@code AuthenticationError}</td></tr>
|
||||||
|
* <tr><td>403</td><td>{@code ForbiddenError}</td></tr>
|
||||||
|
* <tr><td>404</td><td>{@code APIError}</td></tr>
|
||||||
|
* <tr><td>429</td><td>{@code RateLimitError}</td></tr>
|
||||||
|
* <tr><td>500</td><td>{@code ServerError}</td></tr>
|
||||||
|
* <tr><td>503</td><td>{@code ServiceUnavailableError}</td></tr>
|
||||||
|
* <tr><td>502/504</td><td>{@code APIError} (retryable)</td></tr>
|
||||||
|
* <tr><td>other</td><td>{@code APIError} (non-retryable)</td></tr>
|
||||||
|
* <tr><td>network error</td><td>{@code APIConnectionError}</td></tr>
|
||||||
|
* </table>
|
||||||
|
*
|
||||||
|
* @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<Map<String, Object>> sendSecureRequest(
|
||||||
|
Map<String, Object> 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<Map<String, Object>> sendSecureRequest(
|
||||||
|
Map<String, Object> 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<Map<String, Object>> sendSecureRequest(
|
||||||
|
Map<String, Object> payload,
|
||||||
|
String payloadId
|
||||||
|
) {
|
||||||
|
return sendSecureRequest(payload, payloadId, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Key Initialization ──────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensures RSA keys are loaded or generated.
|
||||||
|
*
|
||||||
|
* <p>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.</p>
|
||||||
|
*
|
||||||
|
* @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");
|
||||||
|
}
|
||||||
|
}
|
||||||
237
src/main/java/ai/nomyo/SecureMemory.java
Normal file
237
src/main/java/ai/nomyo/SecureMemory.java
Normal file
|
|
@ -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.
|
||||||
|
*
|
||||||
|
* <p>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).</p>
|
||||||
|
*
|
||||||
|
* <h3>Protection Levels</h3>
|
||||||
|
* <ul>
|
||||||
|
* <li><b>"full"</b> — Memory locking and secure zeroing both available</li>
|
||||||
|
* <li><b>"zeroing_only"</b> — Only secure zeroing available</li>
|
||||||
|
* <li><b>"none"</b> — No memory protection available</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <h3>Usage</h3>
|
||||||
|
* <pre>{@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
|
||||||
|
* }</pre>
|
||||||
|
*/
|
||||||
|
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.
|
||||||
|
*
|
||||||
|
* <p>Implements {@link AutoCloseable} for use with try-with-resources.
|
||||||
|
* The buffer is automatically zeroed and unlocked when closed, even
|
||||||
|
* if an exception occurs.</p>
|
||||||
|
*/
|
||||||
|
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.
|
||||||
|
*
|
||||||
|
* <p>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.</p>
|
||||||
|
*
|
||||||
|
* @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.
|
||||||
|
*
|
||||||
|
* <p><b>Deprecated:</b> Use {@link #secureByteArray(byte[])} instead.
|
||||||
|
* This method exists for compatibility with the Python reference.</p>
|
||||||
|
*
|
||||||
|
* @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.
|
||||||
|
*
|
||||||
|
* <p><b>Deprecated:</b> Use {@link #secureByteArray(byte[])} instead.
|
||||||
|
* This method exists for compatibility with the Python reference.</p>
|
||||||
|
*
|
||||||
|
* @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<String, Object> 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
41
src/main/java/ai/nomyo/errors/APIConnectionError.java
Normal file
41
src/main/java/ai/nomyo/errors/APIConnectionError.java
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
package ai.nomyo.errors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exception thrown when a network failure occurs during communication
|
||||||
|
* with the NOMYO API server.
|
||||||
|
*
|
||||||
|
* <p>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.</p>
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
59
src/main/java/ai/nomyo/errors/APIError.java
Normal file
59
src/main/java/ai/nomyo/errors/APIError.java
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
package ai.nomyo.errors;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base exception for all NOMYO API errors.
|
||||||
|
*
|
||||||
|
* <p>All API error subclasses carry a {@code status_code} and optional
|
||||||
|
* {@code error_details} from the server response.</p>
|
||||||
|
*/
|
||||||
|
public class APIError extends Exception {
|
||||||
|
|
||||||
|
private final Integer statusCode;
|
||||||
|
private final Map<String, Object> 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<String, Object> 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<String, Object> getErrorDetails() {
|
||||||
|
return errorDetails;
|
||||||
|
}
|
||||||
|
}
|
||||||
41
src/main/java/ai/nomyo/errors/AuthenticationError.java
Normal file
41
src/main/java/ai/nomyo/errors/AuthenticationError.java
Normal file
|
|
@ -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<String, Object> 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<String, Object> errorDetails) {
|
||||||
|
super(message, 401, errorDetails);
|
||||||
|
}
|
||||||
|
}
|
||||||
41
src/main/java/ai/nomyo/errors/ForbiddenError.java
Normal file
41
src/main/java/ai/nomyo/errors/ForbiddenError.java
Normal file
|
|
@ -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<String, Object> 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<String, Object> errorDetails) {
|
||||||
|
super(message, 403, errorDetails);
|
||||||
|
}
|
||||||
|
}
|
||||||
41
src/main/java/ai/nomyo/errors/InvalidRequestError.java
Normal file
41
src/main/java/ai/nomyo/errors/InvalidRequestError.java
Normal file
|
|
@ -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<String, Object> 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<String, Object> errorDetails) {
|
||||||
|
super(message, 400, errorDetails);
|
||||||
|
}
|
||||||
|
}
|
||||||
41
src/main/java/ai/nomyo/errors/RateLimitError.java
Normal file
41
src/main/java/ai/nomyo/errors/RateLimitError.java
Normal file
|
|
@ -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<String, Object> 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<String, Object> errorDetails) {
|
||||||
|
super(message, 429, errorDetails);
|
||||||
|
}
|
||||||
|
}
|
||||||
34
src/main/java/ai/nomyo/errors/SecurityError.java
Normal file
34
src/main/java/ai/nomyo/errors/SecurityError.java
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
package ai.nomyo.errors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exception thrown for cryptographic and key-related failures.
|
||||||
|
*
|
||||||
|
* <p>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.</p>
|
||||||
|
*
|
||||||
|
* <p>Any decryption failure (except JSON parse errors) raises
|
||||||
|
* {@code SecurityError("Decryption failed: integrity check or authentication failed")}.</p>
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
41
src/main/java/ai/nomyo/errors/ServerError.java
Normal file
41
src/main/java/ai/nomyo/errors/ServerError.java
Normal file
|
|
@ -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<String, Object> 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<String, Object> errorDetails) {
|
||||||
|
super(message, 500, errorDetails);
|
||||||
|
}
|
||||||
|
}
|
||||||
41
src/main/java/ai/nomyo/errors/ServiceUnavailableError.java
Normal file
41
src/main/java/ai/nomyo/errors/ServiceUnavailableError.java
Normal file
|
|
@ -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<String, Object> 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<String, Object> errorDetails) {
|
||||||
|
super(message, 503, errorDetails);
|
||||||
|
}
|
||||||
|
}
|
||||||
24
src/main/java/ai/nomyo/util/PEMConverter.java
Normal file
24
src/main/java/ai/nomyo/util/PEMConverter.java
Normal file
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
202
src/main/java/ai/nomyo/util/Pass2Key.java
Normal file
202
src/main/java/ai/nomyo/util/Pass2Key.java
Normal file
|
|
@ -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.
|
||||||
|
*
|
||||||
|
* <p>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.</p>
|
||||||
|
*
|
||||||
|
* <h3>Binary Layout</h3>
|
||||||
|
* <pre>
|
||||||
|
* [ 16 bytes salt ][ 12 bytes IV ][ variable bytes ciphertext ]
|
||||||
|
* </pre>
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/main/java/ai/nomyo/util/Splitter.java
Normal file
37
src/main/java/ai/nomyo/util/Splitter.java
Normal file
|
|
@ -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<String> fixedLengthString(int length, String toSplit) {
|
||||||
|
List<String> 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue