From b6af1c97920a22b350fc7a80715bfe66934c2375 Mon Sep 17 00:00:00 2001 From: Oracle Date: Thu, 23 Apr 2026 19:22:01 +0200 Subject: [PATCH] Start with encryption --- pom.xml | 7 + src/main/java/ai/nomyo/EncryptedRequest.java | 61 +++++++ src/main/java/ai/nomyo/Main.java | 14 +- .../java/ai/nomyo/SecureCompletionClient.java | 140 ++++++++++++--- src/main/java/ai/nomyo/util/PEMConverter.java | 18 ++ .../nomyo/SecureCompletionClientE2ETest.java | 24 +-- .../ai/nomyo/SecureCompletionClientTest.java | 62 +++---- .../java/ai/nomyo/util/PEMConverterTest.java | 166 ++++++++++++++++++ 8 files changed, 413 insertions(+), 79 deletions(-) create mode 100644 src/main/java/ai/nomyo/EncryptedRequest.java create mode 100644 src/test/java/ai/nomyo/util/PEMConverterTest.java diff --git a/pom.xml b/pom.xml index 11d2d24..9455ff7 100644 --- a/pom.xml +++ b/pom.xml @@ -27,6 +27,13 @@ 5.12.1 test + + + com.google.code.gson + gson + 2.13.2 + compile + diff --git a/src/main/java/ai/nomyo/EncryptedRequest.java b/src/main/java/ai/nomyo/EncryptedRequest.java new file mode 100644 index 0000000..3472ea2 --- /dev/null +++ b/src/main/java/ai/nomyo/EncryptedRequest.java @@ -0,0 +1,61 @@ +package ai.nomyo; + +import com.google.gson.Gson; +import com.google.gson.annotations.SerializedName; +import lombok.Getter; +import lombok.Setter; + +/** + * Root class matching the 'packageJson' structure. + */ +@Setter +@Getter +public class EncryptedRequest { + + // Getters and Setters + @SerializedName("version") + private String version; + + @SerializedName("algorithm") + private String algorithm; + + @SerializedName("encrypted_payload") + private EncryptedPayload encryptedPayload; + + @SerializedName("encrypted_aes_key") + private String encryptedAESKey; // Java variable name corrected to proper spelling + + @SerializedName("key_algorithm") + private String keyAlgorithm; + + @SerializedName("payload_algorithm") + private String payloadAlgorithm; + + /** + * Represents the inner object containing the encrypted payload details. + */ + @Setter + @Getter + public static class EncryptedPayload { + + // Getters and Setters + @SerializedName("ciphertext") + private String ciphertext; + + @SerializedName("nonce") + private String nonce; + + @SerializedName("tag") + private String tag; + + public EncryptedPayload(String ciphertext, String nonce, String tag) { + this.ciphertext = ciphertext; + this.nonce = nonce; + this.tag = tag; + } + } + + public String toJson() { + return new Gson().toJson(this); + } +} diff --git a/src/main/java/ai/nomyo/Main.java b/src/main/java/ai/nomyo/Main.java index 786f2bb..668355d 100644 --- a/src/main/java/ai/nomyo/Main.java +++ b/src/main/java/ai/nomyo/Main.java @@ -1,6 +1,6 @@ package ai.nomyo; -import ai.nomyo.errors.SecurityError; +import java.util.concurrent.ExecutionException; /** * Entry point — loads RSA keys and validates key length. @@ -10,17 +10,15 @@ public class Main { static void main() { SecureCompletionClient secureCompletionClient = new SecureCompletionClient(); //secureCompletionClient.generateKeys(true, "client_keys", "pokemon"); - secureCompletionClient.loadKeys("client_keys/private_key.pem", "pokemon"); + //secureCompletionClient.loadKeys("client_keys/private_key.pem", "pokemon"); + try { - secureCompletionClient.validateRsaKey(secureCompletionClient.getPrivateKey()); - } catch (SecurityError e) { - System.out.println("RSA Key is too short!"); - return; + System.out.println(secureCompletionClient.fetchServerPublicKey().get()); + } catch (InterruptedException | ExecutionException e) { + throw new RuntimeException(e); } - System.out.println("RSA Key has correct length!"); - } } diff --git a/src/main/java/ai/nomyo/SecureCompletionClient.java b/src/main/java/ai/nomyo/SecureCompletionClient.java index df01e9d..5f95565 100644 --- a/src/main/java/ai/nomyo/SecureCompletionClient.java +++ b/src/main/java/ai/nomyo/SecureCompletionClient.java @@ -6,20 +6,27 @@ import ai.nomyo.util.Pass2Key; import lombok.Getter; import javax.crypto.*; -import java.io.FileWriter; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; import java.io.IOException; import java.math.BigInteger; +import java.net.*; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; 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.RSAKeyGenParameterSpec; -import java.util.Map; -import java.util.Set; +import java.security.spec.*; +import java.time.Duration; +import java.time.temporal.ChronoUnit; +import java.util.*; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ExecutionException; import java.util.concurrent.locks.ReentrantLock; /** @@ -61,19 +68,17 @@ public class SecureCompletionClient { * Lock for double-checked key initialization. */ private final ReentrantLock keyInitLock = new ReentrantLock(); - + private final HttpClient httpClient; /** * RSA private key ({@code null} until loaded/generated). */ @Getter private PrivateKey privateKey; - /** * PEM-encoded public key ({@code null} until loaded/generated). */ @Getter private String publicPemKey; - /** * Keys initialized. */ @@ -98,6 +103,7 @@ public class SecureCompletionClient { this.useSecureMemory = secureMemory; this.keySize = Constants.RSA_KEY_SIZE; this.maxRetries = maxRetries; + this.httpClient = HttpClient.newHttpClient(); } private static String readFileContent(String filePath) throws IOException { @@ -117,7 +123,7 @@ public class SecureCompletionClient { String privatePem = PEMConverter.toPEM(pair.getPrivate().getEncoded(), true); String publicPem = PEMConverter.toPEM(pair.getPublic().getEncoded(), false); - if (saveToFile) { + if (saveToFile) { Path keyFolder = Path.of(keyDir); if (!Files.exists(keyFolder)) { try { @@ -230,9 +236,38 @@ public class SecureCompletionClient { /** * GET {@code {routerUrl}/pki/public_key}. Returns server PEM public key. + * Errors are propagated as {@link CompletionException} wrappers around the underlying checked exception. */ public CompletableFuture fetchServerPublicKey() { - throw new UnsupportedOperationException("Not yet implemented"); + if (!this.routerUrl.startsWith("https://")) { + if (!this.allowHttp) { + return CompletableFuture.failedFuture(new SecurityError("Server public key must be fetched over HTTPS to prevent MITM attacks.")); + } else { + System.out.println("Fetching server public key over HTTP (local dev mode)"); + } + } + + URI url; + + try { + url = new URI(this.routerUrl + "/pki/public_key"); + } catch (URISyntaxException e) { + return CompletableFuture.failedFuture(new CompletionException("Invalid URI: " + e.getMessage(), e)); + } + + HttpRequest request = HttpRequest.newBuilder(url).timeout(Duration.of(60, ChronoUnit.SECONDS)).GET().build(); + + return this.httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()).thenApply(response -> { + if (response.statusCode() != 200) { + throw new CompletionException(new APIConnectionError("Could not fetch server public key!")); + } + return response.body(); + }).thenApply(body -> { + if (!PEMConverter.validatePEM(body)) { + throw new CompletionException(new InvalidKeyException("PEM key had invalid format")); + } + return body; + }); } /** @@ -249,8 +284,77 @@ public class SecureCompletionClient { /** * 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"); + public CompletableFuture doEncrypt(byte[] payloadBytes, Key aesKey) { + return CompletableFuture.supplyAsync(() -> { + SecureRandom random = new SecureRandom(); + byte[] nonce = new byte[12]; + random.nextBytes(nonce); + + Cipher cipher = null; + try { + cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(aesKey.getEncoded(), "AES"), new GCMParameterSpec(128, nonce)); + } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidAlgorithmParameterException | + InvalidKeyException e) { + throw new RuntimeException(e); + } + + byte[] ciphertext; + + try { + ciphertext = cipher.doFinal(payloadBytes); + } catch (IllegalBlockSizeException | BadPaddingException e) { + throw new RuntimeException(e); + } + + String serverPEM; + + try { + serverPEM = fetchServerPublicKey().get(); + } catch (InterruptedException | ExecutionException e) { + throw new RuntimeException(e); + } + + X509EncodedKeySpec keySpec = new X509EncodedKeySpec(PEMConverter.fromPEM(serverPEM).getBytes()); + + PublicKey serverPublicKey; + + try { + serverPublicKey = KeyFactory.getInstance("RSA").generatePublic(keySpec); + } catch (InvalidKeySpecException | NoSuchAlgorithmException e) { + throw new RuntimeException(e); + } + + Cipher rsa; + + byte[] enryptedAESKey = aesKey.getEncoded(); + + try { + rsa = Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding"); + rsa.init(Cipher.ENCRYPT_MODE, serverPublicKey); + } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException e) { + throw new RuntimeException(e); + } + + try { + rsa.doFinal(enryptedAESKey); + } catch (IllegalBlockSizeException | BadPaddingException e) { + throw new RuntimeException(e); + } + + byte[] tag = Arrays.copyOfRange(ciphertext, ciphertext.length - (128 / Byte.SIZE), ciphertext.length); + + EncryptedRequest request = new EncryptedRequest(); + + request.setVersion("1.0"); + request.setAlgorithm("hybrid-aes256-rsa4096"); + request.setEncryptedPayload(new EncryptedRequest.EncryptedPayload(Base64.getEncoder().encodeToString(ciphertext), Base64.getEncoder().encodeToString(nonce), Base64.getEncoder().encodeToString(tag))); + request.setEncryptedAESKey(Base64.getEncoder().encodeToString(enryptedAESKey)); + request.setKeyAlgorithm("RSA-OAEP-SHA256"); + request.setPayloadAlgorithm("AES-256-GCM"); + + return request.toJson().getBytes(StandardCharsets.UTF_8); + }); } /** @@ -311,11 +415,7 @@ public class SecureCompletionClient { keyInitLock.lock(); try { if (keysInitialized) return; - if (keyDir == null || keyDir.isEmpty()) { - generateKeys(false); - } else { - generateKeys(true); - } + generateKeys(keyDir != null && !keyDir.isEmpty()); keysInitialized = true; } finally { keyInitLock.unlock(); @@ -340,10 +440,10 @@ public class SecureCompletionClient { try { var kf = KeyFactory.getInstance("RSA"); try { - var crtSpec = kf.getKeySpec(key, java.security.spec.RSAPrivateCrtKeySpec.class); + var crtSpec = kf.getKeySpec(key, RSAPrivateCrtKeySpec.class); return crtSpec.getModulus().bitLength(); } catch (Exception ignored) { - var privSpec = kf.getKeySpec(key, java.security.spec.RSAPrivateKeySpec.class); + var privSpec = kf.getKeySpec(key, RSAPrivateKeySpec.class); return privSpec.getModulus().bitLength(); } } catch (Exception ignored) { @@ -380,7 +480,7 @@ public class SecureCompletionClient { * URL-encodes PEM key for {@code X-Public-Key} header. */ public String urlEncodePublicKey(String pemKey) { - return java.net.URLEncoder.encode(pemKey, StandardCharsets.UTF_8); + return URLEncoder.encode(pemKey, StandardCharsets.UTF_8); } /** diff --git a/src/main/java/ai/nomyo/util/PEMConverter.java b/src/main/java/ai/nomyo/util/PEMConverter.java index f21bceb..d2f99ce 100644 --- a/src/main/java/ai/nomyo/util/PEMConverter.java +++ b/src/main/java/ai/nomyo/util/PEMConverter.java @@ -1,5 +1,6 @@ package ai.nomyo.util; +import java.util.Arrays; import java.util.Base64; /** @@ -23,4 +24,21 @@ public class PEMConverter { return publicKeyFormatted.toString(); } + + public static String fromPEM(String pem) { + pem = pem.replaceAll("^-----BEGIN\\s+PRIVATE\\s+KEY-----|^------END\\s+PUBLIC\\s+KEY-----\n", ""); + + return Arrays.toString(Base64.getDecoder().decode(pem)); + } + + public static boolean validatePEM(String keyIn) { + if (keyIn == null || keyIn.isBlank()) { + return false; + } + + String trimmed = keyIn.trim(); + + return trimmed.startsWith("-----BEGIN PUBLIC KEY-----") + && trimmed.endsWith("-----END PUBLIC KEY-----"); + } } diff --git a/src/test/java/ai/nomyo/SecureCompletionClientE2ETest.java b/src/test/java/ai/nomyo/SecureCompletionClientE2ETest.java index 72c9406..78f7408 100644 --- a/src/test/java/ai/nomyo/SecureCompletionClientE2ETest.java +++ b/src/test/java/ai/nomyo/SecureCompletionClientE2ETest.java @@ -3,6 +3,8 @@ package ai.nomyo; import ai.nomyo.errors.*; import org.junit.jupiter.api.*; import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.api.parallel.ExecutionMode; import java.io.File; import java.nio.file.Files; @@ -11,7 +13,8 @@ import java.security.*; import static org.junit.jupiter.api.Assertions.*; -@TestMethodOrder(MethodOrderer.OrderAnnotation.class) + +@Execution(ExecutionMode.CONCURRENT) class SecureCompletionClientE2ETest { private static final String TEST_PASSWORD = "e2e-test-password-456"; @@ -31,7 +34,7 @@ class SecureCompletionClientE2ETest { // ── Full Lifecycle E2E Tests ────────────────────────────────────── @Test - @Order(1) + @Execution(ExecutionMode.SAME_THREAD) @DisplayName("E2E: Generate keys, save to disk, load in new client, validate") void e2e_fullLifecycle_generateSaveLoadValidate() { // Step 1: Generate keys and save to disk @@ -72,7 +75,7 @@ class SecureCompletionClientE2ETest { } @Test - @Order(2) + @Execution(ExecutionMode.SAME_THREAD) @DisplayName("E2E: Generate plaintext keys, load, and validate") void e2e_plaintextKeys_generateLoadValidate() { // Generate plaintext keys (no password) @@ -96,7 +99,6 @@ class SecureCompletionClientE2ETest { } @Test - @Order(3) @DisplayName("E2E: Key validation with various key sizes") void e2e_keyValidation_variousSizes() throws Exception { SecureCompletionClient client = new SecureCompletionClient(); @@ -126,7 +128,6 @@ class SecureCompletionClientE2ETest { } @Test - @Order(4) @DisplayName("E2E: HTTP status mapping covers all documented cases") void e2e_httpStatusMapping_allCases() { SecureCompletionClient client = new SecureCompletionClient(); @@ -176,7 +177,6 @@ class SecureCompletionClientE2ETest { } @Test - @Order(5) @DisplayName("E2E: Retryable status codes match Constants.RETRYABLE_STATUS_CODES") void e2e_retryableStatusCodes_matchConstants() { SecureCompletionClient client = new SecureCompletionClient(); @@ -189,7 +189,6 @@ class SecureCompletionClientE2ETest { } @Test - @Order(6) @DisplayName("E2E: URL encoding of public key PEM") void e2e_urlEncoding_publicKey() { SecureCompletionClient client = new SecureCompletionClient(); @@ -214,7 +213,7 @@ class SecureCompletionClientE2ETest { } @Test - @Order(7) + @Execution(ExecutionMode.SAME_THREAD) @DisplayName("E2E: Multiple clients can independently generate and load keys") void e2e_multipleClients_independentOperations() throws Exception { File dir1 = tempDir.resolve("dir1").toFile(); @@ -258,7 +257,6 @@ class SecureCompletionClientE2ETest { } @Test - @Order(8) @DisplayName("E2E: Client constructor parameters are correctly set") void e2e_clientConstructor_parametersSetCorrectly() { SecureCompletionClient client = new SecureCompletionClient( @@ -275,7 +273,6 @@ class SecureCompletionClientE2ETest { } @Test - @Order(9) @DisplayName("E2E: Client strips trailing slashes from routerUrl") void e2e_clientConstructor_stripsTrailingSlashes() { SecureCompletionClient client = new SecureCompletionClient( @@ -287,7 +284,6 @@ class SecureCompletionClientE2ETest { } @Test - @Order(10) @DisplayName("E2E: Client uses default values when constructed with no args") void e2e_clientConstructor_defaultValues() { SecureCompletionClient client = new SecureCompletionClient(); @@ -299,7 +295,7 @@ class SecureCompletionClientE2ETest { } @Test - @Order(11) + @Execution(ExecutionMode.SAME_THREAD) @DisplayName("E2E: Encrypted key file is unreadable without password") void e2e_encryptedKey_unreadableWithoutPassword() throws Exception { SecureCompletionClient client = new SecureCompletionClient(); @@ -321,7 +317,6 @@ class SecureCompletionClientE2ETest { } @Test - @Order(12) @DisplayName("E2E: Generate keys without saving produces in-memory keys") void e2e_generateKeys_noSave_producesInMemoryKeys() { SecureCompletionClient client = new SecureCompletionClient(); @@ -336,7 +331,6 @@ class SecureCompletionClientE2ETest { } @Test - @Order(13) @DisplayName("E2E: SecurityError is thrown for null key validation") void e2e_nullKeyValidation_throwsSecurityError() { SecureCompletionClient client = new SecureCompletionClient(); @@ -349,7 +343,6 @@ class SecureCompletionClientE2ETest { } @Test - @Order(14) @DisplayName("E2E: mapHttpStatus returns null for 200 status") void e2e_mapHttpStatus_200_returnsNull() { SecureCompletionClient client = new SecureCompletionClient(); @@ -359,7 +352,6 @@ class SecureCompletionClientE2ETest { } @Test - @Order(15) @DisplayName("E2E: mapHttpStatus includes response body in error message") void e2e_mapHttpStatus_includesResponseBody() { SecureCompletionClient client = new SecureCompletionClient(); diff --git a/src/test/java/ai/nomyo/SecureCompletionClientTest.java b/src/test/java/ai/nomyo/SecureCompletionClientTest.java index aad3a7d..d46da60 100644 --- a/src/test/java/ai/nomyo/SecureCompletionClientTest.java +++ b/src/test/java/ai/nomyo/SecureCompletionClientTest.java @@ -4,6 +4,8 @@ import ai.nomyo.errors.SecurityError; import ai.nomyo.util.Pass2Key; import org.junit.jupiter.api.*; import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.api.parallel.ExecutionMode; import java.io.File; import java.math.BigInteger; @@ -16,29 +18,18 @@ import java.security.spec.RSAPrivateCrtKeySpec; import static org.junit.jupiter.api.Assertions.*; @TestMethodOrder(MethodOrderer.OrderAnnotation.class) +@Execution(ExecutionMode.CONCURRENT) 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() { + SecureCompletionClient client = new SecureCompletionClient(); client.generateKeys(false); PrivateKey privateKey = client.getPrivateKey(); @@ -51,9 +42,9 @@ class SecureCompletionClientTest { } @Test - @Order(2) @DisplayName("generateKeys should produce keys with correct bit size") void generateKeys_shouldProduceCorrectKeySize() throws Exception { + SecureCompletionClient client = new SecureCompletionClient(); client.generateKeys(false); PrivateKey privateKey = client.getPrivateKey(); @@ -66,9 +57,9 @@ class SecureCompletionClientTest { } @Test - @Order(3) @DisplayName("generateKeys should create unique keys on each call") void generateKeys_shouldProduceUniqueKeys() { + SecureCompletionClient client = new SecureCompletionClient(); client.generateKeys(false); PrivateKey firstKey = client.getPrivateKey(); @@ -83,11 +74,12 @@ class SecureCompletionClientTest { // ── Key Generation with File Save Tests ─────────────────────────── @Test - @Order(4) + @Execution(ExecutionMode.SAME_THREAD) @DisplayName("generateKeys with saveToFile=true should create key files") void generateKeys_withSaveToFile_shouldCreateKeyFiles(@TempDir Path tempDir) { File keyDir = tempDir.toFile(); + SecureCompletionClient client = new SecureCompletionClient(); client.generateKeys(true, keyDir.getAbsolutePath(), null); File privateKeyFile = new File(keyDir, Constants.DEFAULT_PRIVATE_KEY_FILE); @@ -100,11 +92,12 @@ class SecureCompletionClientTest { } @Test - @Order(5) + @Execution(ExecutionMode.SAME_THREAD) @DisplayName("generateKeys with password should encrypt private key file") void generateKeys_withPassword_shouldEncryptPrivateKey(@TempDir Path tempDir) throws Exception { File keyDir = tempDir.toFile(); + SecureCompletionClient client = new SecureCompletionClient(); client.generateKeys(true, keyDir.getAbsolutePath(), TEST_PASSWORD); File privateKeyFile = new File(keyDir, Constants.DEFAULT_PRIVATE_KEY_FILE); @@ -117,11 +110,12 @@ class SecureCompletionClientTest { } @Test - @Order(6) + @Execution(ExecutionMode.SAME_THREAD) @DisplayName("generateKeys should not overwrite existing key files") void generateKeys_shouldNotOverwriteExistingKeys(@TempDir Path tempDir) { File keyDir = tempDir.toFile(); + SecureCompletionClient client = new SecureCompletionClient(); client.generateKeys(true, keyDir.getAbsolutePath(), null); File privateKeyFile = new File(keyDir, Constants.DEFAULT_PRIVATE_KEY_FILE); long firstSize = privateKeyFile.length(); @@ -136,10 +130,11 @@ class SecureCompletionClientTest { // ── Key Loading Tests ───────────────────────────────────────────── @Test - @Order(7) + @Execution(ExecutionMode.SAME_THREAD) @DisplayName("loadKeys should load plaintext private key from file") void loadKeys_plaintext_shouldLoadPrivateKey(@TempDir Path tempDir) { File keyDir = tempDir.toFile(); + SecureCompletionClient client = new SecureCompletionClient(); client.generateKeys(true, keyDir.getAbsolutePath(), null); PrivateKey originalKey = client.getPrivateKey(); @@ -157,10 +152,11 @@ class SecureCompletionClientTest { } @Test - @Order(8) + @Execution(ExecutionMode.SAME_THREAD) @DisplayName("loadKeys should load encrypted private key with correct password") void loadKeys_encrypted_correctPassword_shouldLoadPrivateKey(@TempDir Path tempDir) { File keyDir = tempDir.toFile(); + SecureCompletionClient client = new SecureCompletionClient(); client.generateKeys(true, keyDir.getAbsolutePath(), TEST_PASSWORD); PrivateKey originalKey = client.getPrivateKey(); @@ -179,10 +175,11 @@ class SecureCompletionClientTest { } @Test - @Order(9) + @Execution(ExecutionMode.SAME_THREAD) @DisplayName("loadKeys should handle wrong password gracefully") void loadKeys_encrypted_wrongPassword_shouldHandleGracefully(@TempDir Path tempDir) { File keyDir = tempDir.toFile(); + SecureCompletionClient client = new SecureCompletionClient(); client.generateKeys(true, keyDir.getAbsolutePath(), TEST_PASSWORD); SecureCompletionClient loadClient = new SecureCompletionClient(); @@ -198,7 +195,6 @@ class SecureCompletionClientTest { } @Test - @Order(10) @DisplayName("loadKeys should throw exception for non-existent file") void loadKeys_nonExistentFile_shouldThrowException() { SecureCompletionClient loadClient = new SecureCompletionClient(); @@ -213,9 +209,9 @@ class SecureCompletionClientTest { // ── Key Validation Tests ────────────────────────────────────────── @Test - @Order(11) @DisplayName("validateRsaKey should accept valid 4096-bit key") void validateRsaKey_validKey_shouldPass() { + SecureCompletionClient client = new SecureCompletionClient(); client.generateKeys(false); PrivateKey key = client.getPrivateKey(); @@ -224,9 +220,9 @@ class SecureCompletionClientTest { } @Test - @Order(12) @DisplayName("validateRsaKey should reject null key") void validateRsaKey_nullKey_shouldThrowSecurityError() { + SecureCompletionClient client = new SecureCompletionClient(); SecurityError error = assertThrows(SecurityError.class, () -> client.validateRsaKey(null)); @@ -235,7 +231,6 @@ class SecureCompletionClientTest { } @Test - @Order(13) @DisplayName("validateRsaKey should reject keys below minimum size") void validateRsaKey_tooSmallKey_shouldThrowSecurityError() throws Exception { KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); @@ -243,6 +238,7 @@ class SecureCompletionClientTest { KeyPair pair = generator.generateKeyPair(); PrivateKey smallKey = pair.getPrivate(); + SecureCompletionClient client = new SecureCompletionClient(); SecurityError error = assertThrows(SecurityError.class, () -> client.validateRsaKey(smallKey)); @@ -251,7 +247,6 @@ class SecureCompletionClientTest { } @Test - @Order(14) @DisplayName("validateRsaKey should accept minimum size key (2048 bits)") void validateRsaKey_minimumSizeKey_shouldPass() throws Exception { KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); @@ -259,6 +254,7 @@ class SecureCompletionClientTest { KeyPair pair = generator.generateKeyPair(); PrivateKey minKey = pair.getPrivate(); + SecureCompletionClient client = new SecureCompletionClient(); assertDoesNotThrow(() -> client.validateRsaKey(minKey), "2048-bit key should pass validation (minimum)"); } @@ -266,12 +262,13 @@ class SecureCompletionClientTest { // ── Key Roundtrip Tests ─────────────────────────────────────────── @Test - @Order(15) + @Execution(ExecutionMode.SAME_THREAD) @DisplayName("Full roundtrip: generate, save, load should produce same key") void roundtrip_generateSaveLoad_shouldProduceSameKey(@TempDir Path tempDir) { File keyDir = tempDir.toFile(); // Generate and save + SecureCompletionClient client = new SecureCompletionClient(); client.generateKeys(true, keyDir.getAbsolutePath(), TEST_PASSWORD); PrivateKey originalKey = client.getPrivateKey(); @@ -291,7 +288,7 @@ class SecureCompletionClientTest { } @Test - @Order(16) + @Execution(ExecutionMode.SAME_THREAD) @DisplayName("Multiple generate/load cycles should work correctly") void multipleCycles_shouldWorkCorrectly(@TempDir Path tempDir) throws Exception { File keyDir = tempDir.toFile(); @@ -321,9 +318,9 @@ class SecureCompletionClientTest { // ── Utility Method Tests ────────────────────────────────────────── @Test - @Order(17) @DisplayName("urlEncodePublicKey should properly encode PEM keys") void urlEncodePublicKey_shouldEncodeCorrectly() { + SecureCompletionClient client = new SecureCompletionClient(); String pemKey = "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A\n-----END PUBLIC KEY-----"; String encoded = client.urlEncodePublicKey(pemKey); @@ -334,9 +331,9 @@ class SecureCompletionClientTest { } @Test - @Order(18) @DisplayName("urlEncodePublicKey should handle empty string") void urlEncodePublicKey_emptyString_shouldReturnEmpty() { + SecureCompletionClient client = new SecureCompletionClient(); String encoded = client.urlEncodePublicKey(""); assertEquals("", encoded, "Empty string should encode to empty string"); } @@ -344,7 +341,6 @@ class SecureCompletionClientTest { // ── 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"; @@ -361,7 +357,6 @@ class SecureCompletionClientTest { } @Test - @Order(20) @DisplayName("Pass2Key should produce different ciphertext for same plaintext") void pass2Key_shouldProduceDifferentCiphertext() throws Exception { String plaintext = "Same plaintext"; @@ -375,7 +370,6 @@ class SecureCompletionClientTest { } @Test - @Order(21) @DisplayName("Pass2Key decrypt should fail with wrong password") void pass2Key_wrongPassword_shouldFail() throws Exception { String plaintext = "Secret content"; @@ -391,7 +385,6 @@ class SecureCompletionClientTest { } @Test - @Order(22) @DisplayName("Pass2Key convertStringToPrivateKey should parse PEM correctly") void convertStringToPrivateKey_shouldParsePEM() throws Exception { SecureCompletionClient tempClient = new SecureCompletionClient(); @@ -408,7 +401,6 @@ class SecureCompletionClientTest { } @Test - @Order(23) @DisplayName("Pass2Key convertStringToPrivateKey should handle PEM with whitespace") void convertStringToPrivateKey_shouldHandleWhitespace() throws Exception { SecureCompletionClient tempClient = new SecureCompletionClient(); diff --git a/src/test/java/ai/nomyo/util/PEMConverterTest.java b/src/test/java/ai/nomyo/util/PEMConverterTest.java new file mode 100644 index 0000000..1b957d1 --- /dev/null +++ b/src/test/java/ai/nomyo/util/PEMConverterTest.java @@ -0,0 +1,166 @@ +package ai.nomyo.util; + +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.api.parallel.ExecutionMode; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.*; + +@Execution(ExecutionMode.CONCURRENT) +class PEMConverterTest { + + private static String realPublicKeyPem; + private static String realPrivateKeyPem; + + @BeforeAll + static void loadRealKeys() throws Exception { + Path keyDir = Path.of("client_keys"); + realPublicKeyPem = Files.readString(keyDir.resolve("public_key.pem")); + realPrivateKeyPem = Files.readString(keyDir.resolve("private_key.pem")); + } + + // ── validatePEM Tests ───────────────────────────────────────────── + + @Test + @DisplayName("validatePEM should accept real public key file content") + void validatePEM_realPublicKeyFile_shouldPass() { + assertTrue(PEMConverter.validatePEM(realPublicKeyPem), + "Real public key file content should be valid PEM"); + } + + @Test + @DisplayName("validatePEM should accept PEM generated by toPEM") + void validatePEM_generatedByToPEM_shouldPass() { + String b64Content = realPublicKeyPem + .replace("-----BEGIN PUBLIC KEY-----\n", "") + .replace("\n-----END PUBLIC KEY-----", "") + .replace("\n", ""); + byte[] decoded = java.util.Base64.getDecoder().decode(b64Content); + String generatedPem = PEMConverter.toPEM(decoded, false); + + assertTrue(PEMConverter.validatePEM(generatedPem), + "PEM generated by toPEM should be valid"); + } + + @Test + @DisplayName("validatePEM should reject null input") + void validatePEM_nullInput_shouldReturnFalse() { + assertFalse(PEMConverter.validatePEM(null)); + } + + @Test + @DisplayName("validatePEM should reject blank input") + void validatePEM_blankInput_shouldReturnFalse() { + assertFalse(PEMConverter.validatePEM(" ")); + assertFalse(PEMConverter.validatePEM("")); + } + + @Test + @DisplayName("validatePEM should reject private key PEM") + void validatePEM_privateKey_shouldReturnFalse() { + assertFalse(PEMConverter.validatePEM(realPrivateKeyPem)); + } + + @Test + @DisplayName("validatePEM should reject truncated PEM") + void validatePEM_truncated_shouldReturnFalse() { + assertFalse(PEMConverter.validatePEM("-----BEGIN PUBLIC KEY-----\nMIIBIjAN")); + } + + @Test + @DisplayName("validatePEM should reject PEM with wrong end marker") + void validatePEM_wrongEndMarker_shouldReturnFalse() { + String pem = "-----BEGIN PUBLIC KEY-----\nMIIBIjAN\n-----BEGIN PRIVATE KEY-----"; + assertFalse(PEMConverter.validatePEM(pem)); + } + + @Test + @DisplayName("validatePEM should accept PEM with trailing newline") + void validatePEM_trailingNewline_shouldPass() { + String pemWithNewline = realPublicKeyPem + "\n"; + assertTrue(PEMConverter.validatePEM(pemWithNewline), + "PEM with trailing newline should be valid"); + } + + // ── toPEM Tests ─────────────────────────────────────────────────── + + @Test + @DisplayName("toPEM should produce correct header and footer") + void toPEM_publicKey_shouldHaveCorrectMarkers() { + String b64Content = realPublicKeyPem + .replace("-----BEGIN PUBLIC KEY-----\n", "") + .replace("\n-----END PUBLIC KEY-----", "") + .replace("\n", ""); + byte[] decoded = java.util.Base64.getDecoder().decode(b64Content); + + String pem = PEMConverter.toPEM(decoded, false); + + assertTrue(pem.startsWith("-----BEGIN PUBLIC KEY-----\n")); + assertTrue(pem.contains("-----END PUBLIC KEY-----")); + } + + @Test + @DisplayName("toPEM should produce correct header and footer for private key") + void toPEM_privateKey_shouldHaveCorrectMarkers() { + String b64Content = realPrivateKeyPem + .replace("-----BEGIN PRIVATE KEY-----\n", "") + .replace("\n-----END PRIVATE KEY-----", "") + .replace("\n", ""); + byte[] decoded = java.util.Base64.getDecoder().decode(b64Content); + + String pem = PEMConverter.toPEM(decoded, true); + + assertTrue(pem.startsWith("-----BEGIN PRIVATE KEY-----\n")); + assertTrue(pem.contains("-----END PRIVATE KEY-----")); + } + + @Test + @DisplayName("toPEM should wrap base64 content at 64 characters") + void toPEM_shouldWrapAt64Chars() { + byte[] keyBytes = new byte[100]; + for (int i = 0; i < 100; i++) { + keyBytes[i] = (byte) i; + } + + String pem = PEMConverter.toPEM(keyBytes, false); + + String[] lines = pem.split("\n"); + for (int i = 1; i < lines.length - 1; i++) { + assertTrue(lines[i].length() <= 64, + "Line " + i + " should be at most 64 chars, got " + lines[i].length()); + } + } + + // ── Roundtrip Tests ─────────────────────────────────────────────── + + @Test + @DisplayName("toPEM + validatePEM roundtrip should always pass") + void roundtrip_toPEM_validatePEM_shouldPass() { + String b64Content = realPublicKeyPem + .replace("-----BEGIN PUBLIC KEY-----\n", "") + .replace("\n-----END PUBLIC KEY-----", "") + .replace("\n", ""); + byte[] decoded = java.util.Base64.getDecoder().decode(b64Content); + + String pem = PEMConverter.toPEM(decoded, false); + assertTrue(PEMConverter.validatePEM(pem), + "PEM generated by toPEM should pass validatePEM"); + } + + @Test + @DisplayName("toPEM + validatePEM roundtrip should work for private keys") + void roundtrip_toPEM_validatePEM_privateKey_shouldPass() { + String b64Content = realPrivateKeyPem + .replace("-----BEGIN PRIVATE KEY-----\n", "") + .replace("\n-----END PRIVATE KEY-----", "") + .replace("\n", ""); + byte[] decoded = java.util.Base64.getDecoder().decode(b64Content); + + String pem = PEMConverter.toPEM(decoded, true); + assertFalse(PEMConverter.validatePEM(pem), + "Private key PEM should not pass validatePEM (public key check)"); + } +}