Misc cleanup
This commit is contained in:
parent
9b5fa56215
commit
084ce14451
17 changed files with 101 additions and 620 deletions
40
AGENTS.md
40
AGENTS.md
|
|
@ -1,6 +1,6 @@
|
||||||
# nomyo4J — Agent Instructions
|
# nomyo4J — Agent Instructions
|
||||||
|
|
||||||
Java port of the NOMYO Python client. Hybrid encryption (RSA-4096 + AES-256-GCM) for secure API communication.
|
Java 25 port of the NOMYO Python client. Hybrid encryption (RSA-4096 + AES-256-GCM) for secure API communication.
|
||||||
|
|
||||||
## Build & Run
|
## Build & Run
|
||||||
|
|
||||||
|
|
@ -16,27 +16,26 @@ mvn test -Dtest=ClassName # single test class
|
||||||
- **`SecureChatCompletion`** — high-level OpenAI-compatible surface (`create()`, `acreate()`)
|
- **`SecureChatCompletion`** — high-level OpenAI-compatible surface (`create()`, `acreate()`)
|
||||||
- **`Constants`** — all protocol/crypto constants (version, algorithms, timeouts)
|
- **`Constants`** — all protocol/crypto constants (version, algorithms, timeouts)
|
||||||
- **`SecureMemory`** — Java 25 FFM `SecureBuffer` for locked/zeroed memory
|
- **`SecureMemory`** — Java 25 FFM `SecureBuffer` for locked/zeroed memory
|
||||||
- **`errors/`** — exception hierarchy, all `extends Exception` (checked)
|
- **`errors/`** — 9 exception classes, all `extends Exception` (checked), all `extends APIError`
|
||||||
- **`util/`** — `Pass2Key` (PBKDF2 + AES-GCM), `PEMConverter`, `Splitter`
|
- **`util/`** — `Pass2Key` (PBKDF2 + AES-GCM), `PEMConverter`, `Splitter`
|
||||||
|
- **`EncryptedRequest`** — wire format model with Gson `@SerializedName` annotations
|
||||||
|
|
||||||
## Critical: This is a partial/in-progress port
|
## Stubbed methods (check before implementing)
|
||||||
|
|
||||||
Many methods are stubbed with `UnsupportedOperationException`. Before implementing, check `TRANSLATION_REFERENCE.md` for the Python reference. Stubbed methods:
|
- `SecureMemory.lock()` — always returns `false` (FFM doesn't support locking)
|
||||||
|
- `SecureMemory.unlock()` — always returns `false`
|
||||||
|
- `SecureMemory.initMemoryLocking()` — always returns `false`
|
||||||
|
|
||||||
- `SecureCompletionClient.fetchServerPublicKey()` — GET `/pki/public_key`
|
## Dependencies
|
||||||
- `SecureCompletionClient.encryptPayload()` / `doEncrypt()` — hybrid encryption
|
|
||||||
- `SecureCompletionClient.decryptResponse()` — response decryption
|
|
||||||
- `SecureCompletionClient.sendSecureRequest()` (3 overloads) — full request lifecycle
|
|
||||||
- `SecureCompletionClient.ensureKeys()` — key init (partial DCL implemented)
|
|
||||||
- `SecureCompletionClient.close()` — resource cleanup
|
|
||||||
- `SecureChatCompletion.create()` / `acreate()` — return `null`, stubbed
|
|
||||||
- `SecureMemory` lock/unlock — always returns `false`
|
|
||||||
|
|
||||||
**No JSON library** (Jackson/Gson) in `pom.xml` — needed for wire format serialization.
|
- **Gson** (2.13.2) — JSON serialization, in `pom.xml`
|
||||||
|
- **Lombok** (1.18.44, `provided` scope) — annotation processor configured in maven-compiler-plugin
|
||||||
|
- **JUnit Jupiter** (5.12.1, `test` scope)
|
||||||
|
- **Maven Surefire** (3.5.0)
|
||||||
|
|
||||||
## Key files
|
## Key files
|
||||||
|
|
||||||
- `TRANSLATION_REFERENCE.md` — **primary documentation**. Cross-language spec derived from Python reference. Read before implementing any method.
|
- `TRANSLATION_REFERENCE.md` — **primary documentation**. Cross-language spec from Python reference. Read before implementing any method.
|
||||||
- `client_keys/` — contains real RSA keys. **Gitignored.** Do not commit.
|
- `client_keys/` — contains real RSA keys. **Gitignored.** Do not commit.
|
||||||
- `Main.java` — entry point is `static void main()` — **not `public static void main(String[])`**. Cannot run standalone.
|
- `Main.java` — entry point is `static void main()` — **not `public static void main(String[])`**. Cannot run standalone.
|
||||||
|
|
||||||
|
|
@ -45,7 +44,12 @@ Many methods are stubbed with `UnsupportedOperationException`. Before implementi
|
||||||
- Package: `ai.nomyo`
|
- Package: `ai.nomyo`
|
||||||
- Lombok: `@Getter` on fields, `@Setter` on static flags
|
- Lombok: `@Getter` on fields, `@Setter` on static flags
|
||||||
- Tests: `@TestMethodOrder(OrderAnnotation.class)`, `@DisplayName` on every test
|
- Tests: `@TestMethodOrder(OrderAnnotation.class)`, `@DisplayName` on every test
|
||||||
- Error classes: checked exceptions with `status_code` and `error_details`
|
- Error classes: checked exceptions with `statusCode` and `errorDetails` (immutable, via `Collections.unmodifiableMap`)
|
||||||
- Key files: `PosixFilePermissions.OWNER_READ` only (mode 400)
|
- Key files: `PosixFilePermissions.fromString("rw-------")` for private, `"rw-r--r--"` for public
|
||||||
- RSA: 4096-bit, exponent 65537, OAEP-SHA256 padding
|
- RSA: 4096-bit, exponent 65537, OAEP-SHA256 (MGF1 with SHA-256)
|
||||||
- Protocol constants in `Constants.java` — marked "never change"
|
- Protocol constants in `Constants.java` — marked "never change" (downgrade detection)
|
||||||
|
- `SecureChatCompletion.acreate()` is a sync alias, not async — delegates to `create()`
|
||||||
|
- Streaming is explicitly rejected with `IllegalArgumentException`
|
||||||
|
- `SecureCompletionClient.ValueError` — inner class, maps to Python `ValueError`
|
||||||
|
- Retry: 429/500/502/503/504 + network errors, exponential backoff 2^(attempt-1)s, default 2 retries
|
||||||
|
- All HTTP endpoints use `application/octet-stream` content type for encrypted payloads
|
||||||
|
|
|
||||||
|
|
@ -1,478 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
package ai.nomyo;
|
package ai.nomyo;
|
||||||
|
|
||||||
|
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -26,7 +25,6 @@ public final class Constants {
|
||||||
* AES-256-GCM payload encryption algorithm.
|
* AES-256-GCM payload encryption algorithm.
|
||||||
*/
|
*/
|
||||||
public static final String PAYLOAD_ALGORITHM = "AES-256-GCM";
|
public static final String PAYLOAD_ALGORITHM = "AES-256-GCM";
|
||||||
|
|
||||||
// ── Cryptographic Constants ─────────────────────────────────────
|
// ── Cryptographic Constants ─────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -53,7 +51,6 @@ public final class Constants {
|
||||||
* Minimum RSA key size for validation (bits).
|
* Minimum RSA key size for validation (bits).
|
||||||
*/
|
*/
|
||||||
public static final int MIN_RSA_KEY_SIZE = 2048;
|
public static final int MIN_RSA_KEY_SIZE = 2048;
|
||||||
|
|
||||||
// ── Payload Limits ──────────────────────────────────────────────
|
// ── Payload Limits ──────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -75,7 +72,6 @@ public final class Constants {
|
||||||
* Retryable HTTP status codes.
|
* Retryable HTTP status codes.
|
||||||
*/
|
*/
|
||||||
public static final Set<Integer> RETRYABLE_STATUS_CODES = Set.of(429, 500, 502, 503, 504);
|
public static final Set<Integer> RETRYABLE_STATUS_CODES = Set.of(429, 500, 502, 503, 504);
|
||||||
|
|
||||||
// ── File Permission Constants ───────────────────────────────────
|
// ── File Permission Constants ───────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -86,7 +82,6 @@ public final class Constants {
|
||||||
* Public key file permission (owner rw, group/others r).
|
* Public key file permission (owner rw, group/others r).
|
||||||
*/
|
*/
|
||||||
public static final String PUBLIC_KEY_FILE_MODE = "rw-r--r--";
|
public static final String PUBLIC_KEY_FILE_MODE = "rw-r--r--";
|
||||||
|
|
||||||
// ── Security Tier Constants ─────────────────────────────────────
|
// ── Security Tier Constants ─────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -105,7 +100,6 @@ public final class Constants {
|
||||||
* CPU only for PHI/classified data.
|
* CPU only for PHI/classified data.
|
||||||
*/
|
*/
|
||||||
public static final String SECURITY_TIER_MAXIMUM = "maximum";
|
public static final String SECURITY_TIER_MAXIMUM = "maximum";
|
||||||
|
|
||||||
// ── Endpoint Paths ──────────────────────────────────────────────
|
// ── Endpoint Paths ──────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -116,7 +110,6 @@ public final class Constants {
|
||||||
* Secure chat completion endpoint.
|
* Secure chat completion endpoint.
|
||||||
*/
|
*/
|
||||||
public static final String SECURE_COMPLETION_PATH = "/v1/chat/secure_completion";
|
public static final String SECURE_COMPLETION_PATH = "/v1/chat/secure_completion";
|
||||||
|
|
||||||
// ── HTTP Headers ────────────────────────────────────────────────
|
// ── HTTP Headers ────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -139,7 +132,6 @@ public final class Constants {
|
||||||
* Bearer token prefix.
|
* Bearer token prefix.
|
||||||
*/
|
*/
|
||||||
public static final String AUTHORIZATION_BEARER_PREFIX = "Bearer ";
|
public static final String AUTHORIZATION_BEARER_PREFIX = "Bearer ";
|
||||||
|
|
||||||
// ── Default Values ──────────────────────────────────────────────
|
// ── Default Values ──────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -158,7 +150,6 @@ public final class Constants {
|
||||||
* Default public key file name.
|
* Default public key file name.
|
||||||
*/
|
*/
|
||||||
public static final String DEFAULT_PUBLIC_KEY_FILE = "public_key.pem";
|
public static final String DEFAULT_PUBLIC_KEY_FILE = "public_key.pem";
|
||||||
|
|
||||||
// ── Memory Protection Constants ─────────────────────────────────
|
// ── Memory Protection Constants ─────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ public class EncryptedRequest {
|
||||||
|
|
||||||
private static final Gson GSON = new GsonBuilder().create();
|
private static final Gson GSON = new GsonBuilder().create();
|
||||||
|
|
||||||
// Getters and Setters
|
|
||||||
@SerializedName("version")
|
@SerializedName("version")
|
||||||
private String version;
|
private String version;
|
||||||
|
|
||||||
|
|
@ -26,7 +25,7 @@ public class EncryptedRequest {
|
||||||
private EncryptedPayload encryptedPayload;
|
private EncryptedPayload encryptedPayload;
|
||||||
|
|
||||||
@SerializedName("encrypted_aes_key")
|
@SerializedName("encrypted_aes_key")
|
||||||
private String encryptedAESKey; // Java variable name corrected to proper spelling
|
private String encryptedAESKey;
|
||||||
|
|
||||||
@SerializedName("key_algorithm")
|
@SerializedName("key_algorithm")
|
||||||
private String keyAlgorithm;
|
private String keyAlgorithm;
|
||||||
|
|
@ -41,7 +40,6 @@ public class EncryptedRequest {
|
||||||
@Getter
|
@Getter
|
||||||
public static class EncryptedPayload {
|
public static class EncryptedPayload {
|
||||||
|
|
||||||
// Getters and Setters
|
|
||||||
@SerializedName("ciphertext")
|
@SerializedName("ciphertext")
|
||||||
private String ciphertext;
|
private String ciphertext;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -146,24 +146,11 @@ public class SecureChatCompletion {
|
||||||
/**
|
/**
|
||||||
* Convenience variant with no additional parameters.
|
* Convenience variant with no additional parameters.
|
||||||
*/
|
*/
|
||||||
|
@SuppressWarnings("UnusedReturnValue")
|
||||||
public Map<String, Object> create(String model, List<Map<String, Object>> messages) {
|
public Map<String, Object> create(String model, List<Map<String, Object>> messages) {
|
||||||
return create(model, messages, null);
|
return create(model, messages, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Async alias for {@link #create(String, List, 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)}.
|
|
||||||
*/
|
|
||||||
public Map<String, Object> acreate(String model, List<Map<String, Object>> messages) {
|
|
||||||
return create(model, messages);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delegates to {@link SecureCompletionClient#close()}.
|
* Delegates to {@link SecureCompletionClient#close()}.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -295,6 +295,7 @@ public class SecureCompletionClient {
|
||||||
* @return encrypted bytes (JSON package)
|
* @return encrypted bytes (JSON package)
|
||||||
* @throws SecurityError if encryption fails or keys not loaded
|
* @throws SecurityError if encryption fails or keys not loaded
|
||||||
*/
|
*/
|
||||||
|
@SuppressWarnings("JavadocDeclaration")
|
||||||
public CompletableFuture<byte[]> encryptPayload(Map<String, Object> payload) {
|
public CompletableFuture<byte[]> encryptPayload(Map<String, Object> payload) {
|
||||||
return CompletableFuture.supplyAsync(() -> {
|
return CompletableFuture.supplyAsync(() -> {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import java.util.Map;
|
||||||
/**
|
/**
|
||||||
* Cross-platform memory locking and secure zeroing for sensitive cryptographic buffers. Fails gracefully if unavailable.
|
* Cross-platform memory locking and secure zeroing for sensitive cryptographic buffers. Fails gracefully if unavailable.
|
||||||
*/
|
*/
|
||||||
|
@SuppressWarnings("SameReturnValue")
|
||||||
public final class SecureMemory {
|
public final class SecureMemory {
|
||||||
|
|
||||||
@Getter
|
@Getter
|
||||||
|
|
@ -75,6 +76,7 @@ public final class SecureMemory {
|
||||||
/**
|
/**
|
||||||
* Wraps bytes with memory locking and guaranteed zeroing on close. AutoCloseable for try-with-resources.
|
* Wraps bytes with memory locking and guaranteed zeroing on close. AutoCloseable for try-with-resources.
|
||||||
*/
|
*/
|
||||||
|
@SuppressWarnings("SameReturnValue")
|
||||||
public static class SecureBuffer implements AutoCloseable {
|
public static class SecureBuffer implements AutoCloseable {
|
||||||
|
|
||||||
private final Arena arena;
|
private final Arena arena;
|
||||||
|
|
|
||||||
|
|
@ -31,5 +31,4 @@ public class APIError extends Exception {
|
||||||
public APIError(String message) {
|
public APIError(String message) {
|
||||||
this(message, null, null);
|
this(message, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,36 +11,31 @@ public class PEMConverter {
|
||||||
* Encodes {@code keyData} as PEM (private or public) with 64-char base64 lines.
|
* Encodes {@code keyData} as PEM (private or public) with 64-char base64 lines.
|
||||||
*/
|
*/
|
||||||
public static String toPEM(byte[] keyData, boolean privateKey) {
|
public static String toPEM(byte[] keyData, boolean privateKey) {
|
||||||
String publicKeyContent = Base64.getEncoder().encodeToString(keyData);
|
String b64 = Base64.getEncoder().encodeToString(keyData);
|
||||||
StringBuilder publicKeyFormatted = new StringBuilder(privateKey ? "-----BEGIN PRIVATE KEY-----" : "-----BEGIN PUBLIC KEY-----");
|
String begin = privateKey ? "-----BEGIN PRIVATE KEY-----" : "-----BEGIN PUBLIC KEY-----";
|
||||||
publicKeyFormatted.append(System.lineSeparator());
|
String end = privateKey ? "-----END PRIVATE KEY-----" : "-----END PUBLIC KEY-----";
|
||||||
for (final String row : Splitter.fixedLengthString(64, publicKeyContent)) {
|
StringBuilder sb = new StringBuilder(begin).append(System.lineSeparator());
|
||||||
publicKeyFormatted.append(row);
|
for (String row : Splitter.fixedLengthString(64, b64)) {
|
||||||
publicKeyFormatted.append(System.lineSeparator());
|
sb.append(row).append(System.lineSeparator());
|
||||||
}
|
}
|
||||||
|
sb.append(end);
|
||||||
publicKeyFormatted.append(privateKey ? "-----END PRIVATE KEY-----" : "-----END PUBLIC KEY-----");
|
return sb.toString();
|
||||||
|
|
||||||
return publicKeyFormatted.toString();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static byte[] fromPEM(String pem) {
|
public static byte[] fromPEM(String pem) {
|
||||||
pem = pem.replace("-----BEGIN PRIVATE KEY-----", "")
|
String cleaned = pem.replace("-----BEGIN PRIVATE KEY-----", "")
|
||||||
.replace("-----BEGIN PUBLIC KEY-----", "")
|
.replace("-----BEGIN PUBLIC KEY-----", "")
|
||||||
.replace("-----END PRIVATE KEY-----", "")
|
.replace("-----END PRIVATE KEY-----", "")
|
||||||
.replace("-----END PUBLIC KEY-----", "")
|
.replace("-----END PUBLIC KEY-----", "")
|
||||||
.replaceAll("\\s+", "");
|
.replaceAll("\\s+", "");
|
||||||
|
return Base64.getDecoder().decode(cleaned);
|
||||||
return Base64.getDecoder().decode(pem);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static boolean validatePEM(String keyIn) {
|
public static boolean validatePEM(String keyIn) {
|
||||||
if (keyIn == null || keyIn.isBlank()) {
|
if (keyIn == null || keyIn.isBlank()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
String trimmed = keyIn.trim();
|
String trimmed = keyIn.trim();
|
||||||
|
|
||||||
return trimmed.startsWith("-----BEGIN PUBLIC KEY-----")
|
return trimmed.startsWith("-----BEGIN PUBLIC KEY-----")
|
||||||
&& trimmed.endsWith("-----END PUBLIC KEY-----");
|
&& trimmed.endsWith("-----END PUBLIC KEY-----");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package ai.nomyo.util;
|
package ai.nomyo.util;
|
||||||
|
|
||||||
import ai.nomyo.errors.SecurityError;
|
import ai.nomyo.errors.SecurityError;
|
||||||
|
|
||||||
import javax.crypto.BadPaddingException;
|
import javax.crypto.BadPaddingException;
|
||||||
import javax.crypto.Cipher;
|
import javax.crypto.Cipher;
|
||||||
import javax.crypto.IllegalBlockSizeException;
|
import javax.crypto.IllegalBlockSizeException;
|
||||||
|
|
|
||||||
|
|
@ -13,12 +13,10 @@ public class Splitter {
|
||||||
*/
|
*/
|
||||||
public static List<String> fixedLengthString(int length, String toSplit) {
|
public static List<String> fixedLengthString(int length, String toSplit) {
|
||||||
List<String> parts = new ArrayList<>();
|
List<String> parts = new ArrayList<>();
|
||||||
|
|
||||||
for (int i = 0; i < toSplit.length(); i += length) {
|
for (int i = 0; i < toSplit.length(); i += length) {
|
||||||
int endIndex = Math.min(i + length, toSplit.length());
|
int endIndex = Math.min(i + length, toSplit.length());
|
||||||
parts.add(toSplit.substring(i, endIndex));
|
parts.add(toSplit.substring(i, endIndex));
|
||||||
}
|
}
|
||||||
|
|
||||||
return parts;
|
return parts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -58,8 +58,8 @@ class CloseTest {
|
||||||
SecureCompletionClient client = new SecureCompletionClient();
|
SecureCompletionClient client = new SecureCompletionClient();
|
||||||
client.generateKeys(false);
|
client.generateKeys(false);
|
||||||
|
|
||||||
assertDoesNotThrow(() -> client.close(), "First close should not throw");
|
assertDoesNotThrow(client::close, "First close should not throw");
|
||||||
assertDoesNotThrow(() -> client.close(), "Second close should not throw");
|
assertDoesNotThrow(client::close, "Second close should not throw");
|
||||||
assertDoesNotThrow(() -> client.close(), "Third close should not throw");
|
assertDoesNotThrow(client::close, "Third close should not throw");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,9 @@ import java.util.Map;
|
||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
import java.util.concurrent.ExecutionException;
|
import java.util.concurrent.ExecutionException;
|
||||||
import java.security.spec.X509EncodedKeySpec;
|
import java.security.spec.X509EncodedKeySpec;
|
||||||
|
import javax.crypto.spec.OAEPParameterSpec;
|
||||||
|
import javax.crypto.spec.PSource;
|
||||||
|
import java.security.spec.MGF1ParameterSpec;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
|
@ -31,7 +34,6 @@ class DecryptResponseTest {
|
||||||
void decryptResponse_validPackage_shouldReturnDecryptedMap() throws Exception {
|
void decryptResponse_validPackage_shouldReturnDecryptedMap() throws Exception {
|
||||||
SecureCompletionClient client = new SecureCompletionClient();
|
SecureCompletionClient client = new SecureCompletionClient();
|
||||||
client.generateKeys(false);
|
client.generateKeys(false);
|
||||||
PrivateKey privateKey = client.getPrivateKey();
|
|
||||||
|
|
||||||
String plaintext = "{\"content\":\"Hello, world!\",\"role\":\"assistant\"}";
|
String plaintext = "{\"content\":\"Hello, world!\",\"role\":\"assistant\"}";
|
||||||
byte[] plaintextBytes = plaintext.getBytes(StandardCharsets.UTF_8);
|
byte[] plaintextBytes = plaintext.getBytes(StandardCharsets.UTF_8);
|
||||||
|
|
@ -57,7 +59,8 @@ class DecryptResponseTest {
|
||||||
PublicKey serverPublicKey = KeyFactory.getInstance("RSA").generatePublic(keySpec);
|
PublicKey serverPublicKey = KeyFactory.getInstance("RSA").generatePublic(keySpec);
|
||||||
|
|
||||||
Cipher rsaCipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding");
|
Cipher rsaCipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding");
|
||||||
rsaCipher.init(Cipher.ENCRYPT_MODE, serverPublicKey);
|
OAEPParameterSpec oaepParams = new OAEPParameterSpec("SHA-256", "MGF1", new MGF1ParameterSpec("SHA-256"), PSource.PSpecified.DEFAULT);
|
||||||
|
rsaCipher.init(Cipher.ENCRYPT_MODE, serverPublicKey, oaepParams);
|
||||||
byte[] encryptedAESKey = rsaCipher.doFinal(aesKey.getEncoded());
|
byte[] encryptedAESKey = rsaCipher.doFinal(aesKey.getEncoded());
|
||||||
|
|
||||||
JsonObject packageJson = new JsonObject();
|
JsonObject packageJson = new JsonObject();
|
||||||
|
|
@ -97,7 +100,6 @@ class DecryptResponseTest {
|
||||||
void decryptResponse_missingProcessedAt_shouldSetNull() throws Exception {
|
void decryptResponse_missingProcessedAt_shouldSetNull() throws Exception {
|
||||||
SecureCompletionClient client = new SecureCompletionClient();
|
SecureCompletionClient client = new SecureCompletionClient();
|
||||||
client.generateKeys(false);
|
client.generateKeys(false);
|
||||||
PrivateKey privateKey = client.getPrivateKey();
|
|
||||||
|
|
||||||
String plaintext = "{\"response\":\"ok\"}";
|
String plaintext = "{\"response\":\"ok\"}";
|
||||||
byte[] plaintextBytes = plaintext.getBytes(StandardCharsets.UTF_8);
|
byte[] plaintextBytes = plaintext.getBytes(StandardCharsets.UTF_8);
|
||||||
|
|
@ -121,7 +123,8 @@ class DecryptResponseTest {
|
||||||
PublicKey serverPublicKey = KeyFactory.getInstance("RSA").generatePublic(new X509EncodedKeySpec(pubKeyBytes));
|
PublicKey serverPublicKey = KeyFactory.getInstance("RSA").generatePublic(new X509EncodedKeySpec(pubKeyBytes));
|
||||||
|
|
||||||
Cipher rsaCipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding");
|
Cipher rsaCipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding");
|
||||||
rsaCipher.init(Cipher.ENCRYPT_MODE, serverPublicKey);
|
OAEPParameterSpec oaepParams = new OAEPParameterSpec("SHA-256", "MGF1", new MGF1ParameterSpec("SHA-256"), PSource.PSpecified.DEFAULT);
|
||||||
|
rsaCipher.init(Cipher.ENCRYPT_MODE, serverPublicKey, oaepParams);
|
||||||
byte[] encryptedAESKey = rsaCipher.doFinal(aesKey.getEncoded());
|
byte[] encryptedAESKey = rsaCipher.doFinal(aesKey.getEncoded());
|
||||||
|
|
||||||
JsonObject packageJson = new JsonObject();
|
JsonObject packageJson = new JsonObject();
|
||||||
|
|
@ -155,8 +158,7 @@ class DecryptResponseTest {
|
||||||
CompletableFuture<Map<String, Object>> future = client.decryptResponse(new byte[0], "test-id");
|
CompletableFuture<Map<String, Object>> future = client.decryptResponse(new byte[0], "test-id");
|
||||||
|
|
||||||
ExecutionException error = assertThrows(ExecutionException.class, future::get);
|
ExecutionException error = assertThrows(ExecutionException.class, future::get);
|
||||||
assertTrue(error.getCause() instanceof SecureCompletionClient.ValueError,
|
assertInstanceOf(SecureCompletionClient.ValueError.class, error.getCause(), "Should throw ValueError for empty response");
|
||||||
"Should throw ValueError for empty response");
|
|
||||||
assertTrue(error.getCause().getMessage().contains("Empty"),
|
assertTrue(error.getCause().getMessage().contains("Empty"),
|
||||||
"Error message should mention empty");
|
"Error message should mention empty");
|
||||||
}
|
}
|
||||||
|
|
@ -171,8 +173,7 @@ class DecryptResponseTest {
|
||||||
CompletableFuture<Map<String, Object>> future = client.decryptResponse(null, "test-id");
|
CompletableFuture<Map<String, Object>> future = client.decryptResponse(null, "test-id");
|
||||||
|
|
||||||
ExecutionException error = assertThrows(ExecutionException.class, future::get);
|
ExecutionException error = assertThrows(ExecutionException.class, future::get);
|
||||||
assertTrue(error.getCause() instanceof SecureCompletionClient.ValueError,
|
assertInstanceOf(SecureCompletionClient.ValueError.class, error.getCause(), "Should throw ValueError for null response");
|
||||||
"Should throw ValueError for null response");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -186,8 +187,7 @@ class DecryptResponseTest {
|
||||||
CompletableFuture<Map<String, Object>> future = client.decryptResponse(invalidJson, "test-id");
|
CompletableFuture<Map<String, Object>> future = client.decryptResponse(invalidJson, "test-id");
|
||||||
|
|
||||||
ExecutionException error = assertThrows(ExecutionException.class, future::get);
|
ExecutionException error = assertThrows(ExecutionException.class, future::get);
|
||||||
assertTrue(error.getCause() instanceof SecureCompletionClient.ValueError,
|
assertInstanceOf(SecureCompletionClient.ValueError.class, error.getCause(), "Should throw ValueError for malformed JSON");
|
||||||
"Should throw ValueError for malformed JSON");
|
|
||||||
assertTrue(error.getCause().getMessage().contains("malformed JSON") || error.getCause().getMessage().contains("JSON"),
|
assertTrue(error.getCause().getMessage().contains("malformed JSON") || error.getCause().getMessage().contains("JSON"),
|
||||||
"Error message should mention JSON");
|
"Error message should mention JSON");
|
||||||
}
|
}
|
||||||
|
|
@ -207,8 +207,7 @@ class DecryptResponseTest {
|
||||||
CompletableFuture<Map<String, Object>> future = client.decryptResponse(encryptedResponse, "test-id");
|
CompletableFuture<Map<String, Object>> future = client.decryptResponse(encryptedResponse, "test-id");
|
||||||
|
|
||||||
ExecutionException error = assertThrows(ExecutionException.class, future::get);
|
ExecutionException error = assertThrows(ExecutionException.class, future::get);
|
||||||
assertTrue(error.getCause() instanceof SecureCompletionClient.ValueError,
|
assertInstanceOf(SecureCompletionClient.ValueError.class, error.getCause(), "Should throw ValueError for missing fields");
|
||||||
"Should throw ValueError for missing fields");
|
|
||||||
assertTrue(error.getCause().getMessage().contains("Missing required fields"),
|
assertTrue(error.getCause().getMessage().contains("Missing required fields"),
|
||||||
"Error message should mention missing fields");
|
"Error message should mention missing fields");
|
||||||
}
|
}
|
||||||
|
|
@ -220,22 +219,11 @@ class DecryptResponseTest {
|
||||||
SecureCompletionClient client = new SecureCompletionClient();
|
SecureCompletionClient client = new SecureCompletionClient();
|
||||||
client.generateKeys(false);
|
client.generateKeys(false);
|
||||||
|
|
||||||
JsonObject packageJson = new JsonObject();
|
byte[] encryptedResponse = getJsonResponse("9.9", Constants.HYBRID_ALGORITHM);
|
||||||
packageJson.addProperty("version", "9.9");
|
|
||||||
packageJson.addProperty("algorithm", Constants.HYBRID_ALGORITHM);
|
|
||||||
packageJson.addProperty("encrypted_aes_key", "dGVzdA==");
|
|
||||||
JsonObject encryptedPayload = new JsonObject();
|
|
||||||
encryptedPayload.addProperty("ciphertext", "dGVzdA==");
|
|
||||||
encryptedPayload.addProperty("nonce", "dGVzdA==");
|
|
||||||
encryptedPayload.addProperty("tag", "dGVzdA==");
|
|
||||||
packageJson.add("encrypted_payload", encryptedPayload);
|
|
||||||
|
|
||||||
byte[] encryptedResponse = packageJson.toString().getBytes(StandardCharsets.UTF_8);
|
|
||||||
CompletableFuture<Map<String, Object>> future = client.decryptResponse(encryptedResponse, "test-id");
|
CompletableFuture<Map<String, Object>> future = client.decryptResponse(encryptedResponse, "test-id");
|
||||||
|
|
||||||
ExecutionException error = assertThrows(ExecutionException.class, future::get);
|
ExecutionException error = assertThrows(ExecutionException.class, future::get);
|
||||||
assertTrue(error.getCause() instanceof SecureCompletionClient.ValueError,
|
assertInstanceOf(SecureCompletionClient.ValueError.class, error.getCause(), "Should throw ValueError for wrong version");
|
||||||
"Should throw ValueError for wrong version");
|
|
||||||
assertTrue(error.getCause().getMessage().contains("Unsupported protocol version"),
|
assertTrue(error.getCause().getMessage().contains("Unsupported protocol version"),
|
||||||
"Error message should mention unsupported version");
|
"Error message should mention unsupported version");
|
||||||
}
|
}
|
||||||
|
|
@ -247,9 +235,19 @@ class DecryptResponseTest {
|
||||||
SecureCompletionClient client = new SecureCompletionClient();
|
SecureCompletionClient client = new SecureCompletionClient();
|
||||||
client.generateKeys(false);
|
client.generateKeys(false);
|
||||||
|
|
||||||
|
byte[] encryptedResponse = getJsonResponse(Constants.PROTOCOL_VERSION, "wrong-algorithm");
|
||||||
|
CompletableFuture<Map<String, Object>> future = client.decryptResponse(encryptedResponse, "test-id");
|
||||||
|
|
||||||
|
ExecutionException error = assertThrows(ExecutionException.class, future::get);
|
||||||
|
assertInstanceOf(SecureCompletionClient.ValueError.class, error.getCause(), "Should throw ValueError for wrong algorithm");
|
||||||
|
assertTrue(error.getCause().getMessage().contains("Unsupported encryption algorithm"),
|
||||||
|
"Error message should mention unsupported algorithm");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] getJsonResponse(String protocolVersion, String value) {
|
||||||
JsonObject packageJson = new JsonObject();
|
JsonObject packageJson = new JsonObject();
|
||||||
packageJson.addProperty("version", Constants.PROTOCOL_VERSION);
|
packageJson.addProperty("version", protocolVersion);
|
||||||
packageJson.addProperty("algorithm", "wrong-algorithm");
|
packageJson.addProperty("algorithm", value);
|
||||||
packageJson.addProperty("encrypted_aes_key", "dGVzdA==");
|
packageJson.addProperty("encrypted_aes_key", "dGVzdA==");
|
||||||
JsonObject encryptedPayload = new JsonObject();
|
JsonObject encryptedPayload = new JsonObject();
|
||||||
encryptedPayload.addProperty("ciphertext", "dGVzdA==");
|
encryptedPayload.addProperty("ciphertext", "dGVzdA==");
|
||||||
|
|
@ -257,38 +255,20 @@ class DecryptResponseTest {
|
||||||
encryptedPayload.addProperty("tag", "dGVzdA==");
|
encryptedPayload.addProperty("tag", "dGVzdA==");
|
||||||
packageJson.add("encrypted_payload", encryptedPayload);
|
packageJson.add("encrypted_payload", encryptedPayload);
|
||||||
|
|
||||||
byte[] encryptedResponse = packageJson.toString().getBytes(StandardCharsets.UTF_8);
|
return packageJson.toString().getBytes(StandardCharsets.UTF_8);
|
||||||
CompletableFuture<Map<String, Object>> future = client.decryptResponse(encryptedResponse, "test-id");
|
|
||||||
|
|
||||||
ExecutionException error = assertThrows(ExecutionException.class, future::get);
|
|
||||||
assertTrue(error.getCause() instanceof SecureCompletionClient.ValueError,
|
|
||||||
"Should throw ValueError for wrong algorithm");
|
|
||||||
assertTrue(error.getCause().getMessage().contains("Unsupported encryption algorithm"),
|
|
||||||
"Error message should mention unsupported algorithm");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@Execution(ExecutionMode.SAME_THREAD)
|
@Execution(ExecutionMode.SAME_THREAD)
|
||||||
@DisplayName("decryptResponse should throw SecurityError when private key not initialized")
|
@DisplayName("decryptResponse should throw SecurityError when private key not initialized")
|
||||||
void decryptResponse_noPrivateKey_shouldThrowSecurityError() throws Exception {
|
void decryptResponse_noPrivateKey_shouldThrowSecurityError() {
|
||||||
SecureCompletionClient client = new SecureCompletionClient();
|
SecureCompletionClient client = new SecureCompletionClient();
|
||||||
|
|
||||||
JsonObject packageJson = new JsonObject();
|
byte[] encryptedResponse = getJsonResponse(Constants.PROTOCOL_VERSION, Constants.HYBRID_ALGORITHM);
|
||||||
packageJson.addProperty("version", Constants.PROTOCOL_VERSION);
|
|
||||||
packageJson.addProperty("algorithm", Constants.HYBRID_ALGORITHM);
|
|
||||||
packageJson.addProperty("encrypted_aes_key", "dGVzdA==");
|
|
||||||
JsonObject encryptedPayload = new JsonObject();
|
|
||||||
encryptedPayload.addProperty("ciphertext", "dGVzdA==");
|
|
||||||
encryptedPayload.addProperty("nonce", "dGVzdA==");
|
|
||||||
encryptedPayload.addProperty("tag", "dGVzdA==");
|
|
||||||
packageJson.add("encrypted_payload", encryptedPayload);
|
|
||||||
|
|
||||||
byte[] encryptedResponse = packageJson.toString().getBytes(StandardCharsets.UTF_8);
|
|
||||||
CompletableFuture<Map<String, Object>> future = client.decryptResponse(encryptedResponse, "test-id");
|
CompletableFuture<Map<String, Object>> future = client.decryptResponse(encryptedResponse, "test-id");
|
||||||
|
|
||||||
ExecutionException error = assertThrows(ExecutionException.class, future::get);
|
ExecutionException error = assertThrows(ExecutionException.class, future::get);
|
||||||
assertTrue(error.getCause() instanceof SecurityError,
|
assertInstanceOf(SecurityError.class, error.getCause(), "Should throw SecurityError when no private key");
|
||||||
"Should throw SecurityError when no private key");
|
|
||||||
assertTrue(error.getCause().getMessage().contains("Private key not initialized"),
|
assertTrue(error.getCause().getMessage().contains("Private key not initialized"),
|
||||||
"Error message should mention private key not initialized");
|
"Error message should mention private key not initialized");
|
||||||
}
|
}
|
||||||
|
|
@ -343,7 +323,6 @@ class DecryptResponseTest {
|
||||||
CompletableFuture<Map<String, Object>> future = client2.decryptResponse(encryptedResponse, "test-id");
|
CompletableFuture<Map<String, Object>> future = client2.decryptResponse(encryptedResponse, "test-id");
|
||||||
|
|
||||||
ExecutionException error = assertThrows(ExecutionException.class, future::get);
|
ExecutionException error = assertThrows(ExecutionException.class, future::get);
|
||||||
assertTrue(error.getCause() instanceof SecurityError,
|
assertInstanceOf(SecurityError.class, error.getCause(), "Should throw SecurityError for wrong private key");
|
||||||
"Should throw SecurityError for wrong private key");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -138,7 +138,7 @@ class SecureChatCompletionTest {
|
||||||
} catch (RuntimeException e) {
|
} catch (RuntimeException e) {
|
||||||
Throwable cause = e.getCause();
|
Throwable cause = e.getCause();
|
||||||
if (cause instanceof ExecutionException) {
|
if (cause instanceof ExecutionException) {
|
||||||
throw (ExecutionException) cause;
|
throw cause;
|
||||||
}
|
}
|
||||||
throw new ExecutionException(cause);
|
throw new ExecutionException(cause);
|
||||||
}
|
}
|
||||||
|
|
@ -158,7 +158,7 @@ class SecureChatCompletionTest {
|
||||||
} catch (RuntimeException e) {
|
} catch (RuntimeException e) {
|
||||||
Throwable cause = e.getCause();
|
Throwable cause = e.getCause();
|
||||||
if (cause instanceof ExecutionException) {
|
if (cause instanceof ExecutionException) {
|
||||||
throw (ExecutionException) cause;
|
throw cause;
|
||||||
}
|
}
|
||||||
throw new ExecutionException(cause);
|
throw new ExecutionException(cause);
|
||||||
}
|
}
|
||||||
|
|
@ -176,7 +176,7 @@ class SecureChatCompletionTest {
|
||||||
} catch (RuntimeException e) {
|
} catch (RuntimeException e) {
|
||||||
Throwable cause = e.getCause();
|
Throwable cause = e.getCause();
|
||||||
if (cause instanceof ExecutionException) {
|
if (cause instanceof ExecutionException) {
|
||||||
throw (ExecutionException) cause;
|
throw cause;
|
||||||
}
|
}
|
||||||
throw new ExecutionException(cause);
|
throw new ExecutionException(cause);
|
||||||
}
|
}
|
||||||
|
|
@ -203,7 +203,7 @@ class SecureChatCompletionTest {
|
||||||
} catch (RuntimeException e) {
|
} catch (RuntimeException e) {
|
||||||
Throwable cause = e.getCause();
|
Throwable cause = e.getCause();
|
||||||
if (cause instanceof ExecutionException) {
|
if (cause instanceof ExecutionException) {
|
||||||
throw (ExecutionException) cause;
|
throw cause;
|
||||||
}
|
}
|
||||||
throw new ExecutionException(cause);
|
throw new ExecutionException(cause);
|
||||||
}
|
}
|
||||||
|
|
@ -229,8 +229,8 @@ class SecureChatCompletionTest {
|
||||||
void chatCompletion_close_multipleCalls_shouldNotThrow() {
|
void chatCompletion_close_multipleCalls_shouldNotThrow() {
|
||||||
SecureChatCompletion chat = new SecureChatCompletion();
|
SecureChatCompletion chat = new SecureChatCompletion();
|
||||||
|
|
||||||
assertDoesNotThrow(() -> chat.close(), "First close should not throw");
|
assertDoesNotThrow(chat::close, "First close should not throw");
|
||||||
assertDoesNotThrow(() -> chat.close(), "Second close should not throw");
|
assertDoesNotThrow(chat::close, "Second close should not throw");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
|
||||||
|
|
@ -218,8 +218,8 @@ class SecureCompletionClientE2ETest {
|
||||||
void e2e_multipleClients_independentOperations() throws Exception {
|
void e2e_multipleClients_independentOperations() throws Exception {
|
||||||
File dir1 = tempDir.resolve("dir1").toFile();
|
File dir1 = tempDir.resolve("dir1").toFile();
|
||||||
File dir2 = tempDir.resolve("dir2").toFile();
|
File dir2 = tempDir.resolve("dir2").toFile();
|
||||||
dir1.mkdirs();
|
if (!dir1.mkdirs()) return;
|
||||||
dir2.mkdirs();
|
if (!dir2.mkdirs()) return;
|
||||||
|
|
||||||
// Client 1
|
// Client 1
|
||||||
SecureCompletionClient client1 = new SecureCompletionClient();
|
SecureCompletionClient client1 = new SecureCompletionClient();
|
||||||
|
|
|
||||||
|
|
@ -104,11 +104,12 @@ class SecureMemoryTest {
|
||||||
@DisplayName("SecureBuffer zero should clear all bytes")
|
@DisplayName("SecureBuffer zero should clear all bytes")
|
||||||
void secureBuffer_zero_shouldClearBytes() {
|
void secureBuffer_zero_shouldClearBytes() {
|
||||||
byte[] data = new byte[]{(byte)0xFF, (byte)0xFF, (byte)0xFF, (byte)0xFF};
|
byte[] data = new byte[]{(byte)0xFF, (byte)0xFF, (byte)0xFF, (byte)0xFF};
|
||||||
SecureMemory.SecureBuffer buffer = SecureMemory.secureByteArray(data);
|
try (SecureMemory.SecureBuffer buffer = SecureMemory.secureByteArray(data)) {
|
||||||
|
|
||||||
buffer.zero();
|
buffer.zero();
|
||||||
|
|
||||||
assertDoesNotThrow(() -> buffer.zero(), "Zeroing should not throw");
|
assertDoesNotThrow(buffer::zero, "Zeroing should not throw");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -127,29 +128,32 @@ class SecureMemoryTest {
|
||||||
@DisplayName("SecureBuffer close should be idempotent")
|
@DisplayName("SecureBuffer close should be idempotent")
|
||||||
void secureBuffer_close_idempotent() {
|
void secureBuffer_close_idempotent() {
|
||||||
byte[] data = new byte[]{1, 2, 3};
|
byte[] data = new byte[]{1, 2, 3};
|
||||||
SecureMemory.SecureBuffer buffer = SecureMemory.secureByteArray(data);
|
try (SecureMemory.SecureBuffer buffer = SecureMemory.secureByteArray(data)) {
|
||||||
|
|
||||||
assertDoesNotThrow(() -> buffer.close(), "First close should not throw");
|
assertDoesNotThrow(buffer::close, "First close should not throw");
|
||||||
assertDoesNotThrow(() -> buffer.close(), "Second close should not throw");
|
assertDoesNotThrow(buffer::close, "Second close should not throw");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("SecureBuffer lock should return false (not supported)")
|
@DisplayName("SecureBuffer lock should return false (not supported)")
|
||||||
void secureBuffer_lock_shouldReturnFalse() {
|
void secureBuffer_lock_shouldReturnFalse() {
|
||||||
byte[] data = new byte[]{1, 2, 3};
|
byte[] data = new byte[]{1, 2, 3};
|
||||||
SecureMemory.SecureBuffer buffer = SecureMemory.secureByteArray(data, true);
|
try (SecureMemory.SecureBuffer buffer = SecureMemory.secureByteArray(data, true)) {
|
||||||
|
|
||||||
assertFalse(buffer.lock(), "Lock should return false (not supported)");
|
assertFalse(buffer.lock(), "Lock should return false (not supported)");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("SecureBuffer unlock should return false")
|
@DisplayName("SecureBuffer unlock should return false")
|
||||||
void secureBuffer_unlock_shouldReturnFalse() {
|
void secureBuffer_unlock_shouldReturnFalse() {
|
||||||
byte[] data = new byte[]{1, 2, 3};
|
byte[] data = new byte[]{1, 2, 3};
|
||||||
SecureMemory.SecureBuffer buffer = SecureMemory.secureByteArray(data, true);
|
try (SecureMemory.SecureBuffer buffer = SecureMemory.secureByteArray(data, true)) {
|
||||||
|
|
||||||
assertFalse(buffer.unlock(), "Unlock should return false");
|
assertFalse(buffer.unlock(), "Unlock should return false");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("HAS_MEMORY_LOCKING should be false")
|
@DisplayName("HAS_MEMORY_LOCKING should be false")
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ class SplitterTest {
|
||||||
List<String> result = Splitter.fixedLengthString(10, "1234567890");
|
List<String> result = Splitter.fixedLengthString(10, "1234567890");
|
||||||
|
|
||||||
assertEquals(1, result.size(), "Should have 1 part");
|
assertEquals(1, result.size(), "Should have 1 part");
|
||||||
assertEquals("1234567890", result.get(0), "Single part should be the full string");
|
assertEquals("1234567890", result.getFirst(), "Single part should be the full string");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -45,7 +45,7 @@ class SplitterTest {
|
||||||
List<String> result = Splitter.fixedLengthString(100, "hello");
|
List<String> result = Splitter.fixedLengthString(100, "hello");
|
||||||
|
|
||||||
assertEquals(1, result.size(), "Should have 1 part");
|
assertEquals(1, result.size(), "Should have 1 part");
|
||||||
assertEquals("hello", result.get(0), "Single part should be 'hello'");
|
assertEquals("hello", result.getFirst(), "Single part should be 'hello'");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -81,9 +81,9 @@ class SplitterTest {
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("fixedLengthString should handle unicode characters")
|
@DisplayName("fixedLengthString should handle unicode characters")
|
||||||
void fixedLengthString_unicode_shouldSplitCorrectly() {
|
void fixedLengthString_unicode_shouldSplitCorrectly() {
|
||||||
List<String> result = Splitter.fixedLengthString(2, "ab\u00e9\u00fc");
|
List<String> result = Splitter.fixedLengthString(2, "abéü");
|
||||||
|
|
||||||
assertEquals(2, result.size(), "Should have 2 parts");
|
assertEquals(2, result.size(), "Should have 2 parts");
|
||||||
assertEquals("ab", result.get(0));
|
assertEquals("ab", result.getFirst());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue