Start with encryption
This commit is contained in:
parent
9df61e0cd3
commit
b6af1c9792
8 changed files with 413 additions and 79 deletions
7
pom.xml
7
pom.xml
|
|
@ -27,6 +27,13 @@
|
||||||
<version>5.12.1</version>
|
<version>5.12.1</version>
|
||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<!-- Source: https://mvnrepository.com/artifact/com.google.code.gson/gson -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.google.code.gson</groupId>
|
||||||
|
<artifactId>gson</artifactId>
|
||||||
|
<version>2.13.2</version>
|
||||||
|
<scope>compile</scope>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
|
|
|
||||||
61
src/main/java/ai/nomyo/EncryptedRequest.java
Normal file
61
src/main/java/ai/nomyo/EncryptedRequest.java
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
package ai.nomyo;
|
package ai.nomyo;
|
||||||
|
|
||||||
import ai.nomyo.errors.SecurityError;
|
import java.util.concurrent.ExecutionException;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Entry point — loads RSA keys and validates key length.
|
* Entry point — loads RSA keys and validates key length.
|
||||||
|
|
@ -10,17 +10,15 @@ public class Main {
|
||||||
static void main() {
|
static void main() {
|
||||||
SecureCompletionClient secureCompletionClient = new SecureCompletionClient();
|
SecureCompletionClient secureCompletionClient = new SecureCompletionClient();
|
||||||
//secureCompletionClient.generateKeys(true, "client_keys", "pokemon");
|
//secureCompletionClient.generateKeys(true, "client_keys", "pokemon");
|
||||||
secureCompletionClient.loadKeys("client_keys/private_key.pem", "pokemon");
|
//secureCompletionClient.loadKeys("client_keys/private_key.pem", "pokemon");
|
||||||
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
secureCompletionClient.validateRsaKey(secureCompletionClient.getPrivateKey());
|
System.out.println(secureCompletionClient.fetchServerPublicKey().get());
|
||||||
} catch (SecurityError e) {
|
} catch (InterruptedException | ExecutionException e) {
|
||||||
System.out.println("RSA Key is too short!");
|
throw new RuntimeException(e);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
System.out.println("RSA Key has correct length!");
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,20 +6,27 @@ import ai.nomyo.util.Pass2Key;
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
|
|
||||||
import javax.crypto.*;
|
import javax.crypto.*;
|
||||||
import java.io.FileWriter;
|
import javax.crypto.spec.GCMParameterSpec;
|
||||||
|
import javax.crypto.spec.SecretKeySpec;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.math.BigInteger;
|
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.charset.StandardCharsets;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.StandardOpenOption;
|
|
||||||
import java.nio.file.attribute.PosixFilePermission;
|
import java.nio.file.attribute.PosixFilePermission;
|
||||||
import java.nio.file.attribute.PosixFilePermissions;
|
import java.nio.file.attribute.PosixFilePermissions;
|
||||||
import java.security.*;
|
import java.security.*;
|
||||||
import java.security.spec.RSAKeyGenParameterSpec;
|
import java.security.spec.*;
|
||||||
import java.util.Map;
|
import java.time.Duration;
|
||||||
import java.util.Set;
|
import java.time.temporal.ChronoUnit;
|
||||||
|
import java.util.*;
|
||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import java.util.concurrent.CompletionException;
|
||||||
|
import java.util.concurrent.ExecutionException;
|
||||||
import java.util.concurrent.locks.ReentrantLock;
|
import java.util.concurrent.locks.ReentrantLock;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -61,19 +68,17 @@ public class SecureCompletionClient {
|
||||||
* Lock for double-checked key initialization.
|
* Lock for double-checked key initialization.
|
||||||
*/
|
*/
|
||||||
private final ReentrantLock keyInitLock = new ReentrantLock();
|
private final ReentrantLock keyInitLock = new ReentrantLock();
|
||||||
|
private final HttpClient httpClient;
|
||||||
/**
|
/**
|
||||||
* RSA private key ({@code null} until loaded/generated).
|
* RSA private key ({@code null} until loaded/generated).
|
||||||
*/
|
*/
|
||||||
@Getter
|
@Getter
|
||||||
private PrivateKey privateKey;
|
private PrivateKey privateKey;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PEM-encoded public key ({@code null} until loaded/generated).
|
* PEM-encoded public key ({@code null} until loaded/generated).
|
||||||
*/
|
*/
|
||||||
@Getter
|
@Getter
|
||||||
private String publicPemKey;
|
private String publicPemKey;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Keys initialized.
|
* Keys initialized.
|
||||||
*/
|
*/
|
||||||
|
|
@ -98,6 +103,7 @@ public class SecureCompletionClient {
|
||||||
this.useSecureMemory = secureMemory;
|
this.useSecureMemory = secureMemory;
|
||||||
this.keySize = Constants.RSA_KEY_SIZE;
|
this.keySize = Constants.RSA_KEY_SIZE;
|
||||||
this.maxRetries = maxRetries;
|
this.maxRetries = maxRetries;
|
||||||
|
this.httpClient = HttpClient.newHttpClient();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String readFileContent(String filePath) throws IOException {
|
private static String readFileContent(String filePath) throws IOException {
|
||||||
|
|
@ -117,7 +123,7 @@ public class SecureCompletionClient {
|
||||||
String privatePem = PEMConverter.toPEM(pair.getPrivate().getEncoded(), true);
|
String privatePem = PEMConverter.toPEM(pair.getPrivate().getEncoded(), true);
|
||||||
String publicPem = PEMConverter.toPEM(pair.getPublic().getEncoded(), false);
|
String publicPem = PEMConverter.toPEM(pair.getPublic().getEncoded(), false);
|
||||||
|
|
||||||
if (saveToFile) {
|
if (saveToFile) {
|
||||||
Path keyFolder = Path.of(keyDir);
|
Path keyFolder = Path.of(keyDir);
|
||||||
if (!Files.exists(keyFolder)) {
|
if (!Files.exists(keyFolder)) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -230,9 +236,38 @@ public class SecureCompletionClient {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET {@code {routerUrl}/pki/public_key}. Returns server PEM public key.
|
* 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<String> fetchServerPublicKey() {
|
public CompletableFuture<String> 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}.
|
* Core hybrid encryption: AES-256-GCM encrypts {@code payloadBytes} with {@code aesKey}.
|
||||||
*/
|
*/
|
||||||
public CompletableFuture<byte[]> doEncrypt(byte[] payloadBytes, byte[] aesKey) {
|
public CompletableFuture<byte[]> doEncrypt(byte[] payloadBytes, Key aesKey) {
|
||||||
throw new UnsupportedOperationException("Not yet implemented");
|
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();
|
keyInitLock.lock();
|
||||||
try {
|
try {
|
||||||
if (keysInitialized) return;
|
if (keysInitialized) return;
|
||||||
if (keyDir == null || keyDir.isEmpty()) {
|
generateKeys(keyDir != null && !keyDir.isEmpty());
|
||||||
generateKeys(false);
|
|
||||||
} else {
|
|
||||||
generateKeys(true);
|
|
||||||
}
|
|
||||||
keysInitialized = true;
|
keysInitialized = true;
|
||||||
} finally {
|
} finally {
|
||||||
keyInitLock.unlock();
|
keyInitLock.unlock();
|
||||||
|
|
@ -340,10 +440,10 @@ public class SecureCompletionClient {
|
||||||
try {
|
try {
|
||||||
var kf = KeyFactory.getInstance("RSA");
|
var kf = KeyFactory.getInstance("RSA");
|
||||||
try {
|
try {
|
||||||
var crtSpec = kf.getKeySpec(key, java.security.spec.RSAPrivateCrtKeySpec.class);
|
var crtSpec = kf.getKeySpec(key, RSAPrivateCrtKeySpec.class);
|
||||||
return crtSpec.getModulus().bitLength();
|
return crtSpec.getModulus().bitLength();
|
||||||
} catch (Exception ignored) {
|
} catch (Exception ignored) {
|
||||||
var privSpec = kf.getKeySpec(key, java.security.spec.RSAPrivateKeySpec.class);
|
var privSpec = kf.getKeySpec(key, RSAPrivateKeySpec.class);
|
||||||
return privSpec.getModulus().bitLength();
|
return privSpec.getModulus().bitLength();
|
||||||
}
|
}
|
||||||
} catch (Exception ignored) {
|
} catch (Exception ignored) {
|
||||||
|
|
@ -380,7 +480,7 @@ public class SecureCompletionClient {
|
||||||
* URL-encodes PEM key for {@code X-Public-Key} header.
|
* URL-encodes PEM key for {@code X-Public-Key} header.
|
||||||
*/
|
*/
|
||||||
public String urlEncodePublicKey(String pemKey) {
|
public String urlEncodePublicKey(String pemKey) {
|
||||||
return java.net.URLEncoder.encode(pemKey, StandardCharsets.UTF_8);
|
return URLEncoder.encode(pemKey, StandardCharsets.UTF_8);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package ai.nomyo.util;
|
package ai.nomyo.util;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -23,4 +24,21 @@ public class PEMConverter {
|
||||||
|
|
||||||
return publicKeyFormatted.toString();
|
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-----");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ package ai.nomyo;
|
||||||
import ai.nomyo.errors.*;
|
import ai.nomyo.errors.*;
|
||||||
import org.junit.jupiter.api.*;
|
import org.junit.jupiter.api.*;
|
||||||
import org.junit.jupiter.api.io.TempDir;
|
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.io.File;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
|
|
@ -11,7 +13,8 @@ import java.security.*;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
|
|
||||||
|
@Execution(ExecutionMode.CONCURRENT)
|
||||||
class SecureCompletionClientE2ETest {
|
class SecureCompletionClientE2ETest {
|
||||||
|
|
||||||
private static final String TEST_PASSWORD = "e2e-test-password-456";
|
private static final String TEST_PASSWORD = "e2e-test-password-456";
|
||||||
|
|
@ -31,7 +34,7 @@ class SecureCompletionClientE2ETest {
|
||||||
// ── Full Lifecycle E2E Tests ──────────────────────────────────────
|
// ── Full Lifecycle E2E Tests ──────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@Order(1)
|
@Execution(ExecutionMode.SAME_THREAD)
|
||||||
@DisplayName("E2E: Generate keys, save to disk, load in new client, validate")
|
@DisplayName("E2E: Generate keys, save to disk, load in new client, validate")
|
||||||
void e2e_fullLifecycle_generateSaveLoadValidate() {
|
void e2e_fullLifecycle_generateSaveLoadValidate() {
|
||||||
// Step 1: Generate keys and save to disk
|
// Step 1: Generate keys and save to disk
|
||||||
|
|
@ -72,7 +75,7 @@ class SecureCompletionClientE2ETest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@Order(2)
|
@Execution(ExecutionMode.SAME_THREAD)
|
||||||
@DisplayName("E2E: Generate plaintext keys, load, and validate")
|
@DisplayName("E2E: Generate plaintext keys, load, and validate")
|
||||||
void e2e_plaintextKeys_generateLoadValidate() {
|
void e2e_plaintextKeys_generateLoadValidate() {
|
||||||
// Generate plaintext keys (no password)
|
// Generate plaintext keys (no password)
|
||||||
|
|
@ -96,7 +99,6 @@ class SecureCompletionClientE2ETest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@Order(3)
|
|
||||||
@DisplayName("E2E: Key validation with various key sizes")
|
@DisplayName("E2E: Key validation with various key sizes")
|
||||||
void e2e_keyValidation_variousSizes() throws Exception {
|
void e2e_keyValidation_variousSizes() throws Exception {
|
||||||
SecureCompletionClient client = new SecureCompletionClient();
|
SecureCompletionClient client = new SecureCompletionClient();
|
||||||
|
|
@ -126,7 +128,6 @@ class SecureCompletionClientE2ETest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@Order(4)
|
|
||||||
@DisplayName("E2E: HTTP status mapping covers all documented cases")
|
@DisplayName("E2E: HTTP status mapping covers all documented cases")
|
||||||
void e2e_httpStatusMapping_allCases() {
|
void e2e_httpStatusMapping_allCases() {
|
||||||
SecureCompletionClient client = new SecureCompletionClient();
|
SecureCompletionClient client = new SecureCompletionClient();
|
||||||
|
|
@ -176,7 +177,6 @@ class SecureCompletionClientE2ETest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@Order(5)
|
|
||||||
@DisplayName("E2E: Retryable status codes match Constants.RETRYABLE_STATUS_CODES")
|
@DisplayName("E2E: Retryable status codes match Constants.RETRYABLE_STATUS_CODES")
|
||||||
void e2e_retryableStatusCodes_matchConstants() {
|
void e2e_retryableStatusCodes_matchConstants() {
|
||||||
SecureCompletionClient client = new SecureCompletionClient();
|
SecureCompletionClient client = new SecureCompletionClient();
|
||||||
|
|
@ -189,7 +189,6 @@ class SecureCompletionClientE2ETest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@Order(6)
|
|
||||||
@DisplayName("E2E: URL encoding of public key PEM")
|
@DisplayName("E2E: URL encoding of public key PEM")
|
||||||
void e2e_urlEncoding_publicKey() {
|
void e2e_urlEncoding_publicKey() {
|
||||||
SecureCompletionClient client = new SecureCompletionClient();
|
SecureCompletionClient client = new SecureCompletionClient();
|
||||||
|
|
@ -214,7 +213,7 @@ class SecureCompletionClientE2ETest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@Order(7)
|
@Execution(ExecutionMode.SAME_THREAD)
|
||||||
@DisplayName("E2E: Multiple clients can independently generate and load keys")
|
@DisplayName("E2E: Multiple clients can independently generate and load keys")
|
||||||
void e2e_multipleClients_independentOperations() throws Exception {
|
void e2e_multipleClients_independentOperations() throws Exception {
|
||||||
File dir1 = tempDir.resolve("dir1").toFile();
|
File dir1 = tempDir.resolve("dir1").toFile();
|
||||||
|
|
@ -258,7 +257,6 @@ class SecureCompletionClientE2ETest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@Order(8)
|
|
||||||
@DisplayName("E2E: Client constructor parameters are correctly set")
|
@DisplayName("E2E: Client constructor parameters are correctly set")
|
||||||
void e2e_clientConstructor_parametersSetCorrectly() {
|
void e2e_clientConstructor_parametersSetCorrectly() {
|
||||||
SecureCompletionClient client = new SecureCompletionClient(
|
SecureCompletionClient client = new SecureCompletionClient(
|
||||||
|
|
@ -275,7 +273,6 @@ class SecureCompletionClientE2ETest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@Order(9)
|
|
||||||
@DisplayName("E2E: Client strips trailing slashes from routerUrl")
|
@DisplayName("E2E: Client strips trailing slashes from routerUrl")
|
||||||
void e2e_clientConstructor_stripsTrailingSlashes() {
|
void e2e_clientConstructor_stripsTrailingSlashes() {
|
||||||
SecureCompletionClient client = new SecureCompletionClient(
|
SecureCompletionClient client = new SecureCompletionClient(
|
||||||
|
|
@ -287,7 +284,6 @@ class SecureCompletionClientE2ETest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@Order(10)
|
|
||||||
@DisplayName("E2E: Client uses default values when constructed with no args")
|
@DisplayName("E2E: Client uses default values when constructed with no args")
|
||||||
void e2e_clientConstructor_defaultValues() {
|
void e2e_clientConstructor_defaultValues() {
|
||||||
SecureCompletionClient client = new SecureCompletionClient();
|
SecureCompletionClient client = new SecureCompletionClient();
|
||||||
|
|
@ -299,7 +295,7 @@ class SecureCompletionClientE2ETest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@Order(11)
|
@Execution(ExecutionMode.SAME_THREAD)
|
||||||
@DisplayName("E2E: Encrypted key file is unreadable without password")
|
@DisplayName("E2E: Encrypted key file is unreadable without password")
|
||||||
void e2e_encryptedKey_unreadableWithoutPassword() throws Exception {
|
void e2e_encryptedKey_unreadableWithoutPassword() throws Exception {
|
||||||
SecureCompletionClient client = new SecureCompletionClient();
|
SecureCompletionClient client = new SecureCompletionClient();
|
||||||
|
|
@ -321,7 +317,6 @@ class SecureCompletionClientE2ETest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@Order(12)
|
|
||||||
@DisplayName("E2E: Generate keys without saving produces in-memory keys")
|
@DisplayName("E2E: Generate keys without saving produces in-memory keys")
|
||||||
void e2e_generateKeys_noSave_producesInMemoryKeys() {
|
void e2e_generateKeys_noSave_producesInMemoryKeys() {
|
||||||
SecureCompletionClient client = new SecureCompletionClient();
|
SecureCompletionClient client = new SecureCompletionClient();
|
||||||
|
|
@ -336,7 +331,6 @@ class SecureCompletionClientE2ETest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@Order(13)
|
|
||||||
@DisplayName("E2E: SecurityError is thrown for null key validation")
|
@DisplayName("E2E: SecurityError is thrown for null key validation")
|
||||||
void e2e_nullKeyValidation_throwsSecurityError() {
|
void e2e_nullKeyValidation_throwsSecurityError() {
|
||||||
SecureCompletionClient client = new SecureCompletionClient();
|
SecureCompletionClient client = new SecureCompletionClient();
|
||||||
|
|
@ -349,7 +343,6 @@ class SecureCompletionClientE2ETest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@Order(14)
|
|
||||||
@DisplayName("E2E: mapHttpStatus returns null for 200 status")
|
@DisplayName("E2E: mapHttpStatus returns null for 200 status")
|
||||||
void e2e_mapHttpStatus_200_returnsNull() {
|
void e2e_mapHttpStatus_200_returnsNull() {
|
||||||
SecureCompletionClient client = new SecureCompletionClient();
|
SecureCompletionClient client = new SecureCompletionClient();
|
||||||
|
|
@ -359,7 +352,6 @@ class SecureCompletionClientE2ETest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@Order(15)
|
|
||||||
@DisplayName("E2E: mapHttpStatus includes response body in error message")
|
@DisplayName("E2E: mapHttpStatus includes response body in error message")
|
||||||
void e2e_mapHttpStatus_includesResponseBody() {
|
void e2e_mapHttpStatus_includesResponseBody() {
|
||||||
SecureCompletionClient client = new SecureCompletionClient();
|
SecureCompletionClient client = new SecureCompletionClient();
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import ai.nomyo.errors.SecurityError;
|
||||||
import ai.nomyo.util.Pass2Key;
|
import ai.nomyo.util.Pass2Key;
|
||||||
import org.junit.jupiter.api.*;
|
import org.junit.jupiter.api.*;
|
||||||
import org.junit.jupiter.api.io.TempDir;
|
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.io.File;
|
||||||
import java.math.BigInteger;
|
import java.math.BigInteger;
|
||||||
|
|
@ -16,29 +18,18 @@ import java.security.spec.RSAPrivateCrtKeySpec;
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
|
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
|
||||||
|
@Execution(ExecutionMode.CONCURRENT)
|
||||||
class SecureCompletionClientTest {
|
class SecureCompletionClientTest {
|
||||||
|
|
||||||
private static final String TEST_PASSWORD = "test-password-123";
|
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 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 ──────────────────────────────────────────
|
// ── Key Generation Tests ──────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@Order(1)
|
|
||||||
@DisplayName("generateKeys should create 4096-bit RSA key pair")
|
@DisplayName("generateKeys should create 4096-bit RSA key pair")
|
||||||
void generateKeys_shouldCreateValidKeyPair() {
|
void generateKeys_shouldCreateValidKeyPair() {
|
||||||
|
SecureCompletionClient client = new SecureCompletionClient();
|
||||||
client.generateKeys(false);
|
client.generateKeys(false);
|
||||||
|
|
||||||
PrivateKey privateKey = client.getPrivateKey();
|
PrivateKey privateKey = client.getPrivateKey();
|
||||||
|
|
@ -51,9 +42,9 @@ class SecureCompletionClientTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@Order(2)
|
|
||||||
@DisplayName("generateKeys should produce keys with correct bit size")
|
@DisplayName("generateKeys should produce keys with correct bit size")
|
||||||
void generateKeys_shouldProduceCorrectKeySize() throws Exception {
|
void generateKeys_shouldProduceCorrectKeySize() throws Exception {
|
||||||
|
SecureCompletionClient client = new SecureCompletionClient();
|
||||||
client.generateKeys(false);
|
client.generateKeys(false);
|
||||||
|
|
||||||
PrivateKey privateKey = client.getPrivateKey();
|
PrivateKey privateKey = client.getPrivateKey();
|
||||||
|
|
@ -66,9 +57,9 @@ class SecureCompletionClientTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@Order(3)
|
|
||||||
@DisplayName("generateKeys should create unique keys on each call")
|
@DisplayName("generateKeys should create unique keys on each call")
|
||||||
void generateKeys_shouldProduceUniqueKeys() {
|
void generateKeys_shouldProduceUniqueKeys() {
|
||||||
|
SecureCompletionClient client = new SecureCompletionClient();
|
||||||
client.generateKeys(false);
|
client.generateKeys(false);
|
||||||
PrivateKey firstKey = client.getPrivateKey();
|
PrivateKey firstKey = client.getPrivateKey();
|
||||||
|
|
||||||
|
|
@ -83,11 +74,12 @@ class SecureCompletionClientTest {
|
||||||
// ── Key Generation with File Save Tests ───────────────────────────
|
// ── Key Generation with File Save Tests ───────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@Order(4)
|
@Execution(ExecutionMode.SAME_THREAD)
|
||||||
@DisplayName("generateKeys with saveToFile=true should create key files")
|
@DisplayName("generateKeys with saveToFile=true should create key files")
|
||||||
void generateKeys_withSaveToFile_shouldCreateKeyFiles(@TempDir Path tempDir) {
|
void generateKeys_withSaveToFile_shouldCreateKeyFiles(@TempDir Path tempDir) {
|
||||||
File keyDir = tempDir.toFile();
|
File keyDir = tempDir.toFile();
|
||||||
|
|
||||||
|
SecureCompletionClient client = new SecureCompletionClient();
|
||||||
client.generateKeys(true, keyDir.getAbsolutePath(), null);
|
client.generateKeys(true, keyDir.getAbsolutePath(), null);
|
||||||
|
|
||||||
File privateKeyFile = new File(keyDir, Constants.DEFAULT_PRIVATE_KEY_FILE);
|
File privateKeyFile = new File(keyDir, Constants.DEFAULT_PRIVATE_KEY_FILE);
|
||||||
|
|
@ -100,11 +92,12 @@ class SecureCompletionClientTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@Order(5)
|
@Execution(ExecutionMode.SAME_THREAD)
|
||||||
@DisplayName("generateKeys with password should encrypt private key file")
|
@DisplayName("generateKeys with password should encrypt private key file")
|
||||||
void generateKeys_withPassword_shouldEncryptPrivateKey(@TempDir Path tempDir) throws Exception {
|
void generateKeys_withPassword_shouldEncryptPrivateKey(@TempDir Path tempDir) throws Exception {
|
||||||
File keyDir = tempDir.toFile();
|
File keyDir = tempDir.toFile();
|
||||||
|
|
||||||
|
SecureCompletionClient client = new SecureCompletionClient();
|
||||||
client.generateKeys(true, keyDir.getAbsolutePath(), TEST_PASSWORD);
|
client.generateKeys(true, keyDir.getAbsolutePath(), TEST_PASSWORD);
|
||||||
|
|
||||||
File privateKeyFile = new File(keyDir, Constants.DEFAULT_PRIVATE_KEY_FILE);
|
File privateKeyFile = new File(keyDir, Constants.DEFAULT_PRIVATE_KEY_FILE);
|
||||||
|
|
@ -117,11 +110,12 @@ class SecureCompletionClientTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@Order(6)
|
@Execution(ExecutionMode.SAME_THREAD)
|
||||||
@DisplayName("generateKeys should not overwrite existing key files")
|
@DisplayName("generateKeys should not overwrite existing key files")
|
||||||
void generateKeys_shouldNotOverwriteExistingKeys(@TempDir Path tempDir) {
|
void generateKeys_shouldNotOverwriteExistingKeys(@TempDir Path tempDir) {
|
||||||
File keyDir = tempDir.toFile();
|
File keyDir = tempDir.toFile();
|
||||||
|
|
||||||
|
SecureCompletionClient client = new SecureCompletionClient();
|
||||||
client.generateKeys(true, keyDir.getAbsolutePath(), null);
|
client.generateKeys(true, keyDir.getAbsolutePath(), null);
|
||||||
File privateKeyFile = new File(keyDir, Constants.DEFAULT_PRIVATE_KEY_FILE);
|
File privateKeyFile = new File(keyDir, Constants.DEFAULT_PRIVATE_KEY_FILE);
|
||||||
long firstSize = privateKeyFile.length();
|
long firstSize = privateKeyFile.length();
|
||||||
|
|
@ -136,10 +130,11 @@ class SecureCompletionClientTest {
|
||||||
// ── Key Loading Tests ─────────────────────────────────────────────
|
// ── Key Loading Tests ─────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@Order(7)
|
@Execution(ExecutionMode.SAME_THREAD)
|
||||||
@DisplayName("loadKeys should load plaintext private key from file")
|
@DisplayName("loadKeys should load plaintext private key from file")
|
||||||
void loadKeys_plaintext_shouldLoadPrivateKey(@TempDir Path tempDir) {
|
void loadKeys_plaintext_shouldLoadPrivateKey(@TempDir Path tempDir) {
|
||||||
File keyDir = tempDir.toFile();
|
File keyDir = tempDir.toFile();
|
||||||
|
SecureCompletionClient client = new SecureCompletionClient();
|
||||||
client.generateKeys(true, keyDir.getAbsolutePath(), null);
|
client.generateKeys(true, keyDir.getAbsolutePath(), null);
|
||||||
|
|
||||||
PrivateKey originalKey = client.getPrivateKey();
|
PrivateKey originalKey = client.getPrivateKey();
|
||||||
|
|
@ -157,10 +152,11 @@ class SecureCompletionClientTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@Order(8)
|
@Execution(ExecutionMode.SAME_THREAD)
|
||||||
@DisplayName("loadKeys should load encrypted private key with correct password")
|
@DisplayName("loadKeys should load encrypted private key with correct password")
|
||||||
void loadKeys_encrypted_correctPassword_shouldLoadPrivateKey(@TempDir Path tempDir) {
|
void loadKeys_encrypted_correctPassword_shouldLoadPrivateKey(@TempDir Path tempDir) {
|
||||||
File keyDir = tempDir.toFile();
|
File keyDir = tempDir.toFile();
|
||||||
|
SecureCompletionClient client = new SecureCompletionClient();
|
||||||
client.generateKeys(true, keyDir.getAbsolutePath(), TEST_PASSWORD);
|
client.generateKeys(true, keyDir.getAbsolutePath(), TEST_PASSWORD);
|
||||||
|
|
||||||
PrivateKey originalKey = client.getPrivateKey();
|
PrivateKey originalKey = client.getPrivateKey();
|
||||||
|
|
@ -179,10 +175,11 @@ class SecureCompletionClientTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@Order(9)
|
@Execution(ExecutionMode.SAME_THREAD)
|
||||||
@DisplayName("loadKeys should handle wrong password gracefully")
|
@DisplayName("loadKeys should handle wrong password gracefully")
|
||||||
void loadKeys_encrypted_wrongPassword_shouldHandleGracefully(@TempDir Path tempDir) {
|
void loadKeys_encrypted_wrongPassword_shouldHandleGracefully(@TempDir Path tempDir) {
|
||||||
File keyDir = tempDir.toFile();
|
File keyDir = tempDir.toFile();
|
||||||
|
SecureCompletionClient client = new SecureCompletionClient();
|
||||||
client.generateKeys(true, keyDir.getAbsolutePath(), TEST_PASSWORD);
|
client.generateKeys(true, keyDir.getAbsolutePath(), TEST_PASSWORD);
|
||||||
|
|
||||||
SecureCompletionClient loadClient = new SecureCompletionClient();
|
SecureCompletionClient loadClient = new SecureCompletionClient();
|
||||||
|
|
@ -198,7 +195,6 @@ class SecureCompletionClientTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@Order(10)
|
|
||||||
@DisplayName("loadKeys should throw exception for non-existent file")
|
@DisplayName("loadKeys should throw exception for non-existent file")
|
||||||
void loadKeys_nonExistentFile_shouldThrowException() {
|
void loadKeys_nonExistentFile_shouldThrowException() {
|
||||||
SecureCompletionClient loadClient = new SecureCompletionClient();
|
SecureCompletionClient loadClient = new SecureCompletionClient();
|
||||||
|
|
@ -213,9 +209,9 @@ class SecureCompletionClientTest {
|
||||||
// ── Key Validation Tests ──────────────────────────────────────────
|
// ── Key Validation Tests ──────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@Order(11)
|
|
||||||
@DisplayName("validateRsaKey should accept valid 4096-bit key")
|
@DisplayName("validateRsaKey should accept valid 4096-bit key")
|
||||||
void validateRsaKey_validKey_shouldPass() {
|
void validateRsaKey_validKey_shouldPass() {
|
||||||
|
SecureCompletionClient client = new SecureCompletionClient();
|
||||||
client.generateKeys(false);
|
client.generateKeys(false);
|
||||||
PrivateKey key = client.getPrivateKey();
|
PrivateKey key = client.getPrivateKey();
|
||||||
|
|
||||||
|
|
@ -224,9 +220,9 @@ class SecureCompletionClientTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@Order(12)
|
|
||||||
@DisplayName("validateRsaKey should reject null key")
|
@DisplayName("validateRsaKey should reject null key")
|
||||||
void validateRsaKey_nullKey_shouldThrowSecurityError() {
|
void validateRsaKey_nullKey_shouldThrowSecurityError() {
|
||||||
|
SecureCompletionClient client = new SecureCompletionClient();
|
||||||
SecurityError error = assertThrows(SecurityError.class, () ->
|
SecurityError error = assertThrows(SecurityError.class, () ->
|
||||||
client.validateRsaKey(null));
|
client.validateRsaKey(null));
|
||||||
|
|
||||||
|
|
@ -235,7 +231,6 @@ class SecureCompletionClientTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@Order(13)
|
|
||||||
@DisplayName("validateRsaKey should reject keys below minimum size")
|
@DisplayName("validateRsaKey should reject keys below minimum size")
|
||||||
void validateRsaKey_tooSmallKey_shouldThrowSecurityError() throws Exception {
|
void validateRsaKey_tooSmallKey_shouldThrowSecurityError() throws Exception {
|
||||||
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
|
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
|
||||||
|
|
@ -243,6 +238,7 @@ class SecureCompletionClientTest {
|
||||||
KeyPair pair = generator.generateKeyPair();
|
KeyPair pair = generator.generateKeyPair();
|
||||||
PrivateKey smallKey = pair.getPrivate();
|
PrivateKey smallKey = pair.getPrivate();
|
||||||
|
|
||||||
|
SecureCompletionClient client = new SecureCompletionClient();
|
||||||
SecurityError error = assertThrows(SecurityError.class, () ->
|
SecurityError error = assertThrows(SecurityError.class, () ->
|
||||||
client.validateRsaKey(smallKey));
|
client.validateRsaKey(smallKey));
|
||||||
|
|
||||||
|
|
@ -251,7 +247,6 @@ class SecureCompletionClientTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@Order(14)
|
|
||||||
@DisplayName("validateRsaKey should accept minimum size key (2048 bits)")
|
@DisplayName("validateRsaKey should accept minimum size key (2048 bits)")
|
||||||
void validateRsaKey_minimumSizeKey_shouldPass() throws Exception {
|
void validateRsaKey_minimumSizeKey_shouldPass() throws Exception {
|
||||||
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
|
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
|
||||||
|
|
@ -259,6 +254,7 @@ class SecureCompletionClientTest {
|
||||||
KeyPair pair = generator.generateKeyPair();
|
KeyPair pair = generator.generateKeyPair();
|
||||||
PrivateKey minKey = pair.getPrivate();
|
PrivateKey minKey = pair.getPrivate();
|
||||||
|
|
||||||
|
SecureCompletionClient client = new SecureCompletionClient();
|
||||||
assertDoesNotThrow(() -> client.validateRsaKey(minKey),
|
assertDoesNotThrow(() -> client.validateRsaKey(minKey),
|
||||||
"2048-bit key should pass validation (minimum)");
|
"2048-bit key should pass validation (minimum)");
|
||||||
}
|
}
|
||||||
|
|
@ -266,12 +262,13 @@ class SecureCompletionClientTest {
|
||||||
// ── Key Roundtrip Tests ───────────────────────────────────────────
|
// ── Key Roundtrip Tests ───────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@Order(15)
|
@Execution(ExecutionMode.SAME_THREAD)
|
||||||
@DisplayName("Full roundtrip: generate, save, load should produce same key")
|
@DisplayName("Full roundtrip: generate, save, load should produce same key")
|
||||||
void roundtrip_generateSaveLoad_shouldProduceSameKey(@TempDir Path tempDir) {
|
void roundtrip_generateSaveLoad_shouldProduceSameKey(@TempDir Path tempDir) {
|
||||||
File keyDir = tempDir.toFile();
|
File keyDir = tempDir.toFile();
|
||||||
|
|
||||||
// Generate and save
|
// Generate and save
|
||||||
|
SecureCompletionClient client = new SecureCompletionClient();
|
||||||
client.generateKeys(true, keyDir.getAbsolutePath(), TEST_PASSWORD);
|
client.generateKeys(true, keyDir.getAbsolutePath(), TEST_PASSWORD);
|
||||||
PrivateKey originalKey = client.getPrivateKey();
|
PrivateKey originalKey = client.getPrivateKey();
|
||||||
|
|
||||||
|
|
@ -291,7 +288,7 @@ class SecureCompletionClientTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@Order(16)
|
@Execution(ExecutionMode.SAME_THREAD)
|
||||||
@DisplayName("Multiple generate/load cycles should work correctly")
|
@DisplayName("Multiple generate/load cycles should work correctly")
|
||||||
void multipleCycles_shouldWorkCorrectly(@TempDir Path tempDir) throws Exception {
|
void multipleCycles_shouldWorkCorrectly(@TempDir Path tempDir) throws Exception {
|
||||||
File keyDir = tempDir.toFile();
|
File keyDir = tempDir.toFile();
|
||||||
|
|
@ -321,9 +318,9 @@ class SecureCompletionClientTest {
|
||||||
// ── Utility Method Tests ──────────────────────────────────────────
|
// ── Utility Method Tests ──────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@Order(17)
|
|
||||||
@DisplayName("urlEncodePublicKey should properly encode PEM keys")
|
@DisplayName("urlEncodePublicKey should properly encode PEM keys")
|
||||||
void urlEncodePublicKey_shouldEncodeCorrectly() {
|
void urlEncodePublicKey_shouldEncodeCorrectly() {
|
||||||
|
SecureCompletionClient client = new SecureCompletionClient();
|
||||||
String pemKey = "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A\n-----END PUBLIC KEY-----";
|
String pemKey = "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A\n-----END PUBLIC KEY-----";
|
||||||
|
|
||||||
String encoded = client.urlEncodePublicKey(pemKey);
|
String encoded = client.urlEncodePublicKey(pemKey);
|
||||||
|
|
@ -334,9 +331,9 @@ class SecureCompletionClientTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@Order(18)
|
|
||||||
@DisplayName("urlEncodePublicKey should handle empty string")
|
@DisplayName("urlEncodePublicKey should handle empty string")
|
||||||
void urlEncodePublicKey_emptyString_shouldReturnEmpty() {
|
void urlEncodePublicKey_emptyString_shouldReturnEmpty() {
|
||||||
|
SecureCompletionClient client = new SecureCompletionClient();
|
||||||
String encoded = client.urlEncodePublicKey("");
|
String encoded = client.urlEncodePublicKey("");
|
||||||
assertEquals("", encoded, "Empty string should encode to empty string");
|
assertEquals("", encoded, "Empty string should encode to empty string");
|
||||||
}
|
}
|
||||||
|
|
@ -344,7 +341,6 @@ class SecureCompletionClientTest {
|
||||||
// ── Pass2Key Encryption/Decryption Tests ──────────────────────────
|
// ── Pass2Key Encryption/Decryption Tests ──────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@Order(19)
|
|
||||||
@DisplayName("Pass2Key encrypt/decrypt should preserve plaintext")
|
@DisplayName("Pass2Key encrypt/decrypt should preserve plaintext")
|
||||||
void pass2Key_encryptDecrypt_shouldPreservePlaintext() throws Exception {
|
void pass2Key_encryptDecrypt_shouldPreservePlaintext() throws Exception {
|
||||||
String plaintext = "Test plaintext content for encryption";
|
String plaintext = "Test plaintext content for encryption";
|
||||||
|
|
@ -361,7 +357,6 @@ class SecureCompletionClientTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@Order(20)
|
|
||||||
@DisplayName("Pass2Key should produce different ciphertext for same plaintext")
|
@DisplayName("Pass2Key should produce different ciphertext for same plaintext")
|
||||||
void pass2Key_shouldProduceDifferentCiphertext() throws Exception {
|
void pass2Key_shouldProduceDifferentCiphertext() throws Exception {
|
||||||
String plaintext = "Same plaintext";
|
String plaintext = "Same plaintext";
|
||||||
|
|
@ -375,7 +370,6 @@ class SecureCompletionClientTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@Order(21)
|
|
||||||
@DisplayName("Pass2Key decrypt should fail with wrong password")
|
@DisplayName("Pass2Key decrypt should fail with wrong password")
|
||||||
void pass2Key_wrongPassword_shouldFail() throws Exception {
|
void pass2Key_wrongPassword_shouldFail() throws Exception {
|
||||||
String plaintext = "Secret content";
|
String plaintext = "Secret content";
|
||||||
|
|
@ -391,7 +385,6 @@ class SecureCompletionClientTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@Order(22)
|
|
||||||
@DisplayName("Pass2Key convertStringToPrivateKey should parse PEM correctly")
|
@DisplayName("Pass2Key convertStringToPrivateKey should parse PEM correctly")
|
||||||
void convertStringToPrivateKey_shouldParsePEM() throws Exception {
|
void convertStringToPrivateKey_shouldParsePEM() throws Exception {
|
||||||
SecureCompletionClient tempClient = new SecureCompletionClient();
|
SecureCompletionClient tempClient = new SecureCompletionClient();
|
||||||
|
|
@ -408,7 +401,6 @@ class SecureCompletionClientTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@Order(23)
|
|
||||||
@DisplayName("Pass2Key convertStringToPrivateKey should handle PEM with whitespace")
|
@DisplayName("Pass2Key convertStringToPrivateKey should handle PEM with whitespace")
|
||||||
void convertStringToPrivateKey_shouldHandleWhitespace() throws Exception {
|
void convertStringToPrivateKey_shouldHandleWhitespace() throws Exception {
|
||||||
SecureCompletionClient tempClient = new SecureCompletionClient();
|
SecureCompletionClient tempClient = new SecureCompletionClient();
|
||||||
|
|
|
||||||
166
src/test/java/ai/nomyo/util/PEMConverterTest.java
Normal file
166
src/test/java/ai/nomyo/util/PEMConverterTest.java
Normal file
|
|
@ -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)");
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue