diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..9f87804 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,51 @@ +# nomyo4J — Agent Instructions + +Java port of the NOMYO Python client. Hybrid encryption (RSA-4096 + AES-256-GCM) for secure API communication. + +## Build & Run + +``` +mvn compile # Java 25, Lombok annotation processor +mvn test # JUnit Jupiter 5.12.1, @Order enforced +mvn test -Dtest=ClassName # single test class +``` + +## Architecture + +- **`SecureCompletionClient`** — low-level client: key mgmt, HTTP, encryption, decryption +- **`SecureChatCompletion`** — high-level OpenAI-compatible surface (`create()`, `acreate()`) +- **`Constants`** — all protocol/crypto constants (version, algorithms, timeouts) +- **`SecureMemory`** — Java 25 FFM `SecureBuffer` for locked/zeroed memory +- **`errors/`** — exception hierarchy, all `extends Exception` (checked) +- **`util/`** — `Pass2Key` (PBKDF2 + AES-GCM), `PEMConverter`, `Splitter` + +## Critical: This is a partial/in-progress port + +Many methods are stubbed with `UnsupportedOperationException`. Before implementing, check `TRANSLATION_REFERENCE.md` for the Python reference. Stubbed methods: + +- `SecureCompletionClient.fetchServerPublicKey()` — GET `/pki/public_key` +- `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. + +## Key files + +- `TRANSLATION_REFERENCE.md` — **primary documentation**. Cross-language spec derived from Python reference. Read before implementing any method. +- `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. + +## Conventions + +- Package: `ai.nomyo` +- Lombok: `@Getter` on fields, `@Setter` on static flags +- Tests: `@TestMethodOrder(OrderAnnotation.class)`, `@DisplayName` on every test +- Error classes: checked exceptions with `status_code` and `error_details` +- Key files: `PosixFilePermissions.OWNER_READ` only (mode 400) +- RSA: 4096-bit, exponent 65537, OAEP-SHA256 padding +- Protocol constants in `Constants.java` — marked "never change" diff --git a/src/main/java/ai/nomyo/Constants.java b/src/main/java/ai/nomyo/Constants.java index 25e249d..aacd1b4 100644 --- a/src/main/java/ai/nomyo/Constants.java +++ b/src/main/java/ai/nomyo/Constants.java @@ -4,63 +4,51 @@ package ai.nomyo; import java.util.Set; /** - * Constants used throughout the NOMYO Java client library. - * - *

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

