diff --git a/.gitignore b/.gitignore
index 480bdf5..acfa420 100644
--- a/.gitignore
+++ b/.gitignore
@@ -36,4 +36,5 @@ build/
.vscode/
### Mac OS ###
-.DS_Store
\ No newline at end of file
+.DS_Store
+client_keys
\ No newline at end of file
diff --git a/pom.xml b/pom.xml
index 2fae3b5..11d2d24 100644
--- a/pom.xml
+++ b/pom.xml
@@ -19,18 +19,32 @@
org.projectlombok
lombok
1.18.44
- compile
+ provided
org.junit.jupiter
- junit-jupiter-engine
- 6.0.3
+ junit-jupiter
+ 5.12.1
test
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ 3.13.0
+
+
+
+ org.projectlombok
+ lombok
+ 1.18.44
+
+
+
+
org.apache.maven.plugins
maven-surefire-plugin
diff --git a/src/main/java/ai/nomyo/Constants.java b/src/main/java/ai/nomyo/Constants.java
index 831f5c0..25e249d 100644
--- a/src/main/java/ai/nomyo/Constants.java
+++ b/src/main/java/ai/nomyo/Constants.java
@@ -12,10 +12,6 @@ import java.util.Set;
*/
public final class Constants {
- private Constants() {
- // Utility class — prevents instantiation
- }
-
// ── Protocol Constants ──────────────────────────────────────────
/**
diff --git a/src/main/java/ai/nomyo/SecureCompletionClient.java b/src/main/java/ai/nomyo/SecureCompletionClient.java
index 4eee37e..66913a4 100644
--- a/src/main/java/ai/nomyo/SecureCompletionClient.java
+++ b/src/main/java/ai/nomyo/SecureCompletionClient.java
@@ -16,7 +16,10 @@ 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;
@@ -238,22 +241,31 @@ public class SecureCompletionClient {
* @param password optional password for the encrypted private key
*/
public void loadKeys(String privateKeyPath, String publicPemKeyPath, String password) {
+ File keyFile = new File(privateKeyPath);
+ if (!keyFile.exists()) {
+ throw new RuntimeException("Private key file not found: " + privateKeyPath);
+ }
+
+ String keyContent;
if (password != null && !password.isEmpty()) {
- String cipherText = getEncryptedPrivateKeyFromFile(privateKeyPath);
+ keyContent = getEncryptedPrivateKeyFromFile(privateKeyPath);
try {
- cipherText = Pass2Key.decrypt("AES/GCM/NoPadding", cipherText, password);
+ keyContent = Pass2Key.decrypt("AES/GCM/NoPadding", keyContent, password);
} catch (NoSuchPaddingException | NoSuchAlgorithmException
| BadPaddingException | IllegalBlockSizeException | InvalidAlgorithmParameterException |
InvalidKeyException e) {
System.out.println("Wrong password!");
+ return;
}
+ } else {
+ keyContent = getEncryptedPrivateKeyFromFile(privateKeyPath);
+ }
- try {
- this.privateKey = Pass2Key.convertStringToPrivateKey(cipherText);
- } catch (Exception e) {
- throw new RuntimeException(e);
- }
+ try {
+ this.privateKey = Pass2Key.convertStringToPrivateKey(keyContent);
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to load private key: " + e.getMessage(), e);
}
}
@@ -451,9 +463,7 @@ public class SecureCompletionClient {
if (key == null) {
throw new SecurityError("RSA key is null");
}
- int keySize = key.getEncoded() != null ? key.getEncoded().length * 8 : 0;
-
- System.out.println("Keysize: " + keySize);
+ int keySize = extractKeySize(key);
if (keySize < Constants.MIN_RSA_KEY_SIZE) {
throw new SecurityError(
@@ -462,6 +472,27 @@ public class SecureCompletionClient {
}
}
+ 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
+ try {
+ java.security.KeyFactory kf = java.security.KeyFactory.getInstance("RSA");
+ java.security.spec.RSAPrivateKeySpec 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;
+ }
+ }
+ }
+ return 0;
+ }
+
// ── HTTP Status → Exception Mapping ─────────────────────────────
/**
diff --git a/src/test/java/ai/nomyo/SecureCompletionClientE2ETest.java b/src/test/java/ai/nomyo/SecureCompletionClientE2ETest.java
new file mode 100644
index 0000000..44085ae
--- /dev/null
+++ b/src/test/java/ai/nomyo/SecureCompletionClientE2ETest.java
@@ -0,0 +1,381 @@
+package ai.nomyo;
+
+import ai.nomyo.errors.*;
+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.*;
+
+@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
+class SecureCompletionClientE2ETest {
+
+ private static final String TEST_PASSWORD = "e2e-test-password-456";
+ private static final String BASE_URL = "https://api.nomyo.ai";
+
+ @TempDir
+ Path tempDir;
+
+ private File keyDir;
+
+ @BeforeEach
+ void setUp() {
+ keyDir = tempDir.resolve("e2e_keys").toFile();
+ 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 {
+ // Step 1: Generate keys and save to disk
+ SecureCompletionClient generateClient = new SecureCompletionClient(BASE_URL, false, true, 2);
+ generateClient.generateKeys(true, keyDir.getAbsolutePath(), TEST_PASSWORD);
+
+ PrivateKey generatedKey = generateClient.getPrivateKey();
+ String generatedPublicPem = generateClient.getPublicPemKey();
+
+ assertNotNull(generatedKey, "Generated private key should not be null");
+ assertNotNull(generatedPublicPem, "Generated public PEM should not be null");
+
+ // Step 2: Verify key files exist on disk
+ File privateKeyFile = new File(keyDir, Constants.DEFAULT_PRIVATE_KEY_FILE);
+ File publicKeyFile = new File(keyDir, Constants.DEFAULT_PUBLIC_KEY_FILE);
+
+ assertTrue(privateKeyFile.exists(), "Private key file should exist on disk");
+ assertTrue(publicKeyFile.exists(), "Public key file should exist on disk");
+
+ // Step 3: Load keys into a new client instance
+ SecureCompletionClient loadClient = new SecureCompletionClient(BASE_URL, false, true, 2);
+ loadClient.loadKeys(
+ privateKeyFile.getAbsolutePath(),
+ null,
+ TEST_PASSWORD
+ );
+
+ PrivateKey loadedKey = loadClient.getPrivateKey();
+ assertNotNull(loadedKey, "Loaded private key should not be null");
+
+ // Step 4: Validate the loaded key
+ assertDoesNotThrow(() -> loadClient.validateRsaKey(loadedKey),
+ "Loaded key should pass validation");
+
+ // Step 5: Verify the loaded key matches the original
+ assertArrayEquals(generatedKey.getEncoded(), loadedKey.getEncoded(),
+ "Loaded key bytes should match original key bytes");
+ }
+
+ @Test
+ @Order(2)
+ @DisplayName("E2E: Generate plaintext keys, load, and validate")
+ void e2e_plaintextKeys_generateLoadValidate() throws Exception {
+ // Generate plaintext keys (no password)
+ SecureCompletionClient client = new SecureCompletionClient();
+ client.generateKeys(true, keyDir.getAbsolutePath(), null);
+
+ PrivateKey originalKey = client.getPrivateKey();
+
+ // Load into new client
+ SecureCompletionClient loadClient = new SecureCompletionClient();
+ loadClient.loadKeys(
+ new File(keyDir, Constants.DEFAULT_PRIVATE_KEY_FILE).getAbsolutePath(),
+ null
+ );
+
+ PrivateKey loadedKey = loadClient.getPrivateKey();
+
+ // Validate
+ assertDoesNotThrow(() -> loadClient.validateRsaKey(loadedKey));
+ assertArrayEquals(originalKey.getEncoded(), loadedKey.getEncoded());
+ }
+
+ @Test
+ @Order(3)
+ @DisplayName("E2E: Key validation with various key sizes")
+ void e2e_keyValidation_variousSizes() throws Exception {
+ SecureCompletionClient client = new SecureCompletionClient();
+
+ // Test with 1024-bit key (should fail)
+ KeyPairGenerator gen1024 = KeyPairGenerator.getInstance("RSA");
+ gen1024.initialize(1024);
+ PrivateKey smallKey = gen1024.generateKeyPair().getPrivate();
+
+ SecurityError smallError = assertThrows(SecurityError.class,
+ () -> client.validateRsaKey(smallKey));
+ assertTrue(smallError.getMessage().contains("below minimum"));
+
+ // Test with 2048-bit key (should pass - minimum)
+ KeyPairGenerator gen2048 = KeyPairGenerator.getInstance("RSA");
+ gen2048.initialize(2048);
+ PrivateKey minKey = gen2048.generateKeyPair().getPrivate();
+
+ assertDoesNotThrow(() -> client.validateRsaKey(minKey));
+
+ // Test with 4096-bit key (should pass)
+ KeyPairGenerator gen4096 = KeyPairGenerator.getInstance("RSA");
+ gen4096.initialize(4096);
+ PrivateKey largeKey = gen4096.generateKeyPair().getPrivate();
+
+ assertDoesNotThrow(() -> client.validateRsaKey(largeKey));
+ }
+
+ @Test
+ @Order(4)
+ @DisplayName("E2E: HTTP status mapping covers all documented cases")
+ void e2e_httpStatusMapping_allCases() throws Exception {
+ SecureCompletionClient client = new SecureCompletionClient();
+
+ // 200 - success (null exception)
+ assertNull(client.mapHttpStatus(200, "OK"));
+
+ // 400 - InvalidRequestError
+ Exception e400 = client.mapHttpStatus(400, "Bad request");
+ assertInstanceOf(InvalidRequestError.class, e400);
+
+ // 401 - AuthenticationError
+ Exception e401 = client.mapHttpStatus(401, "Unauthorized");
+ assertInstanceOf(AuthenticationError.class, e401);
+
+ // 403 - ForbiddenError
+ Exception e403 = client.mapHttpStatus(403, "Forbidden");
+ assertInstanceOf(ForbiddenError.class, e403);
+
+ // 404 - APIError
+ Exception e404 = client.mapHttpStatus(404, "Not found");
+ assertInstanceOf(APIError.class, e404);
+
+ // 429 - RateLimitError
+ Exception e429 = client.mapHttpStatus(429, "Too many requests");
+ assertInstanceOf(RateLimitError.class, e429);
+
+ // 500 - ServerError
+ Exception e500 = client.mapHttpStatus(500, "Internal error");
+ assertInstanceOf(ServerError.class, e500);
+
+ // 502 - APIError (retryable)
+ Exception e502 = client.mapHttpStatus(502, "Bad gateway");
+ assertInstanceOf(APIError.class, e502);
+
+ // 503 - ServiceUnavailableError
+ Exception e503 = client.mapHttpStatus(503, "Service unavailable");
+ assertInstanceOf(ServiceUnavailableError.class, e503);
+
+ // 504 - APIError (retryable)
+ Exception e504 = client.mapHttpStatus(504, "Gateway timeout");
+ assertInstanceOf(APIError.class, e504);
+
+ // 599 - APIError (unknown)
+ Exception e599 = client.mapHttpStatus(599, "Unknown error");
+ assertInstanceOf(APIError.class, e599);
+ }
+
+ @Test
+ @Order(5)
+ @DisplayName("E2E: Retryable status codes match Constants.RETRYABLE_STATUS_CODES")
+ void e2e_retryableStatusCodes_matchConstants() throws Exception {
+ SecureCompletionClient client = new SecureCompletionClient();
+
+ for (int code : Constants.RETRYABLE_STATUS_CODES) {
+ Exception exception = client.mapHttpStatus(code, "test");
+ // All retryable codes should return an Exception (not null)
+ assertNotNull(exception, "Retryable status " + code + " should return an exception");
+ }
+ }
+
+ @Test
+ @Order(6)
+ @DisplayName("E2E: URL encoding of public key PEM")
+ void e2e_urlEncoding_publicKey() throws Exception {
+ SecureCompletionClient client = new SecureCompletionClient();
+
+ String pemKey = "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB" +
+ "IjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBI" +
+ "kAMBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB" +
+ "IjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBIjAN\n-----END PUBLIC KEY-----";
+
+ String encoded = client.urlEncodePublicKey(pemKey);
+
+ assertNotNull(encoded);
+ assertFalse(encoded.contains("\n"), "Encoded key should not contain newlines");
+ assertFalse(encoded.contains(" "), "Encoded key should not contain spaces");
+
+ // Decode and verify roundtrip
+ String decoded = java.net.URLDecoder.decode(encoded, java.nio.charset.StandardCharsets.UTF_8);
+ assertEquals(pemKey, decoded, "URL decoding should restore original PEM");
+ }
+
+ @Test
+ @Order(7)
+ @DisplayName("E2E: Multiple clients can independently generate and load keys")
+ void e2e_multipleClients_independentOperations() throws Exception {
+ File dir1 = tempDir.resolve("dir1").toFile();
+ File dir2 = tempDir.resolve("dir2").toFile();
+ dir1.mkdirs();
+ dir2.mkdirs();
+
+ // Client 1
+ SecureCompletionClient client1 = new SecureCompletionClient();
+ client1.generateKeys(true, dir1.getAbsolutePath(), "pass1");
+ PrivateKey key1 = client1.getPrivateKey();
+
+ // Client 2
+ SecureCompletionClient client2 = new SecureCompletionClient();
+ client2.generateKeys(true, dir2.getAbsolutePath(), "pass2");
+ PrivateKey key2 = client2.getPrivateKey();
+
+ // Verify keys are different (different modulus values)
+ java.security.KeyFactory kf = java.security.KeyFactory.getInstance("RSA");
+ java.security.spec.RSAPrivateCrtKeySpec spec1 = kf.getKeySpec(key1, java.security.spec.RSAPrivateCrtKeySpec.class);
+ java.security.spec.RSAPrivateCrtKeySpec spec2 = kf.getKeySpec(key2, java.security.spec.RSAPrivateCrtKeySpec.class);
+ assertNotEquals(spec1.getModulus(), spec2.getModulus(),
+ "Different clients should generate different keys");
+
+ // Load keys independently
+ SecureCompletionClient load1 = new SecureCompletionClient();
+ load1.loadKeys(
+ new File(dir1, Constants.DEFAULT_PRIVATE_KEY_FILE).getAbsolutePath(),
+ null, "pass1"
+ );
+
+ SecureCompletionClient load2 = new SecureCompletionClient();
+ load2.loadKeys(
+ new File(dir2, Constants.DEFAULT_PRIVATE_KEY_FILE).getAbsolutePath(),
+ null, "pass2"
+ );
+
+ // Verify each loaded key matches its original
+ assertArrayEquals(key1.getEncoded(), load1.getPrivateKey().getEncoded());
+ assertArrayEquals(key2.getEncoded(), load2.getPrivateKey().getEncoded());
+ }
+
+ @Test
+ @Order(8)
+ @DisplayName("E2E: Client constructor parameters are correctly set")
+ void e2e_clientConstructor_parametersSetCorrectly() throws Exception {
+ SecureCompletionClient client = new SecureCompletionClient(
+ "https://custom.api.com",
+ true,
+ false,
+ 5
+ );
+
+ assertEquals("https://custom.api.com", client.getRouterUrl());
+ assertTrue(client.isAllowHttp());
+ assertFalse(client.isUseSecureMemory());
+ assertEquals(5, client.getMaxRetries());
+ }
+
+ @Test
+ @Order(9)
+ @DisplayName("E2E: Client strips trailing slashes from routerUrl")
+ void e2e_clientConstructor_stripsTrailingSlashes() throws Exception {
+ SecureCompletionClient client = new SecureCompletionClient(
+ "https://api.example.com///",
+ false, true, 1
+ );
+
+ assertEquals("https://api.example.com", client.getRouterUrl());
+ }
+
+ @Test
+ @Order(10)
+ @DisplayName("E2E: Client uses default values when constructed with no args")
+ void e2e_clientConstructor_defaultValues() throws Exception {
+ SecureCompletionClient client = new SecureCompletionClient();
+
+ assertEquals(Constants.DEFAULT_BASE_URL, client.getRouterUrl());
+ assertFalse(client.isAllowHttp());
+ assertTrue(client.isUseSecureMemory());
+ assertEquals(Constants.DEFAULT_MAX_RETRIES, client.getMaxRetries());
+ }
+
+ @Test
+ @Order(11)
+ @DisplayName("E2E: Encrypted key file is unreadable without password")
+ void e2e_encryptedKey_unreadableWithoutPassword() throws Exception {
+ SecureCompletionClient client = new SecureCompletionClient();
+ client.generateKeys(true, keyDir.getAbsolutePath(), TEST_PASSWORD);
+
+ File privateKeyFile = new File(keyDir, Constants.DEFAULT_PRIVATE_KEY_FILE);
+ String encryptedContent = Files.readString(privateKeyFile.toPath());
+
+ // Verify the file content is encrypted (no PEM header)
+ assertFalse(encryptedContent.contains("BEGIN PRIVATE KEY"),
+ "Encrypted file should not contain PEM header");
+
+ // Try loading with wrong password - should not throw
+ SecureCompletionClient loadClient = new SecureCompletionClient();
+ assertDoesNotThrow(() ->
+ loadClient.loadKeys(privateKeyFile.getAbsolutePath(), null, "wrong-password"),
+ "Wrong password should be handled gracefully"
+ );
+ }
+
+ @Test
+ @Order(12)
+ @DisplayName("E2E: Generate keys without saving produces in-memory keys")
+ void e2e_generateKeys_noSave_producesInMemoryKeys() throws Exception {
+ SecureCompletionClient client = new SecureCompletionClient();
+ client.generateKeys(false);
+
+ PrivateKey privateKey = client.getPrivateKey();
+ String publicPem = client.getPublicPemKey();
+
+ assertNotNull(privateKey, "Private key should be in memory");
+ assertNotNull(publicPem, "Public PEM should be in memory");
+ assertTrue(privateKey.getAlgorithm().equals("RSA"));
+ }
+
+ @Test
+ @Order(13)
+ @DisplayName("E2E: SecurityError is thrown for null key validation")
+ void e2e_nullKeyValidation_throwsSecurityError() throws Exception {
+ SecureCompletionClient client = new SecureCompletionClient();
+
+ SecurityError error = assertThrows(SecurityError.class,
+ () -> client.validateRsaKey(null));
+
+ assertNotNull(error.getMessage());
+ assertTrue(error.getMessage().contains("null"));
+ }
+
+ @Test
+ @Order(14)
+ @DisplayName("E2E: mapHttpStatus returns null for 200 status")
+ void e2e_mapHttpStatus_200_returnsNull() throws Exception {
+ SecureCompletionClient client = new SecureCompletionClient();
+ Exception result = client.mapHttpStatus(200, "Success");
+
+ assertNull(result, "HTTP 200 should return null (no error)");
+ }
+
+ @Test
+ @Order(15)
+ @DisplayName("E2E: mapHttpStatus includes response body in error message")
+ void e2e_mapHttpStatus_includesResponseBody() throws Exception {
+ SecureCompletionClient client = new SecureCompletionClient();
+
+ Exception e400 = client.mapHttpStatus(400, "Invalid parameter: email");
+ assertTrue(e400.getMessage().contains("Invalid parameter: email"),
+ "Error message should include response body");
+
+ Exception e500 = client.mapHttpStatus(500, "Database connection failed");
+ assertTrue(e500.getMessage().contains("Database connection failed"),
+ "Error message should include response body");
+ }
+}
diff --git a/src/test/java/ai/nomyo/SecureCompletionClientTest.java b/src/test/java/ai/nomyo/SecureCompletionClientTest.java
new file mode 100644
index 0000000..aba8f05
--- /dev/null
+++ b/src/test/java/ai/nomyo/SecureCompletionClientTest.java
@@ -0,0 +1,436 @@
+package ai.nomyo;
+
+import ai.nomyo.errors.SecurityError;
+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;
+import java.security.*;
+import java.security.spec.RSAKeyGenParameterSpec;
+import java.security.spec.RSAPrivateCrtKeySpec;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
+class SecureCompletionClientTest {
+
+ private static final String TEST_PASSWORD = "test-password-123";
+ private static final String PLAINTEXT_PRIVATE_KEY = "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC7o4qne60TBb\n-----END PRIVATE KEY-----";
+
+ private SecureCompletionClient client;
+
+ @BeforeEach
+ void setUp() {
+ client = new SecureCompletionClient();
+ }
+
+ @AfterEach
+ void tearDown() {
+ client = null;
+ }
+
+ // ── Key Generation Tests ──────────────────────────────────────────
+
+ @Test
+ @Order(1)
+ @DisplayName("generateKeys should create 4096-bit RSA key pair")
+ void generateKeys_shouldCreateValidKeyPair() {
+ client.generateKeys(false);
+
+ PrivateKey privateKey = client.getPrivateKey();
+ String publicPemKey = client.getPublicPemKey();
+
+ 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");
+ assertTrue(publicPemKey.contains("BEGIN PUBLIC KEY"), "Public key should be valid PEM");
+ }
+
+ @Test
+ @Order(2)
+ @DisplayName("generateKeys should produce keys with correct bit size")
+ void generateKeys_shouldProduceCorrectKeySize() throws Exception {
+ client.generateKeys(false);
+
+ PrivateKey privateKey = client.getPrivateKey();
+ java.security.KeyFactory kf = java.security.KeyFactory.getInstance("RSA");
+ java.security.spec.RSAPrivateCrtKeySpec spec = kf.getKeySpec(privateKey, java.security.spec.RSAPrivateCrtKeySpec.class);
+ int keySizeBits = spec.getModulus().bitLength();
+
+ assertEquals(Constants.RSA_KEY_SIZE, keySizeBits,
+ "RSA key should be " + Constants.RSA_KEY_SIZE + " bits");
+ }
+
+ @Test
+ @Order(3)
+ @DisplayName("generateKeys should create unique keys on each call")
+ void generateKeys_shouldProduceUniqueKeys() {
+ client.generateKeys(false);
+ PrivateKey firstKey = client.getPrivateKey();
+
+ SecureCompletionClient client2 = new SecureCompletionClient();
+ client2.generateKeys(false);
+ PrivateKey secondKey = client2.getPrivateKey();
+
+ assertNotEquals(firstKey.getEncoded().length, secondKey.getEncoded().length,
+ "Different keys should have different encoded lengths");
+ }
+
+ // ── Key Generation with File Save Tests ───────────────────────────
+
+ @Test
+ @Order(4)
+ @DisplayName("generateKeys with saveToFile=true should create key files")
+ void generateKeys_withSaveToFile_shouldCreateKeyFiles(@TempDir Path tempDir) throws Exception {
+ File keyDir = tempDir.toFile();
+
+ client.generateKeys(true, keyDir.getAbsolutePath(), null);
+
+ File privateKeyFile = new File(keyDir, Constants.DEFAULT_PRIVATE_KEY_FILE);
+ File publicKeyFile = new File(keyDir, Constants.DEFAULT_PUBLIC_KEY_FILE);
+
+ assertTrue(privateKeyFile.exists(), "Private key file should be created");
+ assertTrue(publicKeyFile.exists(), "Public key file should be created");
+ assertTrue(privateKeyFile.length() > 0, "Private key file should not be empty");
+ assertTrue(publicKeyFile.length() > 0, "Public key file should not be empty");
+ }
+
+ @Test
+ @Order(5)
+ @DisplayName("generateKeys with password should encrypt private key file")
+ void generateKeys_withPassword_shouldEncryptPrivateKey(@TempDir Path tempDir) throws Exception {
+ File keyDir = tempDir.toFile();
+
+ client.generateKeys(true, keyDir.getAbsolutePath(), TEST_PASSWORD);
+
+ File privateKeyFile = new File(keyDir, Constants.DEFAULT_PRIVATE_KEY_FILE);
+ String content = Files.readString(privateKeyFile.toPath());
+
+ assertFalse(content.contains("BEGIN PRIVATE KEY"),
+ "Encrypted private key should not contain PEM header");
+ assertNotEquals(PLAINTEXT_PRIVATE_KEY, content,
+ "Encrypted key should differ from plaintext");
+ }
+
+ @Test
+ @Order(6)
+ @DisplayName("generateKeys should not overwrite existing key files")
+ void generateKeys_shouldNotOverwriteExistingKeys(@TempDir Path tempDir) throws Exception {
+ File keyDir = tempDir.toFile();
+
+ client.generateKeys(true, keyDir.getAbsolutePath(), null);
+ File privateKeyFile = new File(keyDir, Constants.DEFAULT_PRIVATE_KEY_FILE);
+ long firstSize = privateKeyFile.length();
+
+ client.generateKeys(true, keyDir.getAbsolutePath(), null);
+ long secondSize = privateKeyFile.length();
+
+ assertEquals(firstSize, secondSize,
+ "Existing key files should not be overwritten");
+ }
+
+ // ── Key Loading Tests ─────────────────────────────────────────────
+
+ @Test
+ @Order(7)
+ @DisplayName("loadKeys should load plaintext private key from file")
+ void loadKeys_plaintext_shouldLoadPrivateKey(@TempDir Path tempDir) throws Exception {
+ File keyDir = tempDir.toFile();
+ client.generateKeys(true, keyDir.getAbsolutePath(), null);
+
+ PrivateKey originalKey = client.getPrivateKey();
+
+ SecureCompletionClient loadClient = new SecureCompletionClient();
+ loadClient.loadKeys(
+ new File(keyDir, Constants.DEFAULT_PRIVATE_KEY_FILE).getAbsolutePath(),
+ null
+ );
+
+ PrivateKey loadedKey = loadClient.getPrivateKey();
+ assertNotNull(loadedKey, "Loaded private key should not be null");
+ assertEquals(originalKey.getEncoded().length, loadedKey.getEncoded().length,
+ "Loaded key should have same size as original");
+ }
+
+ @Test
+ @Order(8)
+ @DisplayName("loadKeys should load encrypted private key with correct password")
+ void loadKeys_encrypted_correctPassword_shouldLoadPrivateKey(@TempDir Path tempDir) throws Exception {
+ File keyDir = tempDir.toFile();
+ client.generateKeys(true, keyDir.getAbsolutePath(), TEST_PASSWORD);
+
+ PrivateKey originalKey = client.getPrivateKey();
+
+ SecureCompletionClient loadClient = new SecureCompletionClient();
+ loadClient.loadKeys(
+ new File(keyDir, Constants.DEFAULT_PRIVATE_KEY_FILE).getAbsolutePath(),
+ null,
+ TEST_PASSWORD
+ );
+
+ PrivateKey loadedKey = loadClient.getPrivateKey();
+ assertNotNull(loadedKey, "Loaded private key should not be null");
+ assertEquals(originalKey.getEncoded().length, loadedKey.getEncoded().length,
+ "Loaded key should have same size as original");
+ }
+
+ @Test
+ @Order(9)
+ @DisplayName("loadKeys should handle wrong password gracefully")
+ void loadKeys_encrypted_wrongPassword_shouldHandleGracefully(@TempDir Path tempDir) throws Exception {
+ File keyDir = tempDir.toFile();
+ client.generateKeys(true, keyDir.getAbsolutePath(), TEST_PASSWORD);
+
+ SecureCompletionClient loadClient = new SecureCompletionClient();
+
+ assertDoesNotThrow(() ->
+ loadClient.loadKeys(
+ new File(keyDir, Constants.DEFAULT_PRIVATE_KEY_FILE).getAbsolutePath(),
+ null,
+ "wrong-password"
+ ),
+ "Wrong password should not throw exception"
+ );
+ }
+
+ @Test
+ @Order(10)
+ @DisplayName("loadKeys should throw exception for non-existent file")
+ void loadKeys_nonExistentFile_shouldThrowException() {
+ SecureCompletionClient loadClient = new SecureCompletionClient();
+
+ RuntimeException error = assertThrows(RuntimeException.class, () ->
+ loadClient.loadKeys("/non/existent/path/private_key.pem", null, null));
+
+ assertTrue(error.getMessage().contains("not found"),
+ "Error message should mention file not found");
+ }
+
+ // ── Key Validation Tests ──────────────────────────────────────────
+
+ @Test
+ @Order(11)
+ @DisplayName("validateRsaKey should accept valid 4096-bit key")
+ void validateRsaKey_validKey_shouldPass() throws Exception {
+ client.generateKeys(false);
+ PrivateKey key = client.getPrivateKey();
+
+ assertDoesNotThrow(() -> client.validateRsaKey(key),
+ "Valid 4096-bit key should pass validation");
+ }
+
+ @Test
+ @Order(12)
+ @DisplayName("validateRsaKey should reject null key")
+ void validateRsaKey_nullKey_shouldThrowSecurityError() throws Exception {
+ SecurityError error = assertThrows(SecurityError.class, () ->
+ client.validateRsaKey(null));
+
+ assertTrue(error.getMessage().contains("null"),
+ "Error message should mention null key");
+ }
+
+ @Test
+ @Order(13)
+ @DisplayName("validateRsaKey should reject keys below minimum size")
+ void validateRsaKey_tooSmallKey_shouldThrowSecurityError() throws Exception {
+ KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
+ generator.initialize(1024);
+ KeyPair pair = generator.generateKeyPair();
+ PrivateKey smallKey = pair.getPrivate();
+
+ SecurityError error = assertThrows(SecurityError.class, () ->
+ client.validateRsaKey(smallKey));
+
+ assertTrue(error.getMessage().contains("below minimum"),
+ "Error message should mention minimum size requirement");
+ }
+
+ @Test
+ @Order(14)
+ @DisplayName("validateRsaKey should accept minimum size key (2048 bits)")
+ void validateRsaKey_minimumSizeKey_shouldPass() throws Exception {
+ KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
+ generator.initialize(new RSAKeyGenParameterSpec(2048, BigInteger.valueOf(65537)));
+ KeyPair pair = generator.generateKeyPair();
+ PrivateKey minKey = pair.getPrivate();
+
+ assertDoesNotThrow(() -> client.validateRsaKey(minKey),
+ "2048-bit key should pass validation (minimum)");
+ }
+
+ // ── Key Roundtrip Tests ───────────────────────────────────────────
+
+ @Test
+ @Order(15)
+ @DisplayName("Full roundtrip: generate, save, load should produce same key")
+ void roundtrip_generateSaveLoad_shouldProduceSameKey(@TempDir Path tempDir) throws Exception {
+ File keyDir = tempDir.toFile();
+
+ // Generate and save
+ client.generateKeys(true, keyDir.getAbsolutePath(), TEST_PASSWORD);
+ PrivateKey originalKey = client.getPrivateKey();
+
+ // Load into new client
+ SecureCompletionClient loadClient = new SecureCompletionClient();
+ loadClient.loadKeys(
+ new File(keyDir, Constants.DEFAULT_PRIVATE_KEY_FILE).getAbsolutePath(),
+ null,
+ TEST_PASSWORD
+ );
+ PrivateKey loadedKey = loadClient.getPrivateKey();
+
+ // Verify keys match
+ assertNotNull(loadedKey, "Loaded key should not be null");
+ assertArrayEquals(originalKey.getEncoded(), loadedKey.getEncoded(),
+ "Loaded key should match original key bytes");
+ }
+
+ @Test
+ @Order(16)
+ @DisplayName("Multiple generate/load cycles should work correctly")
+ void multipleCycles_shouldWorkCorrectly(@TempDir Path tempDir) throws Exception {
+ File keyDir = tempDir.toFile();
+
+ for (int i = 0; i < 3; i++) {
+ SecureCompletionClient cycleClient = new SecureCompletionClient();
+ cycleClient.generateKeys(true, keyDir.getAbsolutePath(), TEST_PASSWORD);
+ PrivateKey generatedKey = cycleClient.getPrivateKey();
+
+ SecureCompletionClient loadClient = new SecureCompletionClient();
+ loadClient.loadKeys(
+ new File(keyDir, Constants.DEFAULT_PRIVATE_KEY_FILE).getAbsolutePath(),
+ null,
+ TEST_PASSWORD
+ );
+ PrivateKey loadedKey = loadClient.getPrivateKey();
+
+ assertNotNull(loadedKey, "Cycle " + i + ": loaded key should not be null");
+ KeyFactory kf = KeyFactory.getInstance("RSA");
+ int generatedBits = kf.getKeySpec(generatedKey, RSAPrivateCrtKeySpec.class).getModulus().bitLength();
+ int loadedBits = kf.getKeySpec(loadedKey, RSAPrivateCrtKeySpec.class).getModulus().bitLength();
+ assertEquals(generatedBits, loadedBits,
+ "Cycle " + i + ": loaded key should have same modulus bit length as generated key");
+ }
+ }
+
+ // ── Utility Method Tests ──────────────────────────────────────────
+
+ @Test
+ @Order(17)
+ @DisplayName("urlEncodePublicKey should properly encode PEM keys")
+ void urlEncodePublicKey_shouldEncodeCorrectly() {
+ String pemKey = "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A\n-----END PUBLIC KEY-----";
+
+ String encoded = client.urlEncodePublicKey(pemKey);
+
+ assertNotNull(encoded, "Encoded key should not be null");
+ assertTrue(encoded.contains("%0A"), "Encoded key should contain URL-encoded newlines");
+ assertTrue(encoded.contains("BEGIN+PUBLIC+KEY"), "Encoded key should contain encoded header");
+ }
+
+ @Test
+ @Order(18)
+ @DisplayName("urlEncodePublicKey should handle empty string")
+ void urlEncodePublicKey_emptyString_shouldReturnEmpty() {
+ String encoded = client.urlEncodePublicKey("");
+ assertEquals("", encoded, "Empty string should encode to empty string");
+ }
+
+ // ── Pass2Key Encryption/Decryption Tests ──────────────────────────
+
+ @Test
+ @Order(19)
+ @DisplayName("Pass2Key encrypt/decrypt should preserve plaintext")
+ void pass2Key_encryptDecrypt_shouldPreservePlaintext() throws Exception {
+ String plaintext = "Test plaintext content for encryption";
+ String password = "my-secret-password";
+
+ String encrypted = Pass2Key.encrypt("AES/GCM/NoPadding", plaintext, password);
+
+ assertNotNull(encrypted, "Encrypted text should not be null");
+ assertNotEquals(plaintext, encrypted, "Encrypted text should differ from plaintext");
+
+ String decrypted = Pass2Key.decrypt("AES/GCM/NoPadding", encrypted, password);
+
+ assertEquals(plaintext, decrypted, "Decrypted text should match original plaintext");
+ }
+
+ @Test
+ @Order(20)
+ @DisplayName("Pass2Key should produce different ciphertext for same plaintext")
+ void pass2Key_shouldProduceDifferentCiphertext() throws Exception {
+ String plaintext = "Same plaintext";
+ String password = "same-password";
+
+ String encrypted1 = Pass2Key.encrypt("AES/GCM/NoPadding", plaintext, password);
+ String encrypted2 = Pass2Key.encrypt("AES/GCM/NoPadding", plaintext, password);
+
+ assertNotEquals(encrypted1, encrypted2,
+ "Same plaintext with same password should produce different ciphertext (due to random salt/IV)");
+ }
+
+ @Test
+ @Order(21)
+ @DisplayName("Pass2Key decrypt should fail with wrong password")
+ void pass2Key_wrongPassword_shouldFail() throws Exception {
+ String plaintext = "Secret content";
+ String correctPassword = "correct-password";
+ String wrongPassword = "wrong-password";
+
+ String encrypted = Pass2Key.encrypt("AES/GCM/NoPadding", plaintext, correctPassword);
+
+ assertThrows(Exception.class, () ->
+ Pass2Key.decrypt("AES/GCM/NoPadding", encrypted, wrongPassword),
+ "Decryption with wrong password should throw exception"
+ );
+ }
+
+ @Test
+ @Order(22)
+ @DisplayName("Pass2Key convertStringToPrivateKey should parse PEM correctly")
+ void convertStringToPrivateKey_shouldParsePEM() throws Exception {
+ SecureCompletionClient tempClient = new SecureCompletionClient();
+ tempClient.generateKeys(false);
+ PrivateKey originalKey = tempClient.getPrivateKey();
+
+ String pem = ai.nomyo.util.PEMConverter.toPEM(originalKey.getEncoded(), true);
+ PrivateKey parsedKey = Pass2Key.convertStringToPrivateKey(pem);
+
+ assertNotNull(parsedKey, "Parsed private key should not be null");
+ assertEquals("RSA", parsedKey.getAlgorithm(), "Parsed key should be RSA");
+ assertArrayEquals(originalKey.getEncoded(), parsedKey.getEncoded(),
+ "Parsed key should match original key bytes");
+ }
+
+ @Test
+ @Order(23)
+ @DisplayName("Pass2Key convertStringToPrivateKey should handle PEM with whitespace")
+ void convertStringToPrivateKey_shouldHandleWhitespace() throws Exception {
+ SecureCompletionClient tempClient = new SecureCompletionClient();
+ tempClient.generateKeys(false);
+ PrivateKey originalKey = tempClient.getPrivateKey();
+
+ String pem = "-----BEGIN PRIVATE KEY-----\n " +
+ originalKey.getEncoded().length + "lines\n" +
+ "-----END PRIVATE KEY-----";
+
+ String formattedPem = ai.nomyo.util.PEMConverter.toPEM(originalKey.getEncoded(), true);
+ String pemWithWhitespace = formattedPem.replace("\n", "\n ");
+
+ PrivateKey parsedKey = Pass2Key.convertStringToPrivateKey(pemWithWhitespace);
+
+ assertNotNull(parsedKey, "Parsed private key should not be null even with whitespace");
+ assertArrayEquals(originalKey.getEncoded(), parsedKey.getEncoded(),
+ "Parsed key with whitespace should match original key bytes");
+ }
+}
diff --git a/src/test/resources/junit-platform.properties b/src/test/resources/junit-platform.properties
new file mode 100644
index 0000000..8a8f81e
--- /dev/null
+++ b/src/test/resources/junit-platform.properties
@@ -0,0 +1,2 @@
+junit.jupiter.execution.parallel.enabled = true
+junit.jupiter.execution.parallel.config.dynamic.factor = 1
\ No newline at end of file