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