2026-04-21 17:24:11 +02:00
|
|
|
package ai.nomyo;
|
|
|
|
|
|
|
|
|
|
import ai.nomyo.errors.*;
|
|
|
|
|
import ai.nomyo.util.PEMConverter;
|
|
|
|
|
import ai.nomyo.util.Pass2Key;
|
|
|
|
|
import lombok.Getter;
|
|
|
|
|
|
|
|
|
|
import javax.crypto.*;
|
2026-04-23 19:22:01 +02:00
|
|
|
import javax.crypto.spec.GCMParameterSpec;
|
2026-04-29 15:14:24 +02:00
|
|
|
import javax.crypto.spec.OAEPParameterSpec;
|
|
|
|
|
import javax.crypto.spec.PSource;
|
2026-04-23 19:22:01 +02:00
|
|
|
import javax.crypto.spec.SecretKeySpec;
|
2026-04-23 13:36:46 +02:00
|
|
|
import java.io.IOException;
|
2026-04-21 17:24:11 +02:00
|
|
|
import java.math.BigInteger;
|
2026-04-23 19:22:01 +02:00
|
|
|
import java.net.*;
|
|
|
|
|
import java.net.http.HttpClient;
|
|
|
|
|
import java.net.http.HttpRequest;
|
|
|
|
|
import java.net.http.HttpResponse;
|
2026-04-21 17:24:11 +02:00
|
|
|
import java.nio.charset.StandardCharsets;
|
|
|
|
|
import java.nio.file.Files;
|
|
|
|
|
import java.nio.file.Path;
|
|
|
|
|
import java.nio.file.attribute.PosixFilePermission;
|
|
|
|
|
import java.nio.file.attribute.PosixFilePermissions;
|
|
|
|
|
import java.security.*;
|
2026-04-23 19:22:01 +02:00
|
|
|
import java.security.spec.*;
|
|
|
|
|
import java.time.Duration;
|
|
|
|
|
import java.util.*;
|
2026-04-21 17:24:11 +02:00
|
|
|
import java.util.concurrent.CompletableFuture;
|
2026-04-23 19:22:01 +02:00
|
|
|
import java.util.concurrent.CompletionException;
|
|
|
|
|
import java.util.concurrent.ExecutionException;
|
2026-04-29 15:14:24 +02:00
|
|
|
|
2026-04-26 18:21:05 +02:00
|
|
|
import com.google.gson.Gson;
|
|
|
|
|
import com.google.gson.JsonObject;
|
|
|
|
|
import com.google.gson.JsonParser;
|
|
|
|
|
|
2026-04-21 17:24:11 +02:00
|
|
|
import java.util.concurrent.locks.ReentrantLock;
|
|
|
|
|
|
|
|
|
|
/**
|
2026-04-23 13:36:46 +02:00
|
|
|
* Low-level client: key management, hybrid encryption, HTTP with retry, response decryption. Used by {@link SecureChatCompletion}.
|
2026-04-21 17:24:11 +02:00
|
|
|
*/
|
|
|
|
|
public class SecureCompletionClient {
|
|
|
|
|
|
|
|
|
|
/**
|
2026-04-23 13:36:46 +02:00
|
|
|
* NOMYO router base URL (trailing slash stripped).
|
2026-04-21 17:24:11 +02:00
|
|
|
*/
|
|
|
|
|
@Getter
|
|
|
|
|
private final String routerUrl;
|
|
|
|
|
|
|
|
|
|
/**
|
2026-04-23 13:36:46 +02:00
|
|
|
* Permit HTTP (non-HTTPS) URLs.
|
2026-04-21 17:24:11 +02:00
|
|
|
*/
|
|
|
|
|
@Getter
|
|
|
|
|
private final boolean allowHttp;
|
|
|
|
|
|
|
|
|
|
/**
|
2026-04-23 13:36:46 +02:00
|
|
|
* RSA key size in bits ({@link Constants#RSA_KEY_SIZE}).
|
2026-04-21 17:24:11 +02:00
|
|
|
*/
|
|
|
|
|
@Getter
|
|
|
|
|
private final int keySize;
|
|
|
|
|
|
|
|
|
|
/**
|
2026-04-23 13:36:46 +02:00
|
|
|
* Max retries on retryable errors.
|
2026-04-21 17:24:11 +02:00
|
|
|
*/
|
|
|
|
|
@Getter
|
|
|
|
|
private final int maxRetries;
|
|
|
|
|
|
|
|
|
|
/**
|
2026-04-23 13:36:46 +02:00
|
|
|
* Secure memory operations active.
|
2026-04-21 17:24:11 +02:00
|
|
|
*/
|
|
|
|
|
@Getter
|
|
|
|
|
private final boolean useSecureMemory;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Lock for double-checked key initialization.
|
|
|
|
|
*/
|
|
|
|
|
private final ReentrantLock keyInitLock = new ReentrantLock();
|
2026-04-23 19:22:01 +02:00
|
|
|
private final HttpClient httpClient;
|
2026-04-21 17:24:11 +02:00
|
|
|
/**
|
2026-04-23 13:36:46 +02:00
|
|
|
* RSA private key ({@code null} until loaded/generated).
|
2026-04-21 17:24:11 +02:00
|
|
|
*/
|
|
|
|
|
@Getter
|
|
|
|
|
private PrivateKey privateKey;
|
|
|
|
|
/**
|
2026-04-23 13:36:46 +02:00
|
|
|
* PEM-encoded public key ({@code null} until loaded/generated).
|
2026-04-21 17:24:11 +02:00
|
|
|
*/
|
|
|
|
|
@Getter
|
|
|
|
|
private String publicPemKey;
|
|
|
|
|
/**
|
2026-04-23 13:36:46 +02:00
|
|
|
* Keys initialized.
|
2026-04-21 17:24:11 +02:00
|
|
|
*/
|
|
|
|
|
private volatile boolean keysInitialized = false;
|
|
|
|
|
|
|
|
|
|
/**
|
2026-04-23 13:36:46 +02:00
|
|
|
* Default settings: {@code https://api.nomyo.ai}, HTTPS-only, secure memory, 2 retries.
|
2026-04-21 17:24:11 +02:00
|
|
|
*/
|
|
|
|
|
public SecureCompletionClient() {
|
|
|
|
|
this(Constants.DEFAULT_BASE_URL, false, true, Constants.DEFAULT_MAX_RETRIES);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-04-23 13:36:46 +02:00
|
|
|
* @param routerUrl NOMYO router base URL
|
|
|
|
|
* @param allowHttp permit HTTP URLs
|
|
|
|
|
* @param secureMemory enable memory locking/zeroing
|
|
|
|
|
* @param maxRetries retries on retryable errors
|
2026-04-21 17:24:11 +02:00
|
|
|
*/
|
|
|
|
|
public SecureCompletionClient(String routerUrl, boolean allowHttp, boolean secureMemory, int maxRetries) {
|
|
|
|
|
this.routerUrl = routerUrl != null ? routerUrl.replaceAll("/+$", "") : Constants.DEFAULT_BASE_URL;
|
|
|
|
|
this.allowHttp = allowHttp;
|
|
|
|
|
this.useSecureMemory = secureMemory;
|
|
|
|
|
this.keySize = Constants.RSA_KEY_SIZE;
|
|
|
|
|
this.maxRetries = maxRetries;
|
2026-04-29 15:14:24 +02:00
|
|
|
this.httpClient = HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1).build();
|
2026-04-21 17:24:11 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-23 13:36:46 +02:00
|
|
|
private static String readFileContent(String filePath) throws IOException {
|
|
|
|
|
return Files.readString(Path.of(filePath));
|
2026-04-21 17:24:11 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-29 15:14:24 +02:00
|
|
|
private static Map<String, Object> parseErrorBody(String body) {
|
|
|
|
|
try {
|
|
|
|
|
@SuppressWarnings("unchecked") Map<String, Object> parsed = (Map<String, Object>) new Gson().fromJson(body, Object.class);
|
|
|
|
|
return parsed != null ? parsed : Map.of();
|
|
|
|
|
} catch (Exception e) {
|
|
|
|
|
return Map.of();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-21 17:24:11 +02:00
|
|
|
/**
|
2026-04-23 13:36:46 +02:00
|
|
|
* Generates a 4096-bit RSA key pair (exponent 65537). Saves to disk if {@code saveToFile}.
|
2026-04-21 17:24:11 +02:00
|
|
|
*/
|
2026-04-26 18:21:05 +02:00
|
|
|
public void generateKeys(boolean saveToFile, String keyDir, String password) throws SecurityError {
|
2026-04-21 17:24:11 +02:00
|
|
|
try {
|
|
|
|
|
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
|
|
|
|
|
generator.initialize(new RSAKeyGenParameterSpec(Constants.RSA_KEY_SIZE, BigInteger.valueOf(Constants.RSA_PUBLIC_EXPONENT)));
|
|
|
|
|
|
|
|
|
|
KeyPair pair = generator.generateKeyPair();
|
|
|
|
|
|
|
|
|
|
String privatePem = PEMConverter.toPEM(pair.getPrivate().getEncoded(), true);
|
|
|
|
|
String publicPem = PEMConverter.toPEM(pair.getPublic().getEncoded(), false);
|
|
|
|
|
|
2026-04-23 19:22:01 +02:00
|
|
|
if (saveToFile) {
|
2026-04-23 13:36:46 +02:00
|
|
|
Path keyFolder = Path.of(keyDir);
|
|
|
|
|
if (!Files.exists(keyFolder)) {
|
|
|
|
|
try {
|
|
|
|
|
Files.createDirectories(keyFolder);
|
|
|
|
|
} catch (IOException e) {
|
|
|
|
|
throw new IOException("Failed to create key directory: " + keyDir, e);
|
|
|
|
|
}
|
2026-04-21 17:24:11 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Path privateKeyPath = Path.of(keyDir, Constants.DEFAULT_PRIVATE_KEY_FILE);
|
|
|
|
|
if (!Files.exists(privateKeyPath)) {
|
|
|
|
|
Set<PosixFilePermission> filePermissions = PosixFilePermissions.fromString(Constants.PRIVATE_KEY_FILE_MODE);
|
|
|
|
|
Files.createFile(privateKeyPath, PosixFilePermissions.asFileAttribute(filePermissions));
|
|
|
|
|
|
2026-04-23 13:36:46 +02:00
|
|
|
try (var writer = Files.newBufferedWriter(privateKeyPath)) {
|
2026-04-21 17:24:11 +02:00
|
|
|
if (password == null || password.isEmpty()) {
|
|
|
|
|
System.out.println("WARNING: Saving keys in plaintext!");
|
|
|
|
|
} else {
|
|
|
|
|
try {
|
|
|
|
|
privatePem = Pass2Key.encrypt("AES/GCM/NoPadding", privatePem, password);
|
|
|
|
|
} catch (NoSuchPaddingException | IllegalBlockSizeException | BadPaddingException |
|
2026-04-26 18:21:05 +02:00
|
|
|
InvalidKeyException | SecurityError e) {
|
|
|
|
|
throw new SecurityError("Failed to encrypt private key with password: " + e.getMessage(), e);
|
2026-04-21 17:24:11 +02:00
|
|
|
}
|
|
|
|
|
}
|
2026-04-23 13:36:46 +02:00
|
|
|
writer.write(privatePem);
|
2026-04-21 17:24:11 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Path publicKeyPath = Path.of(keyDir, Constants.DEFAULT_PUBLIC_KEY_FILE);
|
|
|
|
|
if (!Files.exists(publicKeyPath)) {
|
|
|
|
|
Set<PosixFilePermission> publicPermissions = PosixFilePermissions.fromString(Constants.PUBLIC_KEY_FILE_MODE);
|
|
|
|
|
Files.createFile(publicKeyPath, PosixFilePermissions.asFileAttribute(publicPermissions));
|
|
|
|
|
|
2026-04-23 13:36:46 +02:00
|
|
|
try (var writer = Files.newBufferedWriter(publicKeyPath)) {
|
|
|
|
|
writer.write(publicPem);
|
2026-04-21 17:24:11 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.privateKey = pair.getPrivate();
|
|
|
|
|
this.publicPemKey = publicPem;
|
|
|
|
|
|
|
|
|
|
} catch (NoSuchAlgorithmException e) {
|
2026-04-26 18:21:05 +02:00
|
|
|
throw new SecurityError("RSA algorithm not available: " + e.getMessage(), e);
|
2026-04-21 17:24:11 +02:00
|
|
|
} catch (InvalidAlgorithmParameterException e) {
|
2026-04-26 18:21:05 +02:00
|
|
|
throw new SecurityError("Invalid RSA key generation parameters: " + e.getMessage(), e);
|
2026-04-21 17:24:11 +02:00
|
|
|
} catch (IOException e) {
|
2026-04-29 15:14:24 +02:00
|
|
|
throw new SecurityError("Failed to save keys: " + e.getMessage(), e);
|
2026-04-21 17:24:11 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-04-23 13:36:46 +02:00
|
|
|
* Generates a 4096-bit RSA key pair and saves to the default directory.
|
2026-04-21 17:24:11 +02:00
|
|
|
*/
|
2026-04-26 18:21:05 +02:00
|
|
|
public void generateKeys(boolean saveToFile) throws SecurityError {
|
2026-04-21 17:24:11 +02:00
|
|
|
generateKeys(saveToFile, Constants.DEFAULT_KEY_DIR, null);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-04-23 13:36:46 +02:00
|
|
|
* Loads RSA private key from disk. If {@code publicPemKeyPath} is {@code null}, derives public key.
|
|
|
|
|
* Validates key size >= {@link Constants#MIN_RSA_KEY_SIZE} bits.
|
2026-04-21 17:24:11 +02:00
|
|
|
*
|
2026-04-23 13:36:46 +02:00
|
|
|
* @param privateKeyPath private key PEM path
|
|
|
|
|
* @param publicPemKeyPath optional public key PEM path
|
|
|
|
|
* @param password optional password for encrypted private key
|
2026-04-26 18:21:05 +02:00
|
|
|
* @throws SecurityError if key file not found, unreadable, or decryption fails
|
2026-04-21 17:24:11 +02:00
|
|
|
*/
|
2026-04-26 18:21:05 +02:00
|
|
|
public void loadKeys(String privateKeyPath, String publicPemKeyPath, String password) throws SecurityError {
|
2026-04-23 13:36:46 +02:00
|
|
|
Path keyPath = Path.of(privateKeyPath);
|
|
|
|
|
if (!Files.exists(keyPath)) {
|
2026-04-26 18:21:05 +02:00
|
|
|
throw new SecurityError("Private key file not found: " + privateKeyPath);
|
2026-04-21 18:00:31 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
String keyContent;
|
2026-04-21 17:24:11 +02:00
|
|
|
if (password != null && !password.isEmpty()) {
|
2026-04-23 13:36:46 +02:00
|
|
|
try {
|
|
|
|
|
keyContent = readFileContent(privateKeyPath);
|
|
|
|
|
} catch (IOException e) {
|
2026-04-26 18:21:05 +02:00
|
|
|
throw new SecurityError("Failed to read private key file: " + e.getMessage(), e);
|
2026-04-23 13:36:46 +02:00
|
|
|
}
|
2026-04-21 17:24:11 +02:00
|
|
|
|
|
|
|
|
try {
|
2026-04-21 18:00:31 +02:00
|
|
|
keyContent = Pass2Key.decrypt("AES/GCM/NoPadding", keyContent, password);
|
2026-04-23 13:36:46 +02:00
|
|
|
} catch (NoSuchPaddingException | NoSuchAlgorithmException | BadPaddingException |
|
2026-04-29 15:14:24 +02:00
|
|
|
IllegalBlockSizeException | InvalidAlgorithmParameterException | InvalidKeyException |
|
|
|
|
|
SecurityError e) {
|
2026-04-26 18:21:05 +02:00
|
|
|
throw new SecurityError("Failed to decrypt private key with provided password: " + e.getMessage(), e);
|
2026-04-21 17:24:11 +02:00
|
|
|
}
|
2026-04-21 18:00:31 +02:00
|
|
|
} else {
|
2026-04-23 13:36:46 +02:00
|
|
|
try {
|
|
|
|
|
keyContent = readFileContent(privateKeyPath);
|
|
|
|
|
} catch (IOException e) {
|
2026-04-26 18:21:05 +02:00
|
|
|
throw new SecurityError("Failed to read private key file: " + e.getMessage(), e);
|
2026-04-23 13:36:46 +02:00
|
|
|
}
|
2026-04-21 18:00:31 +02:00
|
|
|
}
|
2026-04-21 17:24:11 +02:00
|
|
|
|
2026-04-21 18:00:31 +02:00
|
|
|
try {
|
|
|
|
|
this.privateKey = Pass2Key.convertStringToPrivateKey(keyContent);
|
|
|
|
|
} catch (Exception e) {
|
2026-04-26 18:21:05 +02:00
|
|
|
throw new SecurityError("Failed to load private key: " + e.getMessage(), e);
|
2026-04-21 17:24:11 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-04-23 13:36:46 +02:00
|
|
|
* Loads RSA private key from disk, deriving public key.
|
2026-04-26 18:21:05 +02:00
|
|
|
*
|
|
|
|
|
* @throws SecurityError if key file not found, unreadable, or decryption fails
|
2026-04-21 17:24:11 +02:00
|
|
|
*/
|
2026-04-26 18:21:05 +02:00
|
|
|
public void loadKeys(String privateKeyPath, String password) throws SecurityError {
|
2026-04-21 17:24:11 +02:00
|
|
|
loadKeys(privateKeyPath, null, password);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-04-23 13:36:46 +02:00
|
|
|
* GET {@code {routerUrl}/pki/public_key}. Returns server PEM public key.
|
2026-04-23 19:22:01 +02:00
|
|
|
* Errors are propagated as {@link CompletionException} wrappers around the underlying checked exception.
|
2026-04-21 17:24:11 +02:00
|
|
|
*/
|
|
|
|
|
public CompletableFuture<String> fetchServerPublicKey() {
|
2026-04-23 19:22:01 +02:00
|
|
|
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 {
|
2026-04-26 18:21:05 +02:00
|
|
|
url = new URI(this.routerUrl + Constants.PKI_PUBLIC_KEY_PATH);
|
2026-04-23 19:22:01 +02:00
|
|
|
} catch (URISyntaxException e) {
|
2026-04-26 18:21:05 +02:00
|
|
|
return CompletableFuture.failedFuture(new CompletionException(new APIConnectionError("Invalid URI: " + e.getMessage(), e)));
|
2026-04-23 19:22:01 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-26 18:21:05 +02:00
|
|
|
HttpRequest request = HttpRequest.newBuilder(url).timeout(Duration.ofSeconds(Constants.DEFAULT_TIMEOUT_SECONDS)).GET().build();
|
2026-04-23 19:22:01 +02:00
|
|
|
|
|
|
|
|
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)) {
|
2026-04-26 18:21:05 +02:00
|
|
|
throw new CompletionException(new SecurityError("Server returned invalid PEM key format (possible MITM attack)"));
|
2026-04-23 19:22:01 +02:00
|
|
|
}
|
|
|
|
|
return body;
|
|
|
|
|
});
|
2026-04-21 17:24:11 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-04-23 13:36:46 +02:00
|
|
|
* Hybrid encryption: AES-256-GCM for payload, RSA-OAEP-SHA256 for AES key wrapping.
|
2026-04-21 17:24:11 +02:00
|
|
|
*
|
2026-04-23 13:36:46 +02:00
|
|
|
* @param payload OpenAI-compatible chat parameters
|
|
|
|
|
* @return encrypted bytes (JSON package)
|
|
|
|
|
* @throws SecurityError if encryption fails or keys not loaded
|
2026-04-21 17:24:11 +02:00
|
|
|
*/
|
|
|
|
|
public CompletableFuture<byte[]> encryptPayload(Map<String, Object> payload) {
|
2026-04-26 18:21:05 +02:00
|
|
|
return CompletableFuture.supplyAsync(() -> {
|
|
|
|
|
try {
|
|
|
|
|
ensureKeys(null);
|
|
|
|
|
} catch (SecurityError e) {
|
|
|
|
|
throw new CompletionException(new SecurityError("Failed to ensure keys are initialized: " + e.getMessage(), e));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (this.privateKey == null) {
|
|
|
|
|
throw new CompletionException(new SecurityError("Private key not available for encryption"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Generate AES key
|
|
|
|
|
KeyGenerator keyGen;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
keyGen = KeyGenerator.getInstance("AES");
|
|
|
|
|
keyGen.init(Constants.AES_KEY_SIZE * 8);
|
|
|
|
|
} catch (NoSuchAlgorithmException e) {
|
|
|
|
|
throw new CompletionException(new SecurityError("AES key generation not available: " + e.getMessage(), e));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Key aesKey = keyGen.generateKey();
|
|
|
|
|
|
|
|
|
|
// Serialize payload to JSON
|
|
|
|
|
Gson gson = new Gson();
|
|
|
|
|
String payloadJson = gson.toJson(payload);
|
|
|
|
|
byte[] payloadBytes = payloadJson.getBytes(StandardCharsets.UTF_8);
|
|
|
|
|
|
|
|
|
|
// Encrypt
|
|
|
|
|
return doEncrypt(payloadBytes, aesKey).join();
|
|
|
|
|
});
|
2026-04-21 17:24:11 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-04-23 13:36:46 +02:00
|
|
|
* Core hybrid encryption: AES-256-GCM encrypts {@code payloadBytes} with {@code aesKey}.
|
2026-04-21 17:24:11 +02:00
|
|
|
*/
|
2026-04-23 19:22:01 +02:00
|
|
|
public CompletableFuture<byte[]> doEncrypt(byte[] payloadBytes, Key aesKey) {
|
|
|
|
|
return CompletableFuture.supplyAsync(() -> {
|
|
|
|
|
SecureRandom random = new SecureRandom();
|
2026-04-26 18:21:05 +02:00
|
|
|
byte[] nonce = new byte[Constants.GCM_NONCE_SIZE];
|
2026-04-23 19:22:01 +02:00
|
|
|
random.nextBytes(nonce);
|
|
|
|
|
|
2026-04-26 19:06:38 +02:00
|
|
|
Cipher cipher;
|
2026-04-23 19:22:01 +02:00
|
|
|
try {
|
|
|
|
|
cipher = Cipher.getInstance("AES/GCM/NoPadding");
|
2026-04-26 18:21:05 +02:00
|
|
|
cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(aesKey.getEncoded(), "AES"), new GCMParameterSpec(Constants.GCM_TAG_SIZE * Byte.SIZE, nonce));
|
2026-04-23 19:22:01 +02:00
|
|
|
} catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidAlgorithmParameterException |
|
|
|
|
|
InvalidKeyException e) {
|
2026-04-26 18:21:05 +02:00
|
|
|
throw new RuntimeException(new SecurityError("AES-GCM cipher initialization failed: " + e.getMessage(), e));
|
2026-04-23 19:22:01 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
byte[] ciphertext;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
ciphertext = cipher.doFinal(payloadBytes);
|
|
|
|
|
} catch (IllegalBlockSizeException | BadPaddingException e) {
|
2026-04-26 18:21:05 +02:00
|
|
|
throw new RuntimeException(new SecurityError("AES-GCM encryption failed: " + e.getMessage(), e));
|
2026-04-23 19:22:01 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
String serverPEM;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
serverPEM = fetchServerPublicKey().get();
|
2026-04-26 18:21:05 +02:00
|
|
|
} catch (InterruptedException e) {
|
|
|
|
|
Thread.currentThread().interrupt();
|
|
|
|
|
throw new RuntimeException(new SecurityError("Encryption interrupted while fetching server public key", e));
|
|
|
|
|
} catch (ExecutionException e) {
|
|
|
|
|
Throwable cause = e.getCause();
|
|
|
|
|
if (cause instanceof SecurityError) {
|
2026-04-26 19:06:38 +02:00
|
|
|
throw new RuntimeException(cause);
|
2026-04-26 18:21:05 +02:00
|
|
|
}
|
|
|
|
|
throw new RuntimeException(new SecurityError("Failed to fetch server public key: " + cause.getMessage(), cause));
|
2026-04-23 19:22:01 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-26 18:21:05 +02:00
|
|
|
X509EncodedKeySpec keySpec = new X509EncodedKeySpec(PEMConverter.fromPEM(serverPEM));
|
2026-04-23 19:22:01 +02:00
|
|
|
|
|
|
|
|
PublicKey serverPublicKey;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
serverPublicKey = KeyFactory.getInstance("RSA").generatePublic(keySpec);
|
|
|
|
|
} catch (InvalidKeySpecException | NoSuchAlgorithmException e) {
|
2026-04-26 18:21:05 +02:00
|
|
|
throw new RuntimeException(new SecurityError("RSA key factory failed to parse server public key: " + e.getMessage(), e));
|
2026-04-23 19:22:01 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Cipher rsa;
|
|
|
|
|
try {
|
2026-04-29 15:14:24 +02:00
|
|
|
OAEPParameterSpec oaepParams = new OAEPParameterSpec("SHA-256", "MGF1", new MGF1ParameterSpec("SHA-256"), // Must match server: SHA-256, NOT SHA-1
|
|
|
|
|
PSource.PSpecified.DEFAULT);
|
|
|
|
|
|
2026-04-23 19:22:01 +02:00
|
|
|
rsa = Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding");
|
2026-04-29 15:14:24 +02:00
|
|
|
rsa.init(Cipher.ENCRYPT_MODE, serverPublicKey, oaepParams);
|
|
|
|
|
} catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException |
|
|
|
|
|
InvalidAlgorithmParameterException e) {
|
2026-04-26 18:21:05 +02:00
|
|
|
throw new RuntimeException(new SecurityError("RSA-OAEP cipher initialization failed: " + e.getMessage(), e));
|
2026-04-23 19:22:01 +02:00
|
|
|
}
|
2026-04-29 15:14:24 +02:00
|
|
|
|
2026-04-26 18:21:05 +02:00
|
|
|
byte[] encryptedAESKey;
|
2026-04-29 15:14:24 +02:00
|
|
|
|
2026-04-23 19:22:01 +02:00
|
|
|
try {
|
2026-04-26 18:21:05 +02:00
|
|
|
encryptedAESKey = rsa.doFinal(aesKey.getEncoded());
|
2026-04-23 19:22:01 +02:00
|
|
|
} catch (IllegalBlockSizeException | BadPaddingException e) {
|
2026-04-26 18:21:05 +02:00
|
|
|
throw new RuntimeException(new SecurityError("RSA-OAEP key wrapping failed: " + e.getMessage(), e));
|
2026-04-23 19:22:01 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-26 18:21:05 +02:00
|
|
|
byte[] tag = Arrays.copyOfRange(ciphertext, ciphertext.length - Constants.GCM_TAG_SIZE, ciphertext.length);
|
2026-04-23 19:22:01 +02:00
|
|
|
|
2026-04-29 15:14:24 +02:00
|
|
|
byte[] actualCiphertext = Arrays.copyOf(ciphertext, ciphertext.length - Constants.GCM_TAG_SIZE);
|
|
|
|
|
|
2026-04-23 19:22:01 +02:00
|
|
|
EncryptedRequest request = new EncryptedRequest();
|
|
|
|
|
|
2026-04-26 18:21:05 +02:00
|
|
|
request.setVersion(Constants.PROTOCOL_VERSION);
|
|
|
|
|
request.setAlgorithm(Constants.HYBRID_ALGORITHM);
|
2026-04-29 15:14:24 +02:00
|
|
|
request.setEncryptedPayload(new EncryptedRequest.EncryptedPayload(Base64.getEncoder().encodeToString(actualCiphertext), Base64.getEncoder().encodeToString(nonce), Base64.getEncoder().encodeToString(tag)));
|
2026-04-26 18:21:05 +02:00
|
|
|
request.setEncryptedAESKey(Base64.getEncoder().encodeToString(encryptedAESKey));
|
|
|
|
|
request.setKeyAlgorithm(Constants.KEY_WRAP_ALGORITHM);
|
|
|
|
|
request.setPayloadAlgorithm(Constants.PAYLOAD_ALGORITHM);
|
2026-04-23 19:22:01 +02:00
|
|
|
|
|
|
|
|
return request.toJson().getBytes(StandardCharsets.UTF_8);
|
|
|
|
|
});
|
2026-04-21 17:24:11 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-04-23 13:36:46 +02:00
|
|
|
* encrypt → POST {routerUrl}/v1/chat/secure_completion → retry → decrypt → return.
|
|
|
|
|
* <p>Headers: Content-Type=octet-stream, X-Payload-ID, X-Public-Key, Authorization (Bearer), X-Security-Tier.
|
|
|
|
|
* Retryable: 429, 500, 502, 503, 504 + network errors. Backoff: 2^(attempt-1)s.
|
|
|
|
|
* <p>Status mapping: 200→return, 400→InvalidRequestError, 401→AuthenticationError, 403→ForbiddenError,
|
|
|
|
|
* 404→APIError, 429→RateLimitError, 500→ServerError, 503→ServiceUnavailableError,
|
|
|
|
|
* 502/504→APIError(retryable), network→APIConnectionError.
|
2026-04-21 17:24:11 +02:00
|
|
|
*
|
2026-04-23 13:36:46 +02:00
|
|
|
* @param payload OpenAI-compatible chat parameters
|
2026-04-21 17:24:11 +02:00
|
|
|
* @param payloadId unique payload identifier
|
2026-04-23 13:36:46 +02:00
|
|
|
* @param apiKey optional API key
|
|
|
|
|
* @param securityTier optional: "standard", "high", "maximum"
|
|
|
|
|
* @return decrypted response map
|
|
|
|
|
* @throws SecurityError encryption/decryption failure
|
|
|
|
|
* @throws APIConnectionError network error
|
|
|
|
|
* @throws InvalidRequestError HTTP 400
|
|
|
|
|
* @throws AuthenticationError HTTP 401
|
|
|
|
|
* @throws ForbiddenError HTTP 403
|
|
|
|
|
* @throws RateLimitError HTTP 429
|
|
|
|
|
* @throws ServerError HTTP 500
|
|
|
|
|
* @throws ServiceUnavailableError HTTP 503
|
|
|
|
|
* @throws APIError other errors
|
2026-04-21 17:24:11 +02:00
|
|
|
*/
|
2026-04-26 19:06:38 +02:00
|
|
|
@SuppressWarnings("JavadocDeclaration")
|
2026-04-23 13:36:46 +02:00
|
|
|
public CompletableFuture<Map<String, Object>> sendSecureRequest(Map<String, Object> payload, String payloadId, String apiKey, String securityTier) {
|
2026-04-26 18:21:05 +02:00
|
|
|
return CompletableFuture.supplyAsync(() -> {
|
|
|
|
|
// Validate security tier if provided
|
|
|
|
|
if (securityTier != null && !Constants.VALID_SECURITY_TIERS.contains(securityTier)) {
|
2026-04-29 15:14:24 +02:00
|
|
|
throw new CompletionException(new ValueError("Invalid security_tier: '" + securityTier + "'. Must be one of: " + Constants.VALID_SECURITY_TIERS));
|
2026-04-26 18:21:05 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
ensureKeys(null);
|
|
|
|
|
} catch (SecurityError e) {
|
|
|
|
|
throw new CompletionException(new SecurityError("Failed to ensure keys: " + e.getMessage(), e));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Step 1: Encrypt payload
|
|
|
|
|
byte[] encryptedPayload;
|
|
|
|
|
try {
|
|
|
|
|
encryptedPayload = encryptPayload(payload).get();
|
|
|
|
|
} catch (InterruptedException e) {
|
|
|
|
|
Thread.currentThread().interrupt();
|
|
|
|
|
throw new CompletionException(new APIConnectionError("Encryption interrupted"));
|
|
|
|
|
} catch (ExecutionException e) {
|
|
|
|
|
Throwable cause = e.getCause();
|
|
|
|
|
if (cause instanceof SecurityError) {
|
2026-04-26 19:06:38 +02:00
|
|
|
throw new CompletionException(cause);
|
2026-04-26 18:21:05 +02:00
|
|
|
}
|
|
|
|
|
throw new CompletionException(new SecurityError("Encryption failed: " + cause.getMessage(), cause));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Step 2: Prepare headers
|
|
|
|
|
URI url;
|
|
|
|
|
try {
|
|
|
|
|
url = new URI(this.routerUrl + Constants.SECURE_COMPLETION_PATH);
|
|
|
|
|
} catch (URISyntaxException e) {
|
|
|
|
|
throw new CompletionException(new APIConnectionError("Invalid URL: " + e.getMessage(), e));
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 15:14:24 +02:00
|
|
|
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(url).timeout(Duration.ofSeconds(Constants.DEFAULT_TIMEOUT_SECONDS)).header("Content-Type", Constants.CONTENT_TYPE_OCTET_STREAM).header(Constants.HEADER_PAYLOAD_ID, payloadId).header(Constants.HEADER_PUBLIC_KEY, urlEncodePublicKey(this.publicPemKey)).POST(HttpRequest.BodyPublishers.ofByteArray(encryptedPayload));
|
2026-04-26 18:21:05 +02:00
|
|
|
|
|
|
|
|
if (apiKey != null && !apiKey.isEmpty()) {
|
|
|
|
|
requestBuilder.header("Authorization", Constants.AUTHORIZATION_BEARER_PREFIX + apiKey);
|
|
|
|
|
}
|
|
|
|
|
if (securityTier != null) {
|
|
|
|
|
requestBuilder.header(Constants.HEADER_SECURITY_TIER, securityTier);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
HttpRequest request = requestBuilder.build();
|
|
|
|
|
|
|
|
|
|
// Step 3: Send request with retry
|
|
|
|
|
Exception lastExc = new APIConnectionError("Request failed");
|
|
|
|
|
int retryableCodes = 0;
|
|
|
|
|
for (int attempt = 0; attempt <= this.maxRetries; attempt++) {
|
|
|
|
|
if (attempt > 0) {
|
|
|
|
|
long delay = (long) Math.pow(2, attempt - 1);
|
|
|
|
|
try {
|
|
|
|
|
Thread.sleep(delay * 1000);
|
|
|
|
|
} catch (InterruptedException e) {
|
|
|
|
|
Thread.currentThread().interrupt();
|
|
|
|
|
throw new CompletionException(new APIConnectionError("Retry interrupted"));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
HttpResponse<byte[]> response = this.httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray());
|
|
|
|
|
int statusCode = response.statusCode();
|
|
|
|
|
|
|
|
|
|
if (statusCode == 200) {
|
|
|
|
|
// Step 4: Decrypt response
|
|
|
|
|
return decryptResponse(response.body(), payloadId).get();
|
|
|
|
|
} else if (statusCode == 400) {
|
|
|
|
|
String body = new String(response.body(), StandardCharsets.UTF_8);
|
2026-04-29 15:14:24 +02:00
|
|
|
Map<String, Object> errorDetails = parseErrorBody(body);
|
2026-04-26 18:21:05 +02:00
|
|
|
String detail = errorDetails.containsKey("detail") ? errorDetails.get("detail").toString() : "Unknown error";
|
|
|
|
|
throw new CompletionException(new InvalidRequestError("Bad request: " + detail, 400, errorDetails));
|
|
|
|
|
} else if (statusCode == 401) {
|
|
|
|
|
String body = new String(response.body(), StandardCharsets.UTF_8);
|
2026-04-29 15:14:24 +02:00
|
|
|
Map<String, Object> errorDetails = parseErrorBody(body);
|
2026-04-26 18:21:05 +02:00
|
|
|
String detail = errorDetails.containsKey("detail") ? errorDetails.get("detail").toString() : "Invalid API key or authentication failed";
|
|
|
|
|
throw new CompletionException(new AuthenticationError(detail, 401, errorDetails));
|
|
|
|
|
} else if (statusCode == 403) {
|
|
|
|
|
String body = new String(response.body(), StandardCharsets.UTF_8);
|
2026-04-29 15:14:24 +02:00
|
|
|
Map<String, Object> errorDetails = parseErrorBody(body);
|
2026-04-26 18:21:05 +02:00
|
|
|
String detail = errorDetails.containsKey("detail") ? errorDetails.get("detail").toString() : "Model not allowed for the requested security tier";
|
|
|
|
|
throw new CompletionException(new ForbiddenError("Forbidden: " + detail, 403, errorDetails));
|
|
|
|
|
} else if (statusCode == 404) {
|
|
|
|
|
String body = new String(response.body(), StandardCharsets.UTF_8);
|
2026-04-29 15:14:24 +02:00
|
|
|
Map<String, Object> errorDetails = parseErrorBody(body);
|
2026-04-26 18:21:05 +02:00
|
|
|
String detail = errorDetails.containsKey("detail") ? errorDetails.get("detail").toString() : "Secure inference not enabled";
|
|
|
|
|
throw new CompletionException(new APIError("Endpoint not found: " + detail, 404, errorDetails));
|
|
|
|
|
} else if (Constants.RETRYABLE_STATUS_CODES.contains(statusCode)) {
|
|
|
|
|
String body = new String(response.body(), StandardCharsets.UTF_8);
|
2026-04-29 15:14:24 +02:00
|
|
|
Map<String, Object> error = parseErrorBody(body);
|
|
|
|
|
String detailMsg = error.containsKey("detail") ? error.get("detail").toString() : "unknown";
|
2026-04-26 18:21:05 +02:00
|
|
|
|
|
|
|
|
if (statusCode == 429) {
|
|
|
|
|
lastExc = new RateLimitError("Rate limit exceeded: " + detailMsg, 429, error);
|
|
|
|
|
} else if (statusCode == 500) {
|
|
|
|
|
lastExc = new ServerError("Server error: " + detailMsg, 500, error);
|
|
|
|
|
} else if (statusCode == 503) {
|
|
|
|
|
lastExc = new ServiceUnavailableError("Service unavailable: " + detailMsg, 503, error);
|
|
|
|
|
} else {
|
|
|
|
|
lastExc = new APIError("Unexpected status code: " + statusCode + " " + detailMsg, statusCode, error);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (attempt < this.maxRetries) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
throw new CompletionException(lastExc);
|
|
|
|
|
} else {
|
|
|
|
|
String body = new String(response.body(), StandardCharsets.UTF_8);
|
2026-04-29 15:14:24 +02:00
|
|
|
Map<String, Object> errorDetails = parseErrorBody(body);
|
|
|
|
|
String detailMsg = errorDetails.containsKey("detail") ? errorDetails.get("detail").toString() : "unknown";
|
2026-04-26 18:21:05 +02:00
|
|
|
throw new CompletionException(new APIError("Unexpected status code: " + statusCode + " " + detailMsg, statusCode, errorDetails));
|
|
|
|
|
}
|
|
|
|
|
} catch (CompletionException e) {
|
|
|
|
|
Throwable cause = e.getCause();
|
2026-04-29 15:14:24 +02:00
|
|
|
if (cause instanceof InvalidRequestError || cause instanceof AuthenticationError || cause instanceof ForbiddenError || cause instanceof SecurityError) {
|
2026-04-26 18:21:05 +02:00
|
|
|
throw e;
|
|
|
|
|
}
|
|
|
|
|
if (cause instanceof RateLimitError || cause instanceof ServerError || cause instanceof ServiceUnavailableError) {
|
|
|
|
|
lastExc = (Exception) cause;
|
|
|
|
|
if (attempt < this.maxRetries) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
throw new CompletionException(lastExc);
|
|
|
|
|
}
|
|
|
|
|
if (cause instanceof APIError apiError) {
|
|
|
|
|
if (Constants.RETRYABLE_STATUS_CODES.contains(apiError.getStatusCode())) {
|
|
|
|
|
lastExc = apiError;
|
|
|
|
|
if (attempt < this.maxRetries) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
throw new CompletionException(lastExc);
|
|
|
|
|
}
|
|
|
|
|
throw e;
|
|
|
|
|
}
|
|
|
|
|
lastExc = new APIConnectionError("Failed to connect to router: " + e.getMessage());
|
|
|
|
|
if (attempt < this.maxRetries) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
throw new CompletionException(lastExc);
|
|
|
|
|
} catch (InterruptedException e) {
|
|
|
|
|
Thread.currentThread().interrupt();
|
|
|
|
|
throw new CompletionException(new APIConnectionError("Request interrupted"));
|
|
|
|
|
} catch (ExecutionException e) {
|
|
|
|
|
throw new CompletionException(new APIConnectionError("Request failed: " + e.getMessage(), e));
|
|
|
|
|
} catch (IOException e) {
|
|
|
|
|
lastExc = new APIConnectionError("IO error: " + e.getMessage(), e);
|
|
|
|
|
if (attempt < this.maxRetries) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
throw new CompletionException(lastExc);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
throw new CompletionException(lastExc);
|
|
|
|
|
});
|
2026-04-21 17:24:11 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-04-23 13:36:46 +02:00
|
|
|
* Without security tier.
|
2026-04-21 17:24:11 +02:00
|
|
|
*/
|
2026-04-23 13:36:46 +02:00
|
|
|
public CompletableFuture<Map<String, Object>> sendSecureRequest(Map<String, Object> payload, String payloadId, String apiKey) {
|
2026-04-21 17:24:11 +02:00
|
|
|
return sendSecureRequest(payload, payloadId, apiKey, null);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-04-23 13:36:46 +02:00
|
|
|
* No API key or security tier.
|
2026-04-21 17:24:11 +02:00
|
|
|
*/
|
2026-04-23 13:36:46 +02:00
|
|
|
public CompletableFuture<Map<String, Object>> sendSecureRequest(Map<String, Object> payload, String payloadId) {
|
2026-04-21 17:24:11 +02:00
|
|
|
return sendSecureRequest(payload, payloadId, null, null);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-04-23 13:36:46 +02:00
|
|
|
* Thread-safe key init via double-checked locking. Loads from disk if {@code keyDir} set, else generates.
|
2026-04-21 17:24:11 +02:00
|
|
|
*
|
2026-04-23 13:36:46 +02:00
|
|
|
* @param keyDir key directory or {@code null} for ephemeral
|
2026-04-21 17:24:11 +02:00
|
|
|
*/
|
2026-04-26 18:21:05 +02:00
|
|
|
public void ensureKeys(String keyDir) throws SecurityError {
|
2026-04-21 17:24:11 +02:00
|
|
|
if (keysInitialized) return;
|
|
|
|
|
keyInitLock.lock();
|
|
|
|
|
try {
|
|
|
|
|
if (keysInitialized) return;
|
2026-04-23 19:22:01 +02:00
|
|
|
generateKeys(keyDir != null && !keyDir.isEmpty());
|
2026-04-21 17:24:11 +02:00
|
|
|
keysInitialized = true;
|
|
|
|
|
} finally {
|
|
|
|
|
keyInitLock.unlock();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-04-23 13:36:46 +02:00
|
|
|
* Validates RSA key size >= {@link Constants#MIN_RSA_KEY_SIZE} bits.
|
2026-04-21 17:24:11 +02:00
|
|
|
*/
|
|
|
|
|
public void validateRsaKey(PrivateKey key) throws SecurityError {
|
|
|
|
|
if (key == null) {
|
|
|
|
|
throw new SecurityError("RSA key is null");
|
|
|
|
|
}
|
2026-04-21 18:00:31 +02:00
|
|
|
int keySize = extractKeySize(key);
|
2026-04-21 17:24:11 +02:00
|
|
|
|
|
|
|
|
if (keySize < Constants.MIN_RSA_KEY_SIZE) {
|
2026-04-23 13:36:46 +02:00
|
|
|
throw new SecurityError("RSA key size " + keySize + " bits is below minimum " + Constants.MIN_RSA_KEY_SIZE + " bits");
|
2026-04-21 17:24:11 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-21 18:00:31 +02:00
|
|
|
private int extractKeySize(PrivateKey key) {
|
|
|
|
|
try {
|
2026-04-23 13:36:46 +02:00
|
|
|
var kf = KeyFactory.getInstance("RSA");
|
2026-04-21 18:00:31 +02:00
|
|
|
try {
|
2026-04-23 19:22:01 +02:00
|
|
|
var crtSpec = kf.getKeySpec(key, RSAPrivateCrtKeySpec.class);
|
2026-04-23 13:36:46 +02:00
|
|
|
return crtSpec.getModulus().bitLength();
|
|
|
|
|
} catch (Exception ignored) {
|
2026-04-23 19:22:01 +02:00
|
|
|
var privSpec = kf.getKeySpec(key, RSAPrivateKeySpec.class);
|
2026-04-21 18:00:31 +02:00
|
|
|
return privSpec.getModulus().bitLength();
|
2026-04-23 13:36:46 +02:00
|
|
|
}
|
|
|
|
|
} catch (Exception ignored) {
|
|
|
|
|
if (key.getEncoded() != null) {
|
|
|
|
|
return key.getEncoded().length * 8;
|
2026-04-21 18:00:31 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-21 17:24:11 +02:00
|
|
|
/**
|
2026-04-23 13:36:46 +02:00
|
|
|
* Maps HTTP status code to exception (200→null).
|
2026-04-21 17:24:11 +02:00
|
|
|
*/
|
|
|
|
|
public Exception mapHttpStatus(int statusCode, String responseBody) {
|
2026-04-26 18:21:05 +02:00
|
|
|
String message = responseBody != null ? responseBody : "no body";
|
|
|
|
|
Map<String, Object> errorDetails = responseBody != null ? Map.of("response", responseBody) : Map.of();
|
2026-04-23 13:36:46 +02:00
|
|
|
return switch (statusCode) {
|
|
|
|
|
case 200 -> null;
|
2026-04-26 18:21:05 +02:00
|
|
|
case 400 -> new InvalidRequestError("Invalid request: " + message, statusCode, errorDetails);
|
|
|
|
|
case 401 -> new AuthenticationError("Authentication failed: " + message, statusCode, errorDetails);
|
|
|
|
|
case 403 -> new ForbiddenError("Access forbidden: " + message, statusCode, errorDetails);
|
|
|
|
|
case 404 -> new APIError("Not found: " + message, statusCode, errorDetails);
|
|
|
|
|
case 429 -> new RateLimitError("Rate limit exceeded: " + message, statusCode, errorDetails);
|
|
|
|
|
case 500 -> new ServerError("Internal server error: " + message, statusCode, errorDetails);
|
|
|
|
|
case 503 -> new ServiceUnavailableError("Service unavailable: " + message, statusCode, errorDetails);
|
|
|
|
|
case 502, 504 -> new APIError("Gateway error: " + message, statusCode, errorDetails);
|
|
|
|
|
default -> new APIError("Unexpected status " + statusCode + ": " + message, statusCode, errorDetails);
|
2026-04-23 13:36:46 +02:00
|
|
|
};
|
2026-04-21 17:24:11 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-04-23 13:36:46 +02:00
|
|
|
* URL-encodes PEM key for {@code X-Public-Key} header.
|
2026-04-21 17:24:11 +02:00
|
|
|
*/
|
|
|
|
|
public String urlEncodePublicKey(String pemKey) {
|
2026-04-23 19:22:01 +02:00
|
|
|
return URLEncoder.encode(pemKey, StandardCharsets.UTF_8);
|
2026-04-21 17:24:11 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-04-26 18:21:05 +02:00
|
|
|
* Closes the HTTP client and clears keys from memory.
|
2026-04-21 17:24:11 +02:00
|
|
|
*/
|
|
|
|
|
public void close() {
|
2026-04-26 18:21:05 +02:00
|
|
|
this.httpClient.close();
|
|
|
|
|
this.privateKey = null;
|
|
|
|
|
this.publicPemKey = null;
|
|
|
|
|
this.keysInitialized = false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Decrypts server response.
|
|
|
|
|
*/
|
|
|
|
|
public CompletableFuture<Map<String, Object>> decryptResponse(byte[] encryptedResponse, String payloadId) {
|
|
|
|
|
return CompletableFuture.supplyAsync(() -> {
|
|
|
|
|
if (encryptedResponse == null || encryptedResponse.length == 0) {
|
|
|
|
|
throw new CompletionException(new ValueError("Empty encrypted response"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
String jsonResponse;
|
|
|
|
|
try {
|
|
|
|
|
jsonResponse = new String(encryptedResponse, StandardCharsets.UTF_8);
|
|
|
|
|
} catch (Exception e) {
|
|
|
|
|
throw new CompletionException(new ValueError("Invalid encrypted package format: not valid UTF-8"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Gson gson = new Gson();
|
|
|
|
|
JsonObject packageJson;
|
|
|
|
|
try {
|
|
|
|
|
packageJson = JsonParser.parseString(jsonResponse).getAsJsonObject();
|
|
|
|
|
} catch (Exception e) {
|
|
|
|
|
throw new CompletionException(new ValueError("Invalid encrypted package format: malformed JSON"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Validate required fields
|
|
|
|
|
String[] requiredFields = {"version", "algorithm", "encrypted_payload", "encrypted_aes_key"};
|
|
|
|
|
for (String field : requiredFields) {
|
|
|
|
|
if (!packageJson.has(field)) {
|
|
|
|
|
throw new CompletionException(new ValueError("Missing required fields in encrypted package: " + field));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Validate version and algorithm
|
|
|
|
|
String version = packageJson.get("version").getAsString();
|
|
|
|
|
String algorithm = packageJson.get("algorithm").getAsString();
|
|
|
|
|
|
|
|
|
|
if (!Constants.PROTOCOL_VERSION.equals(version)) {
|
2026-04-29 15:14:24 +02:00
|
|
|
throw new CompletionException(new ValueError("Unsupported protocol version: '" + version + "'. Expected: '" + Constants.PROTOCOL_VERSION + "'"));
|
2026-04-26 18:21:05 +02:00
|
|
|
}
|
|
|
|
|
if (!Constants.HYBRID_ALGORITHM.equals(algorithm)) {
|
2026-04-29 15:14:24 +02:00
|
|
|
throw new CompletionException(new ValueError("Unsupported encryption algorithm: '" + algorithm + "'. Expected: '" + Constants.HYBRID_ALGORITHM + "'"));
|
2026-04-26 18:21:05 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Validate encrypted_payload structure
|
|
|
|
|
JsonObject encryptedPayload = packageJson.get("encrypted_payload").getAsJsonObject();
|
|
|
|
|
String[] payloadRequired = {"ciphertext", "nonce", "tag"};
|
|
|
|
|
for (String field : payloadRequired) {
|
|
|
|
|
if (!encryptedPayload.has(field)) {
|
|
|
|
|
throw new CompletionException(new ValueError("Missing fields in encrypted_payload: " + field));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Guard: private key must be initialized
|
|
|
|
|
if (this.privateKey == null) {
|
|
|
|
|
throw new CompletionException(new SecurityError("Private key not initialized. Call generateKeys() or loadKeys() first."));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// Decrypt AES key with private key
|
|
|
|
|
byte[] encryptedAESKey = Base64.getDecoder().decode(packageJson.get("encrypted_aes_key").getAsString());
|
2026-04-29 15:14:24 +02:00
|
|
|
|
|
|
|
|
OAEPParameterSpec oaepParams = new OAEPParameterSpec("SHA-256", "MGF1", new MGF1ParameterSpec("SHA-256"), // Must match server: SHA-256, NOT SHA-1
|
|
|
|
|
PSource.PSpecified.DEFAULT);
|
|
|
|
|
|
2026-04-26 18:21:05 +02:00
|
|
|
Cipher rsaCipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding");
|
2026-04-29 15:14:24 +02:00
|
|
|
rsaCipher.init(Cipher.DECRYPT_MODE, this.privateKey, oaepParams);
|
2026-04-26 18:21:05 +02:00
|
|
|
byte[] aesKeyBytes = rsaCipher.doFinal(encryptedAESKey);
|
|
|
|
|
SecretKeySpec aesKey = new SecretKeySpec(aesKeyBytes, "AES");
|
|
|
|
|
|
|
|
|
|
// Decrypt payload
|
|
|
|
|
byte[] ciphertext = Base64.getDecoder().decode(encryptedPayload.get("ciphertext").getAsString());
|
|
|
|
|
byte[] nonce = Base64.getDecoder().decode(encryptedPayload.get("nonce").getAsString());
|
|
|
|
|
byte[] tag = Base64.getDecoder().decode(encryptedPayload.get("tag").getAsString());
|
|
|
|
|
|
|
|
|
|
Cipher aesCipher = Cipher.getInstance("AES/GCM/NoPadding");
|
|
|
|
|
aesCipher.init(Cipher.DECRYPT_MODE, aesKey, new GCMParameterSpec(Constants.GCM_TAG_SIZE * 8, nonce));
|
2026-04-29 15:14:24 +02:00
|
|
|
|
2026-04-26 18:21:05 +02:00
|
|
|
// Combine ciphertext (without tag) and tag for decryption
|
|
|
|
|
byte[] ciphertextWithTag = new byte[ciphertext.length + tag.length];
|
|
|
|
|
System.arraycopy(ciphertext, 0, ciphertextWithTag, 0, ciphertext.length);
|
|
|
|
|
System.arraycopy(tag, 0, ciphertextWithTag, ciphertext.length, tag.length);
|
|
|
|
|
|
|
|
|
|
byte[] plaintextBytes = aesCipher.doFinal(ciphertextWithTag);
|
|
|
|
|
|
|
|
|
|
// Parse JSON response
|
|
|
|
|
Map<String, Object> response;
|
|
|
|
|
try {
|
|
|
|
|
Object parsed = gson.fromJson(new String(plaintextBytes, StandardCharsets.UTF_8), Object.class);
|
2026-04-29 15:14:24 +02:00
|
|
|
@SuppressWarnings("unchecked") Map<String, Object> resultMap = (Map<String, Object>) parsed;
|
2026-04-26 18:21:05 +02:00
|
|
|
response = resultMap != null ? resultMap : new HashMap<>();
|
|
|
|
|
} catch (Exception e) {
|
|
|
|
|
throw new CompletionException(new ValueError("Decrypted response is not valid JSON: " + e.getMessage()));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Add metadata
|
|
|
|
|
if (!response.containsKey("_metadata")) {
|
|
|
|
|
response.put("_metadata", new HashMap<String, Object>());
|
|
|
|
|
}
|
2026-04-29 15:14:24 +02:00
|
|
|
@SuppressWarnings("unchecked") Map<String, Object> metadata = (Map<String, Object>) response.get("_metadata");
|
2026-04-26 18:21:05 +02:00
|
|
|
metadata.put("payload_id", payloadId);
|
|
|
|
|
metadata.put("processed_at", packageJson.has("processed_at") ? packageJson.get("processed_at").getAsString() : null);
|
|
|
|
|
metadata.put("is_encrypted", true);
|
|
|
|
|
metadata.put("encryption_algorithm", Constants.HYBRID_ALGORITHM);
|
|
|
|
|
|
|
|
|
|
return response;
|
|
|
|
|
|
|
|
|
|
} catch (Exception e) {
|
2026-04-29 15:14:24 +02:00
|
|
|
throw new CompletionException(new SecurityError("Decryption failed: integrity check or authentication failed: " + e.getMessage(), e));
|
2026-04-26 18:21:05 +02:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Error class for invalid argument/value errors (maps to Python ValueError).
|
|
|
|
|
*/
|
|
|
|
|
public static class ValueError extends Exception {
|
|
|
|
|
public ValueError(String message) {
|
|
|
|
|
super(message);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public ValueError(String message, Throwable cause) {
|
|
|
|
|
super(message, cause);
|
|
|
|
|
}
|
2026-04-21 17:24:11 +02:00
|
|
|
}
|
|
|
|
|
}
|