+ * Protocol, crypto, and configuration constants. Immutable — used for downgrade detection. */ public final class Constants { // ── Protocol Constants ────────────────────────────────────────── /** - * Protocol version string. Never change — used for downgrade detection. + * Protocol version — never change (downgrade detection). */ public static final String PROTOCOL_VERSION = "1.0"; - /** - * Hybrid encryption algorithm identifier. Never change — used for downgrade detection. + * Hybrid encryption algorithm identifier — never change (downgrade detection). */ public static final String HYBRID_ALGORITHM = "hybrid-aes256-rsa4096"; - /** - * RSA-OAEP-SHA256 key wrapping algorithm identifier. + * RSA-OAEP-SHA256 key wrapping algorithm. */ public static final String KEY_WRAP_ALGORITHM = "RSA-OAEP-SHA256"; - /** - * AES-256-GCM payload encryption algorithm identifier. + * AES-256-GCM payload encryption algorithm. */ public static final String PAYLOAD_ALGORITHM = "AES-256-GCM"; // ── Cryptographic Constants ───────────────────────────────────── /** - * RSA key size in bits. Fixed at 4096. + * RSA key size in bits. */ public static final int RSA_KEY_SIZE = 4096; - /** - * RSA public exponent. Fixed at 65537. + * RSA public exponent. */ public static final int RSA_PUBLIC_EXPONENT = 65537; - /** - * AES key size in bytes (256-bit). Per-request ephemeral. + * AES key size in bytes (256-bit). */ public static final int AES_KEY_SIZE = 32; - /** - * GCM nonce size in bytes (96-bit). Per-request. + * GCM nonce size in bytes (96-bit). */ 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). */ @@ -69,7 +57,7 @@ public final class Constants { // ── Payload Limits ────────────────────────────────────────────── /** - * Maximum payload size in bytes (10 MB). Used for DoS protection. + * Maximum payload size in bytes (10 MB). */ public static final long MAX_PAYLOAD_SIZE = 10L * 1024 * 1024; @@ -79,61 +67,53 @@ public final class 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… + * Default 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. + * Retryable HTTP status codes. */ public static final Set RETRYABLE_STATUS_CODES = Set.of(429, 500, 502, 503, 504); // ── File Permission Constants ─────────────────────────────────── /** - * File permission for private key files (owner read/write only). + * Private key file permission (owner rw only). */ public static final String PRIVATE_KEY_FILE_MODE = "rw-------"; - /** - * File permission for public key files (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--"; // ── Security Tier Constants ───────────────────────────────────── /** - * Valid security tier values. Case-sensitive. + * Valid security tier values (case-sensitive). */ public static final Set VALID_SECURITY_TIERS = Set.of("standard", "high", "maximum"); - /** - * Standard security tier — GPU general secure inference. + * GPU general secure inference. */ public static final String SECURITY_TIER_STANDARD = "standard"; - /** - * High security tier — CPU/GPU for sensitive business data. + * CPU/GPU for sensitive business data. */ public static final String SECURITY_TIER_HIGH = "high"; - /** - * Maximum security tier — CPU only for PHI/classified data. + * CPU only for PHI/classified data. */ public static final String SECURITY_TIER_MAXIMUM = "maximum"; // ── Endpoint Paths ────────────────────────────────────────────── /** - * PKI public key endpoint path. + * PKI public key endpoint. */ public static final String PKI_PUBLIC_KEY_PATH = "/pki/public_key"; - /** - * Secure chat completion endpoint path. + * Secure chat completion endpoint. */ public static final String SECURE_COMPLETION_PATH = "/v1/chat/secure_completion"; @@ -143,24 +123,20 @@ public final class Constants { * Content-Type for encrypted payloads. */ public static final String CONTENT_TYPE_OCTET_STREAM = "application/octet-stream"; - /** - * HTTP header name for payload ID. + * Header name for payload ID. */ public static final String HEADER_PAYLOAD_ID = "X-Payload-ID"; - /** - * HTTP header name for client public key. + * Header name for client public key. */ public static final String HEADER_PUBLIC_KEY = "X-Public-Key"; - /** - * HTTP header name for security tier. + * Header name for security tier. */ public static final String HEADER_SECURITY_TIER = "X-Security-Tier"; - /** - * HTTP header prefix for Bearer token authorization. + * Bearer token prefix. */ public static final String AUTHORIZATION_BEARER_PREFIX = "Bearer "; @@ -170,17 +146,14 @@ public final class Constants { * Default NOMYO router base URL. */ public static final String DEFAULT_BASE_URL = "https://api.nomyo.ai"; - /** - * Default key directory name for persisted keys. + * Default key directory name. */ 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. */ @@ -189,7 +162,7 @@ public final class Constants { // ── Memory Protection Constants ───────────────────────────────── /** - * Page size used for memory locking calculations (typically 4096 bytes). + * Page size for memory locking calculations. */ public static final int PAGE_SIZE = 4096; } diff --git a/src/main/java/ai/nomyo/Main.java b/src/main/java/ai/nomyo/Main.java index 26a9c4d..786f2bb 100644 --- a/src/main/java/ai/nomyo/Main.java +++ b/src/main/java/ai/nomyo/Main.java @@ -3,8 +3,7 @@ package ai.nomyo; import ai.nomyo.errors.SecurityError; /** - * @author NieGestorben - * Copyright© (c) 2026, All Rights Reserved. + * Entry point — loads RSA keys and validates key length. */ public class Main { @@ -16,7 +15,7 @@ public class Main { try { secureCompletionClient.validateRsaKey(secureCompletionClient.getPrivateKey()); } catch (SecurityError e) { - System.out.println("RSA Key is to short!"); + System.out.println("RSA Key is too short!"); return; } diff --git a/src/main/java/ai/nomyo/SecureChatCompletion.java b/src/main/java/ai/nomyo/SecureChatCompletion.java index 4823961..879c121 100644 --- a/src/main/java/ai/nomyo/SecureChatCompletion.java +++ b/src/main/java/ai/nomyo/SecureChatCompletion.java @@ -7,45 +7,7 @@ import java.util.List; import java.util.Map; /** - * High-level OpenAI-compatible entrypoint for the NOMYO secure API. - * - *

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

- * - *

Usage

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

Streaming

- *

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

- * - *

Security Tiers

- *

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

- * - * - *

Key Persistence

- *

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

+ * High-level OpenAI-compatible entrypoint with automatic hybrid encryption (AES-256-GCM + RSA-4096). */ @Getter public class SecureChatCompletion { @@ -55,83 +17,45 @@ public class SecureChatCompletion { private final String keyDir; /** - * Constructs a {@code SecureChatCompletion} with default settings. - * - *

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

+ * Default settings: {@code https://api.nomyo.ai}, HTTPS-only, secure memory, ephemeral keys, 2 retries. */ public SecureChatCompletion() { this(Constants.DEFAULT_BASE_URL, false, null, true, null, Constants.DEFAULT_MAX_RETRIES); } /** - * Constructs a {@code SecureChatCompletion} with the specified settings. - * - * @param baseUrl NOMYO Router base URL (HTTPS enforced unless {@code allowHttp} is {@code true}) - * @param allowHttp permit {@code http://} URLs (development only) - * @param apiKey Bearer token for authentication (can also be passed per-call via {@link #create}) - * @param secureMemory enable memory locking/zeroing (warns if unavailable) - * @param keyDir directory to persist RSA keys; {@code null} = ephemeral (in-memory only) - * @param maxRetries retries on 429/500/502/503/504 + network errors (exponential backoff: 1s, 2s, 4s…) + * @param baseUrl NOMYO Router base URL (HTTPS enforced unless {@code allowHttp}) + * @param allowHttp permit {@code http://} URLs (development only) + * @param apiKey Bearer token (can also be passed per-call via {@link #create}) + * @param secureMemory enable memory locking/zeroing + * @param keyDir RSA key directory; {@code null} = ephemeral + * @param maxRetries retries on 429/500/502/503/504 + network errors (exponential backoff) */ - public SecureChatCompletion( - String baseUrl, - boolean allowHttp, - String apiKey, - boolean secureMemory, - String keyDir, - int maxRetries - ) { + 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. + * Main entrypoint — same signature as {@code openai.ChatCompletion.create()}. + * All kwargs are passed through to the OpenAI-compatible API. + *

Streaming is not supported (server rejects with HTTP 400). + * Security tiers: "standard", "high", "maximum". * - *

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

- * - *

Parameters

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

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

- * - *

Encryption Wire Format

- *

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

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

Key Lifecycle

- *

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

+ * Low-level client: key management, hybrid encryption, HTTP with retry, response decryption. Used by {@link SecureChatCompletion}. */ public class SecureCompletionClient { - // ── Instance Attributes ───────────────────────────────────────── - /** - * Base URL of the NOMYO router (trailing slash stripped). + * NOMYO router base URL (trailing slash stripped). */ @Getter private final String routerUrl; /** - * Whether HTTP (non-HTTPS) URLs are permitted. + * Permit HTTP (non-HTTPS) URLs. */ @Getter private final boolean allowHttp; /** - * RSA key size in bits. Always {@link Constants#RSA_KEY_SIZE}. + * RSA key size in bits ({@link Constants#RSA_KEY_SIZE}). */ @Getter private final int keySize; /** - * Maximum number of retries for retryable errors. + * Max retries on retryable errors. */ @Getter private final int maxRetries; /** - * Whether secure memory operations are active. + * Secure memory operations active. */ @Getter private final boolean useSecureMemory; @@ -88,38 +63,34 @@ public class SecureCompletionClient { private final ReentrantLock keyInitLock = new ReentrantLock(); /** - * RSA private key, or {@code null} if not yet loaded/generated. + * RSA private key ({@code null} until loaded/generated). */ @Getter private PrivateKey privateKey; - // ── Internal State ────────────────────────────────────────────── - /** - * PEM-encoded public key string, or {@code null} if not yet loaded/generated. + * PEM-encoded public key ({@code null} until loaded/generated). */ @Getter private String publicPemKey; /** - * Whether keys have been initialized. + * Keys initialized. */ private volatile boolean keysInitialized = false; /** - * Constructs a {@code SecureCompletionClient} with default settings. + * Default settings: {@code https://api.nomyo.ai}, HTTPS-only, secure memory, 2 retries. */ 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 + * @param routerUrl NOMYO router base URL + * @param allowHttp permit HTTP URLs + * @param secureMemory enable memory locking/zeroing + * @param maxRetries retries on retryable errors */ public SecureCompletionClient(String routerUrl, boolean allowHttp, boolean secureMemory, int maxRetries) { this.routerUrl = routerUrl != null ? routerUrl.replaceAll("/+$", "") : Constants.DEFAULT_BASE_URL; @@ -129,33 +100,12 @@ public class SecureCompletionClient { 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(); + private static String readFileContent(String filePath) throws IOException { + return Files.readString(Path.of(filePath)); } /** - * Generates a new 4096-bit RSA key pair. - * - *

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

- * - * @param saveToFile whether to save the keys to disk - * @param keyDir directory to save keys (ignored if {@code saveToFile} is {@code false}) - * @param password optional password to encrypt the private key file + * Generates a 4096-bit RSA key pair (exponent 65537). Saves to disk if {@code saveToFile}. */ public void generateKeys(boolean saveToFile, String keyDir, String password) { try { @@ -167,10 +117,14 @@ public class SecureCompletionClient { 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); + if (saveToFile) { + Path keyFolder = Path.of(keyDir); + if (!Files.exists(keyFolder)) { + try { + Files.createDirectories(keyFolder); + } catch (IOException e) { + throw new IOException("Failed to create key directory: " + keyDir, e); + } } Path privateKeyPath = Path.of(keyDir, Constants.DEFAULT_PRIVATE_KEY_FILE); @@ -178,7 +132,7 @@ public class SecureCompletionClient { Set filePermissions = PosixFilePermissions.fromString(Constants.PRIVATE_KEY_FILE_MODE); Files.createFile(privateKeyPath, PosixFilePermissions.asFileAttribute(filePermissions)); - try (FileWriter fileWriter = new FileWriter(privateKeyPath.toFile())) { + try (var writer = Files.newBufferedWriter(privateKeyPath)) { if (password == null || password.isEmpty()) { System.out.println("WARNING: Saving keys in plaintext!"); } else { @@ -188,11 +142,8 @@ public class SecureCompletionClient { InvalidKeyException e) { throw new RuntimeException(e); } - } - - fileWriter.write(privatePem); - fileWriter.flush(); + writer.write(privatePem); } } @@ -201,9 +152,8 @@ public class SecureCompletionClient { Set publicPermissions = PosixFilePermissions.fromString(Constants.PUBLIC_KEY_FILE_MODE); Files.createFile(publicKeyPath, PosixFilePermissions.asFileAttribute(publicPermissions)); - try (FileWriter fileWriter = new FileWriter(publicKeyPath.toFile())) { - fileWriter.write(publicPem); - fileWriter.flush(); + try (var writer = Files.newBufferedWriter(publicKeyPath)) { + writer.write(publicPem); } } } @@ -221,45 +171,47 @@ public class SecureCompletionClient { } /** - * Generates a new 4096-bit RSA key pair and saves to the default directory. - * - * @param saveToFile whether to save the keys to disk + * Generates a 4096-bit RSA key pair and saves to the default directory. */ public void generateKeys(boolean saveToFile) { generateKeys(saveToFile, Constants.DEFAULT_KEY_DIR, null); } /** - * Loads an RSA private key from disk. + * Loads RSA private key from disk. If {@code publicPemKeyPath} is {@code null}, derives public key. + * Validates key size >= {@link Constants#MIN_RSA_KEY_SIZE} bits. * - *

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

- * - * @param privateKeyPath path to the private key PEM file - * @param publicPemKeyPath optional path to the public key PEM file - * @param password optional password for the encrypted private key + * @param privateKeyPath private key PEM path + * @param publicPemKeyPath optional public key PEM path + * @param password optional password for encrypted private key */ public void loadKeys(String privateKeyPath, String publicPemKeyPath, String password) { - File keyFile = new File(privateKeyPath); - if (!keyFile.exists()) { + Path keyPath = Path.of(privateKeyPath); + if (!Files.exists(keyPath)) { throw new RuntimeException("Private key file not found: " + privateKeyPath); } String keyContent; if (password != null && !password.isEmpty()) { - keyContent = getEncryptedPrivateKeyFromFile(privateKeyPath); + try { + keyContent = readFileContent(privateKeyPath); + } catch (IOException e) { + throw new RuntimeException("Failed to read private key file: " + e.getMessage(), e); + } try { keyContent = Pass2Key.decrypt("AES/GCM/NoPadding", keyContent, password); - } catch (NoSuchPaddingException | NoSuchAlgorithmException - | BadPaddingException | IllegalBlockSizeException | InvalidAlgorithmParameterException | - InvalidKeyException e) { + } catch (NoSuchPaddingException | NoSuchAlgorithmException | BadPaddingException | + IllegalBlockSizeException | InvalidAlgorithmParameterException | InvalidKeyException e) { System.out.println("Wrong password!"); return; } } else { - keyContent = getEncryptedPrivateKeyFromFile(privateKeyPath); + try { + keyContent = readFileContent(privateKeyPath); + } catch (IOException e) { + throw new RuntimeException("Failed to read private key file: " + e.getMessage(), e); + } } try { @@ -270,194 +222,108 @@ public class SecureCompletionClient { } /** - * 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 + * Loads RSA private key from disk, deriving public key. */ public void loadKeys(String privateKeyPath, String password) { loadKeys(privateKeyPath, null, password); } - // ── Server Key Fetching ───────────────────────────────────────── - /** - * Fetches the server's RSA public key from the PKI endpoint. - * - *

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

- * - * @return the server's PEM-encoded public key string - * @throws SecurityError if the URL is not HTTPS and {@code allowHttp} is {@code false}, - * or if the response does not contain a valid PEM public key + * GET {@code {routerUrl}/pki/public_key}. Returns server PEM public key. */ public CompletableFuture fetchServerPublicKey() { throw new UnsupportedOperationException("Not yet implemented"); } - // ── Encryption ────────────────────────────────────────────────── - /** - * Encrypts a payload dict using hybrid encryption. + * Hybrid encryption: AES-256-GCM for payload, RSA-OAEP-SHA256 for AES key wrapping. * - *

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

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

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

Headers: Content-Type=octet-stream, X-Payload-ID, X-Public-Key, Authorization (Bearer), X-Security-Tier. + * Retryable: 429, 500, 502, 503, 504 + network errors. Backoff: 2^(attempt-1)s. + *

Status mapping: 200→return, 400→InvalidRequestError, 401→AuthenticationError, 403→ForbiddenError, + * 404→APIError, 429→RateLimitError, 500→ServerError, 503→ServiceUnavailableError, + * 502/504→APIError(retryable), network→APIConnectionError. * - *

Request Headers

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

POST

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

Retry Logic

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

Status → Exception Mapping

- * - * - * - * - * - * - * - * - * - * - * - * - * - *
StatusResult
200Return decrypted response map
400{@code InvalidRequestError}
401{@code AuthenticationError}
403{@code ForbiddenError}
404{@code APIError}
429{@code RateLimitError}
500{@code ServerError}
503{@code ServiceUnavailableError}
502/504{@code APIError} (retryable)
other{@code APIError} (non-retryable)
network error{@code APIConnectionError}
- * - * @param payload the payload to send (OpenAI-compatible chat parameters) + * @param payload 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 + * @param apiKey optional API key + * @param securityTier optional: "standard", "high", "maximum" + * @return decrypted response map + * @throws SecurityError encryption/decryption failure + * @throws APIConnectionError network error + * @throws InvalidRequestError HTTP 400 + * @throws AuthenticationError HTTP 401 + * @throws ForbiddenError HTTP 403 + * @throws RateLimitError HTTP 429 + * @throws ServerError HTTP 500 + * @throws ServiceUnavailableError HTTP 503 + * @throws APIError other errors */ - public CompletableFuture> sendSecureRequest( - Map payload, - String payloadId, - String apiKey, - String securityTier - ) { + public CompletableFuture> sendSecureRequest(Map payload, String payloadId, String apiKey, String securityTier) { throw new UnsupportedOperationException("Not yet implemented"); } /** - * Sends a secure request without a security tier. - * - * @param payload the payload to send - * @param payloadId unique payload identifier - * @param apiKey optional API key for authentication - * @return the decrypted response map + * Without security tier. */ - public CompletableFuture> sendSecureRequest( - Map payload, - String payloadId, - String apiKey - ) { + public CompletableFuture> sendSecureRequest(Map payload, String payloadId, String apiKey) { return sendSecureRequest(payload, payloadId, apiKey, null); } /** - * Sends a secure request with no API key or security tier. - * - * @param payload the payload to send - * @param payloadId unique payload identifier - * @return the decrypted response map + * No API key or security tier. */ - public CompletableFuture> sendSecureRequest( - Map payload, - String payloadId - ) { + public CompletableFuture> sendSecureRequest(Map payload, String payloadId) { return sendSecureRequest(payload, payloadId, null, null); } - // ── Key Initialization ────────────────────────────────────────── - /** - * Ensures RSA keys are loaded or generated. + * Thread-safe key init via double-checked locking. Loads from disk if {@code keyDir} set, else generates. * - *

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

- * - * @param keyDir directory to persist keys, or {@code null} for ephemeral + * @param keyDir key directory or {@code null} for ephemeral */ public void ensureKeys(String keyDir) { if (keysInitialized) return; keyInitLock.lock(); try { if (keysInitialized) return; - // TODO: implement key loading/generation + if (keyDir == null || keyDir.isEmpty()) { + generateKeys(false); + } else { + generateKeys(true); + } 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 + * Validates RSA key size >= {@link Constants#MIN_RSA_KEY_SIZE} bits. */ public void validateRsaKey(PrivateKey key) throws SecurityError { if (key == null) { @@ -466,77 +332,59 @@ public class SecureCompletionClient { int keySize = extractKeySize(key); if (keySize < Constants.MIN_RSA_KEY_SIZE) { - throw new SecurityError( - "RSA key size " + keySize + " bits is below minimum " + Constants.MIN_RSA_KEY_SIZE + " bits" - ); + throw new SecurityError("RSA key size " + keySize + " bits is below minimum " + Constants.MIN_RSA_KEY_SIZE + " bits"); } } private int extractKeySize(PrivateKey key) { try { - java.security.KeyFactory kf = java.security.KeyFactory.getInstance("RSA"); - java.security.spec.RSAPrivateCrtKeySpec crtSpec = kf.getKeySpec(key, java.security.spec.RSAPrivateCrtKeySpec.class); - return crtSpec.getModulus().bitLength(); - } catch (Exception ignored) { - // Try RSAPrivateKeySpec + var kf = KeyFactory.getInstance("RSA"); try { - java.security.KeyFactory kf = java.security.KeyFactory.getInstance("RSA"); - java.security.spec.RSAPrivateKeySpec privSpec = kf.getKeySpec(key, java.security.spec.RSAPrivateKeySpec.class); + var crtSpec = kf.getKeySpec(key, java.security.spec.RSAPrivateCrtKeySpec.class); + return crtSpec.getModulus().bitLength(); + } catch (Exception ignored) { + var privSpec = kf.getKeySpec(key, java.security.spec.RSAPrivateKeySpec.class); return privSpec.getModulus().bitLength(); - } catch (Exception ignored2) { - // Fall back to encoded length - if (key.getEncoded() != null) { - return key.getEncoded().length * 8; - } + } + } catch (Exception ignored) { + if (key.getEncoded() != null) { + return key.getEncoded().length * 8; } } return 0; } - // ── HTTP Status → Exception Mapping ───────────────────────────── - /** - * Maps an HTTP status code to the appropriate exception. + * Maps HTTP status code to exception (200→null). */ 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")); - } + return switch (statusCode) { + case 200 -> null; + case 400 -> + new InvalidRequestError("Invalid request: " + (responseBody != null ? responseBody : "no body")); + case 401 -> + new AuthenticationError("Authentication failed: " + (responseBody != null ? responseBody : "no body")); + case 403 -> new ForbiddenError("Access forbidden: " + (responseBody != null ? responseBody : "no body")); + case 404 -> new APIError("Not found: " + (responseBody != null ? responseBody : "no body")); + case 429 -> new RateLimitError("Rate limit exceeded: " + (responseBody != null ? responseBody : "no body")); + case 500 -> new ServerError("Internal server error: " + (responseBody != null ? responseBody : "no body")); + case 503 -> + new ServiceUnavailableError("Service unavailable: " + (responseBody != null ? responseBody : "no body")); + case 502, 504 -> new APIError("Gateway error: " + (responseBody != null ? responseBody : "no body")); + default -> + 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. + * URL-encodes PEM key for {@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. + * Delegates to resource cleanup (stub). */ public void close() { throw new UnsupportedOperationException("Not yet implemented"); diff --git a/src/main/java/ai/nomyo/SecureMemory.java b/src/main/java/ai/nomyo/SecureMemory.java index e16d7fc..ee94d3e 100644 --- a/src/main/java/ai/nomyo/SecureMemory.java +++ b/src/main/java/ai/nomyo/SecureMemory.java @@ -8,43 +8,22 @@ import java.lang.foreign.MemorySegment; import java.util.Map; /** - * Cross-platform memory locking and secure zeroing utilities. - * - *

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

- * - *

Protection Levels

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

Usage

- *
{@code
- * try (SecureBuffer buf = SecureMemory.secureBytearray(sensitiveData)) {
- *     // buf is locked and ready to use
- *     process(buf.getData());
- * }
- * // buf is automatically zeroed and unlocked on exit
- * }
+ * Cross-platform memory locking and secure zeroing for sensitive cryptographic buffers. Fails gracefully if unavailable. */ 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; + @Getter + @Setter + private static volatile boolean secureMemoryEnabled = true; static { boolean locking = false; boolean zeroing = false; + try { locking = initMemoryLocking(); zeroing = true; // Secure zeroing is always available at the JVM level @@ -58,16 +37,59 @@ public final class SecureMemory { private static boolean initMemoryLocking() { // FFM doesn't support memory locking at this point in time - // TODO: Bypass this with native libraries return false; } /** - * Wraps a byte array with memory locking and guaranteed zeroing on exit. - * - *

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

+ * Recommended way to handle sensitive data — use within try-with-resources for secure zeroing. + */ + public static SecureBuffer secureByteArray(byte[] data, boolean lock) { + return new SecureBuffer(data, lock); + } + + /** + * Always attempts locking. + */ + public static SecureBuffer secureByteArray(byte[] data) { + return secureByteArray(data, true); + } + + /** + * @deprecated Use {@link #secureByteArray(byte[])} instead. + */ + @Deprecated + public static SecureBuffer secureBytes(byte[] data, boolean lock) { + return new SecureBuffer(data, lock); + } + + /** + * @deprecated Use {@link #secureByteArray(byte[])} instead. + */ + @Deprecated + public static SecureBuffer secureBytes(byte[] data) { + return secureBytes(data, true); + } + + /** + * Returns protection capabilities: enabled, protection_level, has_memory_locking, has_secure_zeroing, supports_full_protection, page_size. + */ + public static Map getMemoryProtectionInfo() { + String protectionLevel; + if (HAS_MEMORY_LOCKING && HAS_SECURE_ZEROING) { + protectionLevel = "full"; + } else if (HAS_SECURE_ZEROING) { + protectionLevel = "zeroing_only"; + } else { + protectionLevel = "none"; + } + + boolean supportsFull = HAS_MEMORY_LOCKING && HAS_SECURE_ZEROING && secureMemoryEnabled; + + return Map.of("enabled", secureMemoryEnabled, "protection_level", protectionLevel, "has_memory_locking", HAS_MEMORY_LOCKING, "has_secure_zeroing", HAS_SECURE_ZEROING, "supports_full_protection", supportsFull, "page_size", Constants.PAGE_SIZE); + } + + /** + * Wraps bytes with memory locking and guaranteed zeroing on close. AutoCloseable for try-with-resources. */ public static class SecureBuffer implements AutoCloseable { @@ -85,10 +107,8 @@ public final class SecureMemory { 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 + * @param data byte array to wrap + * @param lock whether to attempt memory locking */ public SecureBuffer(byte[] data, boolean lock) { this.arena = Arena.ofConfined(); @@ -110,19 +130,14 @@ public final class SecureMemory { } /** - * Attempts to lock the buffer in memory, preventing swapping to disk. - * - * @return {@code true} if locking succeeded, {@code false} otherwise + * Locks buffer in memory (prevents disk swapping). Returns false if unavailable. */ 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 + * Unlocks buffer (allows disk swapping). */ public boolean unlock() { if (!locked) return false; @@ -131,7 +146,7 @@ public final class SecureMemory { } /** - * Securely zeros the buffer contents. + * Securely zeros buffer contents. */ public void zero() { if (data != null) { @@ -150,88 +165,4 @@ public final class SecureMemory { closed = true; } } - - /** - * Creates a SecureBuffer for the given data with memory locking. - * - *

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

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

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

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

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

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

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

- */ +/** Network-level errors: timeouts, DNS failures, TLS handshake failures. No HTTP status code. */ public class APIConnectionError extends Exception { - /** - * Constructs an {@code APIConnectionError} with the specified detail message. - * - * @param message the detail message - */ public APIConnectionError(String message) { super(message); } - /** - * Constructs an {@code APIConnectionError} with the specified detail message - * and cause. - * - * @param message the detail message - * @param cause the cause of this exception, or {@code null} - */ public APIConnectionError(String message, Throwable cause) { super(message, cause); } - /** - * Constructs an {@code APIConnectionError} with the specified cause. - * - * @param cause the cause of this exception - */ public APIConnectionError(Throwable cause) { super(cause); } diff --git a/src/main/java/ai/nomyo/errors/APIError.java b/src/main/java/ai/nomyo/errors/APIError.java index df4dc36..801bd9d 100644 --- a/src/main/java/ai/nomyo/errors/APIError.java +++ b/src/main/java/ai/nomyo/errors/APIError.java @@ -1,26 +1,23 @@ package ai.nomyo.errors; +import lombok.Getter; + import java.util.Collections; import java.util.Map; -/** - * Base exception for all NOMYO API errors. - * - *

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

- */ +/** Base exception for all NOMYO API errors. Carries HTTP status code and optional error details. */ +@Getter public class APIError extends Exception { + /** HTTP status code ({@code null} if not applicable). */ private final Integer statusCode; + /** Unmodifiable map of error details from server (never {@code null}). */ private final Map errorDetails; /** - * Constructs an {@code APIError} with the specified detail message, - * status code, and error details. - * - * @param message the detail message - * @param statusCode the HTTP status code, or {@code null} if not applicable - * @param errorDetails additional error details from the server, or {@code null} + * @param message detail message + * @param statusCode HTTP status code ({@code null} if not applicable) + * @param errorDetails error details from server ({@code null} for empty) */ public APIError(String message, Integer statusCode, Map errorDetails) { super(message); @@ -30,30 +27,9 @@ public class APIError extends Exception { : Collections.emptyMap(); } - /** - * Constructs an {@code APIError} with the specified detail message. - * - * @param message the detail message - */ + /** Convenience: no status code or details. */ public APIError(String message) { this(message, null, null); } - /** - * Returns the HTTP status code associated with this error, or {@code null}. - * - * @return the status code, or {@code null} - */ - public Integer getStatusCode() { - return statusCode; - } - - /** - * Returns an unmodifiable map of additional error details from the server. - * - * @return the error details map (never {@code null}) - */ - public Map getErrorDetails() { - return errorDetails; - } } diff --git a/src/main/java/ai/nomyo/errors/AuthenticationError.java b/src/main/java/ai/nomyo/errors/AuthenticationError.java index a219789..7ae3f16 100644 --- a/src/main/java/ai/nomyo/errors/AuthenticationError.java +++ b/src/main/java/ai/nomyo/errors/AuthenticationError.java @@ -2,39 +2,17 @@ package ai.nomyo.errors; import java.util.Map; -/** - * Exception thrown when the API returns a 401 (Unauthorized) status. - */ +/** HTTP 401 (Unauthorized). */ public class AuthenticationError extends APIError { - /** - * Constructs an {@code AuthenticationError} with the specified detail message, - * status code, and error details. - * - * @param message the detail message - * @param statusCode the HTTP status code (must be 401) - * @param errorDetails additional error details from the server, or {@code null} - */ public AuthenticationError(String message, Integer statusCode, Map errorDetails) { super(message, statusCode, errorDetails); } - /** - * Constructs an {@code AuthenticationError} with the specified detail message. - * - * @param message the detail message - */ public AuthenticationError(String message) { super(message, 401, null); } - /** - * Constructs an {@code AuthenticationError} with the specified detail message - * and error details. - * - * @param message the detail message - * @param errorDetails additional error details from the server, or {@code null} - */ public AuthenticationError(String message, Map errorDetails) { super(message, 401, errorDetails); } diff --git a/src/main/java/ai/nomyo/errors/ForbiddenError.java b/src/main/java/ai/nomyo/errors/ForbiddenError.java index 7d9125b..f677533 100644 --- a/src/main/java/ai/nomyo/errors/ForbiddenError.java +++ b/src/main/java/ai/nomyo/errors/ForbiddenError.java @@ -2,39 +2,17 @@ package ai.nomyo.errors; import java.util.Map; -/** - * Exception thrown when the API returns a 403 (Forbidden) status. - */ +/** HTTP 403 (Forbidden). */ public class ForbiddenError extends APIError { - /** - * Constructs a {@code ForbiddenError} with the specified detail message, - * status code, and error details. - * - * @param message the detail message - * @param statusCode the HTTP status code (must be 403) - * @param errorDetails additional error details from the server, or {@code null} - */ public ForbiddenError(String message, Integer statusCode, Map errorDetails) { super(message, statusCode, errorDetails); } - /** - * Constructs a {@code ForbiddenError} with the specified detail message. - * - * @param message the detail message - */ public ForbiddenError(String message) { super(message, 403, null); } - /** - * Constructs a {@code ForbiddenError} with the specified detail message - * and error details. - * - * @param message the detail message - * @param errorDetails additional error details from the server, or {@code null} - */ public ForbiddenError(String message, Map errorDetails) { super(message, 403, errorDetails); } diff --git a/src/main/java/ai/nomyo/errors/InvalidRequestError.java b/src/main/java/ai/nomyo/errors/InvalidRequestError.java index 357b2b8..36a9be9 100644 --- a/src/main/java/ai/nomyo/errors/InvalidRequestError.java +++ b/src/main/java/ai/nomyo/errors/InvalidRequestError.java @@ -2,39 +2,17 @@ package ai.nomyo.errors; import java.util.Map; -/** - * Exception thrown when the API returns a 400 (Bad Request) status. - */ +/** HTTP 400 (Bad Request). */ public class InvalidRequestError extends APIError { - /** - * Constructs an {@code InvalidRequestError} with the specified detail message, - * status code, and error details. - * - * @param message the detail message - * @param statusCode the HTTP status code (must be 400) - * @param errorDetails additional error details from the server, or {@code null} - */ public InvalidRequestError(String message, Integer statusCode, Map errorDetails) { super(message, statusCode, errorDetails); } - /** - * Constructs an {@code InvalidRequestError} with the specified detail message. - * - * @param message the detail message - */ public InvalidRequestError(String message) { super(message, 400, null); } - /** - * Constructs an {@code InvalidRequestError} with the specified detail message - * and error details. - * - * @param message the detail message - * @param errorDetails additional error details from the server, or {@code null} - */ public InvalidRequestError(String message, Map errorDetails) { super(message, 400, errorDetails); } diff --git a/src/main/java/ai/nomyo/errors/RateLimitError.java b/src/main/java/ai/nomyo/errors/RateLimitError.java index b56da8d..d108729 100644 --- a/src/main/java/ai/nomyo/errors/RateLimitError.java +++ b/src/main/java/ai/nomyo/errors/RateLimitError.java @@ -2,39 +2,17 @@ package ai.nomyo.errors; import java.util.Map; -/** - * Exception thrown when the API returns a 429 (Too Many Requests) status. - */ +/** HTTP 429 (Too Many Requests). */ public class RateLimitError extends APIError { - /** - * Constructs a {@code RateLimitError} with the specified detail message, - * status code, and error details. - * - * @param message the detail message - * @param statusCode the HTTP status code (must be 429) - * @param errorDetails additional error details from the server, or {@code null} - */ public RateLimitError(String message, Integer statusCode, Map errorDetails) { super(message, statusCode, errorDetails); } - /** - * Constructs a {@code RateLimitError} with the specified detail message. - * - * @param message the detail message - */ public RateLimitError(String message) { super(message, 429, null); } - /** - * Constructs a {@code RateLimitError} with the specified detail message - * and error details. - * - * @param message the detail message - * @param errorDetails additional error details from the server, or {@code null} - */ public RateLimitError(String message, Map errorDetails) { super(message, 429, errorDetails); } diff --git a/src/main/java/ai/nomyo/errors/SecurityError.java b/src/main/java/ai/nomyo/errors/SecurityError.java index 8dd550e..64494a7 100644 --- a/src/main/java/ai/nomyo/errors/SecurityError.java +++ b/src/main/java/ai/nomyo/errors/SecurityError.java @@ -1,33 +1,12 @@ package ai.nomyo.errors; -/** - * Exception thrown for cryptographic and key-related failures. - * - *

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

- * - *

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

- */ +/** Cryptographic and key-related failures. No HTTP status code. */ public class SecurityError extends Exception { - /** - * Constructs a {@code SecurityError} with the specified detail message. - * - * @param message the detail message - */ public SecurityError(String message) { super(message); } - /** - * Constructs a {@code SecurityError} with the specified detail message - * and cause. - * - * @param message the detail message - * @param cause the underlying cause, or {@code null} - */ public SecurityError(String message, Throwable cause) { super(message, cause); } diff --git a/src/main/java/ai/nomyo/errors/ServerError.java b/src/main/java/ai/nomyo/errors/ServerError.java index 55415c1..b94f659 100644 --- a/src/main/java/ai/nomyo/errors/ServerError.java +++ b/src/main/java/ai/nomyo/errors/ServerError.java @@ -2,39 +2,17 @@ package ai.nomyo.errors; import java.util.Map; -/** - * Exception thrown when the API returns a 500 (Internal Server Error) status. - */ +/** HTTP 500 (Internal Server Error). */ public class ServerError extends APIError { - /** - * Constructs a {@code ServerError} with the specified detail message, - * status code, and error details. - * - * @param message the detail message - * @param statusCode the HTTP status code (must be 500) - * @param errorDetails additional error details from the server, or {@code null} - */ public ServerError(String message, Integer statusCode, Map errorDetails) { super(message, statusCode, errorDetails); } - /** - * Constructs a {@code ServerError} with the specified detail message. - * - * @param message the detail message - */ public ServerError(String message) { super(message, 500, null); } - /** - * Constructs a {@code ServerError} with the specified detail message - * and error details. - * - * @param message the detail message - * @param errorDetails additional error details from the server, or {@code null} - */ public ServerError(String message, Map errorDetails) { super(message, 500, errorDetails); } diff --git a/src/main/java/ai/nomyo/errors/ServiceUnavailableError.java b/src/main/java/ai/nomyo/errors/ServiceUnavailableError.java index 4784fdd..c83d614 100644 --- a/src/main/java/ai/nomyo/errors/ServiceUnavailableError.java +++ b/src/main/java/ai/nomyo/errors/ServiceUnavailableError.java @@ -2,39 +2,17 @@ package ai.nomyo.errors; import java.util.Map; -/** - * Exception thrown when the API returns a 503 (Service Unavailable) status. - */ +/** HTTP 503 (Service Unavailable). */ public class ServiceUnavailableError extends APIError { - /** - * Constructs a {@code ServiceUnavailableError} with the specified detail - * message, status code, and error details. - * - * @param message the detail message - * @param statusCode the HTTP status code (must be 503) - * @param errorDetails additional error details from the server, or {@code null} - */ public ServiceUnavailableError(String message, Integer statusCode, Map errorDetails) { super(message, statusCode, errorDetails); } - /** - * Constructs a {@code ServiceUnavailableError} with the specified detail message. - * - * @param message the detail message - */ public ServiceUnavailableError(String message) { super(message, 503, null); } - /** - * Constructs a {@code ServiceUnavailableError} with the specified detail message - * and error details. - * - * @param message the detail message - * @param errorDetails additional error details from the server, or {@code null} - */ public ServiceUnavailableError(String message, Map errorDetails) { super(message, 503, errorDetails); } diff --git a/src/main/java/ai/nomyo/util/PEMConverter.java b/src/main/java/ai/nomyo/util/PEMConverter.java index fd9a3c3..f21bceb 100644 --- a/src/main/java/ai/nomyo/util/PEMConverter.java +++ b/src/main/java/ai/nomyo/util/PEMConverter.java @@ -3,11 +3,13 @@ package ai.nomyo.util; import java.util.Base64; /** - * @author NieGestorben - * Copyright© (c) 2026, All Rights Reserved. + * Converts raw key bytes to PEM-encoded strings. */ public class PEMConverter { + /** + * Encodes {@code keyData} as PEM (private or public) with 64-char base64 lines. + */ 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-----"); diff --git a/src/main/java/ai/nomyo/util/Pass2Key.java b/src/main/java/ai/nomyo/util/Pass2Key.java index a986e4f..c22ccd0 100644 --- a/src/main/java/ai/nomyo/util/Pass2Key.java +++ b/src/main/java/ai/nomyo/util/Pass2Key.java @@ -10,6 +10,7 @@ import javax.crypto.spec.GCMParameterSpec; import javax.crypto.spec.PBEKeySpec; import javax.crypto.spec.SecretKeySpec; import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; import java.security.*; import java.security.spec.InvalidKeySpecException; import java.security.spec.KeySpec; @@ -17,16 +18,7 @@ import java.security.spec.PKCS8EncodedKeySpec; import java.util.Base64; /** - * Password-based encryption utility using PBKDF2 key derivation and AES-GCM encryption. - * - *

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

- * - *

Binary Layout

- *
- * [ 16 bytes salt ][ 12 bytes IV ][ variable bytes ciphertext ]
- * 
+ * Password-based encryption: PBKDF2 key derivation + AES-GCM. Output: base64(salt[16] + IV[12] + ciphertext). */ public final class Pass2Key { @@ -36,7 +28,8 @@ public final class Pass2Key { private static final int GCM_TAG_LENGTH = 128; private static final int ITERATION_COUNT = 65536; - private Pass2Key() {} + private Pass2Key() { + } /** * Encrypts the given plaintext using the specified algorithm and password. @@ -46,10 +39,7 @@ public final class Pass2Key { * @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 { + 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); @@ -76,10 +66,7 @@ public final class Pass2Key { * @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 { + public static String decrypt(String algorithm, String cipherText, String password) throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidAlgorithmParameterException, InvalidKeyException, BadPaddingException, IllegalBlockSizeException { byte[] decoded = Base64.getDecoder().decode(cipherText); @@ -99,8 +86,6 @@ public final class Pass2Key { } } - // ── Key Derivation ──────────────────────────────────────────────── - private static SecretKey deriveKey(String password, byte[] salt) { try { SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256"); @@ -111,50 +96,32 @@ public final class Pass2Key { } } - // ── Cipher Operations ───────────────────────────────────────────── - - private static byte[] encryptWithCipher(String algorithm, SecretKey key, - GCMParameterSpec spec, String input) - throws NoSuchPaddingException, NoSuchAlgorithmException, - InvalidAlgorithmParameterException, InvalidKeyException, - BadPaddingException, IllegalBlockSizeException { + 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()); + return cipher.doFinal(input.getBytes(StandardCharsets.UTF_8)); } - private static byte[] encryptWithCipher(String algorithm, SecretKey key, String input) - throws NoSuchPaddingException, NoSuchAlgorithmException, - InvalidAlgorithmParameterException, InvalidKeyException, - BadPaddingException, IllegalBlockSizeException { + private static byte[] encryptWithCipher(String algorithm, SecretKey key, String input) throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidKeyException, BadPaddingException, IllegalBlockSizeException { Cipher cipher = Cipher.getInstance(algorithm); cipher.init(Cipher.ENCRYPT_MODE, key); - return cipher.doFinal(input.getBytes()); + return cipher.doFinal(input.getBytes(StandardCharsets.UTF_8)); } - private static String decryptWithCipher(String algorithm, SecretKey key, - GCMParameterSpec spec, byte[] ciphertext) - throws NoSuchPaddingException, NoSuchAlgorithmException, - InvalidAlgorithmParameterException, InvalidKeyException, - BadPaddingException, IllegalBlockSizeException { + 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); + return new String(plaintext, StandardCharsets.UTF_8); } - private static String decryptWithCipher(String algorithm, SecretKey key, byte[] ciphertext) - throws NoSuchPaddingException, NoSuchAlgorithmException, - InvalidAlgorithmParameterException, InvalidKeyException, - BadPaddingException, IllegalBlockSizeException { + private static String decryptWithCipher(String algorithm, SecretKey key, byte[] ciphertext) throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidKeyException, BadPaddingException, IllegalBlockSizeException { Cipher cipher = Cipher.getInstance(algorithm); cipher.init(Cipher.DECRYPT_MODE, key); byte[] plaintext = cipher.doFinal(ciphertext); - return new String(plaintext); + return new String(plaintext, StandardCharsets.UTF_8); } - // ── Helpers ─────────────────────────────────────────────────────── - private static boolean isGcmMode(String algorithm) { return algorithm.contains("GCM"); } @@ -183,9 +150,7 @@ public final class Pass2Key { 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+", ""); + 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); diff --git a/src/main/java/ai/nomyo/util/Splitter.java b/src/main/java/ai/nomyo/util/Splitter.java index 47f4dec..4f80b3e 100644 --- a/src/main/java/ai/nomyo/util/Splitter.java +++ b/src/main/java/ai/nomyo/util/Splitter.java @@ -4,34 +4,22 @@ import java.util.ArrayList; import java.util.List; /** - * @author NieGestorben - * Copyright© (c) 2026, All Rights Reserved. + * Splits a string into fixed-length substrings. */ public class Splitter { + /** + * Splits {@code toSplit} into substrings of at most {@code length} characters. + */ public static List fixedLengthString(int length, String toSplit) { - List returnList = new ArrayList<>(); + List parts = 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; + for (int i = 0; i < toSplit.length(); i += length) { + int endIndex = Math.min(i + length, toSplit.length()); + parts.add(toSplit.substring(i, endIndex)); } - return returnList; + return parts; } } diff --git a/src/test/java/ai/nomyo/SecureCompletionClientE2ETest.java b/src/test/java/ai/nomyo/SecureCompletionClientE2ETest.java index 44085ae..72c9406 100644 --- a/src/test/java/ai/nomyo/SecureCompletionClientE2ETest.java +++ b/src/test/java/ai/nomyo/SecureCompletionClientE2ETest.java @@ -5,13 +5,9 @@ import org.junit.jupiter.api.*; import org.junit.jupiter.api.io.TempDir; import java.io.File; -import java.io.FileWriter; -import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.security.*; -import java.util.*; -import java.util.concurrent.CompletableFuture; import static org.junit.jupiter.api.Assertions.*; @@ -32,17 +28,12 @@ class SecureCompletionClientE2ETest { assertTrue(keyDir.mkdirs(), "Key directory should be created"); } - @AfterEach - void tearDown() { - // Cleanup is handled by @TempDir - } - // ── Full Lifecycle E2E Tests ────────────────────────────────────── @Test @Order(1) @DisplayName("E2E: Generate keys, save to disk, load in new client, validate") - void e2e_fullLifecycle_generateSaveLoadValidate() throws Exception { + void e2e_fullLifecycle_generateSaveLoadValidate() { // Step 1: Generate keys and save to disk SecureCompletionClient generateClient = new SecureCompletionClient(BASE_URL, false, true, 2); generateClient.generateKeys(true, keyDir.getAbsolutePath(), TEST_PASSWORD); @@ -83,7 +74,7 @@ class SecureCompletionClientE2ETest { @Test @Order(2) @DisplayName("E2E: Generate plaintext keys, load, and validate") - void e2e_plaintextKeys_generateLoadValidate() throws Exception { + void e2e_plaintextKeys_generateLoadValidate() { // Generate plaintext keys (no password) SecureCompletionClient client = new SecureCompletionClient(); client.generateKeys(true, keyDir.getAbsolutePath(), null); @@ -137,7 +128,7 @@ class SecureCompletionClientE2ETest { @Test @Order(4) @DisplayName("E2E: HTTP status mapping covers all documented cases") - void e2e_httpStatusMapping_allCases() throws Exception { + void e2e_httpStatusMapping_allCases() { SecureCompletionClient client = new SecureCompletionClient(); // 200 - success (null exception) @@ -187,7 +178,7 @@ class SecureCompletionClientE2ETest { @Test @Order(5) @DisplayName("E2E: Retryable status codes match Constants.RETRYABLE_STATUS_CODES") - void e2e_retryableStatusCodes_matchConstants() throws Exception { + void e2e_retryableStatusCodes_matchConstants() { SecureCompletionClient client = new SecureCompletionClient(); for (int code : Constants.RETRYABLE_STATUS_CODES) { @@ -200,13 +191,16 @@ class SecureCompletionClientE2ETest { @Test @Order(6) @DisplayName("E2E: URL encoding of public key PEM") - void e2e_urlEncoding_publicKey() throws Exception { + void e2e_urlEncoding_publicKey() { SecureCompletionClient client = new SecureCompletionClient(); - String pemKey = "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB" + - "IjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBI" + - "kAMBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB" + - "IjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBIjAN\n-----END PUBLIC KEY-----"; + String pemKey = """ + -----BEGIN PUBLIC KEY----- + MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB\ + IjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBI\ + kAMBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB\ + IjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBIjAN + -----END PUBLIC KEY-----"""; String encoded = client.urlEncodePublicKey(pemKey); @@ -266,7 +260,7 @@ class SecureCompletionClientE2ETest { @Test @Order(8) @DisplayName("E2E: Client constructor parameters are correctly set") - void e2e_clientConstructor_parametersSetCorrectly() throws Exception { + void e2e_clientConstructor_parametersSetCorrectly() { SecureCompletionClient client = new SecureCompletionClient( "https://custom.api.com", true, @@ -283,7 +277,7 @@ class SecureCompletionClientE2ETest { @Test @Order(9) @DisplayName("E2E: Client strips trailing slashes from routerUrl") - void e2e_clientConstructor_stripsTrailingSlashes() throws Exception { + void e2e_clientConstructor_stripsTrailingSlashes() { SecureCompletionClient client = new SecureCompletionClient( "https://api.example.com///", false, true, 1 @@ -295,7 +289,7 @@ class SecureCompletionClientE2ETest { @Test @Order(10) @DisplayName("E2E: Client uses default values when constructed with no args") - void e2e_clientConstructor_defaultValues() throws Exception { + void e2e_clientConstructor_defaultValues() { SecureCompletionClient client = new SecureCompletionClient(); assertEquals(Constants.DEFAULT_BASE_URL, client.getRouterUrl()); @@ -329,7 +323,7 @@ class SecureCompletionClientE2ETest { @Test @Order(12) @DisplayName("E2E: Generate keys without saving produces in-memory keys") - void e2e_generateKeys_noSave_producesInMemoryKeys() throws Exception { + void e2e_generateKeys_noSave_producesInMemoryKeys() { SecureCompletionClient client = new SecureCompletionClient(); client.generateKeys(false); @@ -338,13 +332,13 @@ class SecureCompletionClientE2ETest { assertNotNull(privateKey, "Private key should be in memory"); assertNotNull(publicPem, "Public PEM should be in memory"); - assertTrue(privateKey.getAlgorithm().equals("RSA")); + assertEquals("RSA", privateKey.getAlgorithm()); } @Test @Order(13) @DisplayName("E2E: SecurityError is thrown for null key validation") - void e2e_nullKeyValidation_throwsSecurityError() throws Exception { + void e2e_nullKeyValidation_throwsSecurityError() { SecureCompletionClient client = new SecureCompletionClient(); SecurityError error = assertThrows(SecurityError.class, @@ -357,7 +351,7 @@ class SecureCompletionClientE2ETest { @Test @Order(14) @DisplayName("E2E: mapHttpStatus returns null for 200 status") - void e2e_mapHttpStatus_200_returnsNull() throws Exception { + void e2e_mapHttpStatus_200_returnsNull() { SecureCompletionClient client = new SecureCompletionClient(); Exception result = client.mapHttpStatus(200, "Success"); @@ -367,7 +361,7 @@ class SecureCompletionClientE2ETest { @Test @Order(15) @DisplayName("E2E: mapHttpStatus includes response body in error message") - void e2e_mapHttpStatus_includesResponseBody() throws Exception { + void e2e_mapHttpStatus_includesResponseBody() { SecureCompletionClient client = new SecureCompletionClient(); Exception e400 = client.mapHttpStatus(400, "Invalid parameter: email"); diff --git a/src/test/java/ai/nomyo/SecureCompletionClientTest.java b/src/test/java/ai/nomyo/SecureCompletionClientTest.java index aba8f05..aad3a7d 100644 --- a/src/test/java/ai/nomyo/SecureCompletionClientTest.java +++ b/src/test/java/ai/nomyo/SecureCompletionClientTest.java @@ -5,12 +5,7 @@ import ai.nomyo.util.Pass2Key; import org.junit.jupiter.api.*; import org.junit.jupiter.api.io.TempDir; -import javax.crypto.BadPaddingException; -import javax.crypto.IllegalBlockSizeException; -import javax.crypto.NoSuchPaddingException; import java.io.File; -import java.io.FileWriter; -import java.io.IOException; import java.math.BigInteger; import java.nio.file.Files; import java.nio.file.Path; @@ -51,7 +46,7 @@ class SecureCompletionClientTest { assertNotNull(privateKey, "Private key should not be null"); assertNotNull(publicPemKey, "Public PEM key should not be null"); - assertTrue(privateKey.getAlgorithm().equals("RSA"), "Key algorithm should be RSA"); + assertEquals("RSA", privateKey.getAlgorithm(), "Key algorithm should be RSA"); assertTrue(publicPemKey.contains("BEGIN PUBLIC KEY"), "Public key should be valid PEM"); } @@ -81,8 +76,8 @@ class SecureCompletionClientTest { client2.generateKeys(false); PrivateKey secondKey = client2.getPrivateKey(); - assertNotEquals(firstKey.getEncoded().length, secondKey.getEncoded().length, - "Different keys should have different encoded lengths"); + assertNotEquals(java.util.Arrays.hashCode(firstKey.getEncoded()), java.util.Arrays.hashCode(secondKey.getEncoded()), + "Different keys should have different encoded content"); } // ── Key Generation with File Save Tests ─────────────────────────── @@ -90,7 +85,7 @@ class SecureCompletionClientTest { @Test @Order(4) @DisplayName("generateKeys with saveToFile=true should create key files") - void generateKeys_withSaveToFile_shouldCreateKeyFiles(@TempDir Path tempDir) throws Exception { + void generateKeys_withSaveToFile_shouldCreateKeyFiles(@TempDir Path tempDir) { File keyDir = tempDir.toFile(); client.generateKeys(true, keyDir.getAbsolutePath(), null); @@ -124,7 +119,7 @@ class SecureCompletionClientTest { @Test @Order(6) @DisplayName("generateKeys should not overwrite existing key files") - void generateKeys_shouldNotOverwriteExistingKeys(@TempDir Path tempDir) throws Exception { + void generateKeys_shouldNotOverwriteExistingKeys(@TempDir Path tempDir) { File keyDir = tempDir.toFile(); client.generateKeys(true, keyDir.getAbsolutePath(), null); @@ -143,7 +138,7 @@ class SecureCompletionClientTest { @Test @Order(7) @DisplayName("loadKeys should load plaintext private key from file") - void loadKeys_plaintext_shouldLoadPrivateKey(@TempDir Path tempDir) throws Exception { + void loadKeys_plaintext_shouldLoadPrivateKey(@TempDir Path tempDir) { File keyDir = tempDir.toFile(); client.generateKeys(true, keyDir.getAbsolutePath(), null); @@ -164,7 +159,7 @@ class SecureCompletionClientTest { @Test @Order(8) @DisplayName("loadKeys should load encrypted private key with correct password") - void loadKeys_encrypted_correctPassword_shouldLoadPrivateKey(@TempDir Path tempDir) throws Exception { + void loadKeys_encrypted_correctPassword_shouldLoadPrivateKey(@TempDir Path tempDir) { File keyDir = tempDir.toFile(); client.generateKeys(true, keyDir.getAbsolutePath(), TEST_PASSWORD); @@ -186,7 +181,7 @@ class SecureCompletionClientTest { @Test @Order(9) @DisplayName("loadKeys should handle wrong password gracefully") - void loadKeys_encrypted_wrongPassword_shouldHandleGracefully(@TempDir Path tempDir) throws Exception { + void loadKeys_encrypted_wrongPassword_shouldHandleGracefully(@TempDir Path tempDir) { File keyDir = tempDir.toFile(); client.generateKeys(true, keyDir.getAbsolutePath(), TEST_PASSWORD); @@ -220,7 +215,7 @@ class SecureCompletionClientTest { @Test @Order(11) @DisplayName("validateRsaKey should accept valid 4096-bit key") - void validateRsaKey_validKey_shouldPass() throws Exception { + void validateRsaKey_validKey_shouldPass() { client.generateKeys(false); PrivateKey key = client.getPrivateKey(); @@ -231,7 +226,7 @@ class SecureCompletionClientTest { @Test @Order(12) @DisplayName("validateRsaKey should reject null key") - void validateRsaKey_nullKey_shouldThrowSecurityError() throws Exception { + void validateRsaKey_nullKey_shouldThrowSecurityError() { SecurityError error = assertThrows(SecurityError.class, () -> client.validateRsaKey(null)); @@ -273,7 +268,7 @@ class SecureCompletionClientTest { @Test @Order(15) @DisplayName("Full roundtrip: generate, save, load should produce same key") - void roundtrip_generateSaveLoad_shouldProduceSameKey(@TempDir Path tempDir) throws Exception { + void roundtrip_generateSaveLoad_shouldProduceSameKey(@TempDir Path tempDir) { File keyDir = tempDir.toFile(); // Generate and save