package ai.nomyo;
import ai.nomyo.errors.*;
import ai.nomyo.util.PEMConverter;
import ai.nomyo.util.Pass2Key;
import lombok.Getter;
import javax.crypto.*;
import java.io.*;
import java.math.BigInteger;
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.*;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.RSAKeyGenParameterSpec;
import java.io.UnsupportedEncodingException;
import java.util.Arrays;
import java.util.Map;
import java.util.Scanner;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.locks.ReentrantLock;
/**
* Low-level secure completion client for the NOMYO API.
*
*
This class handles key management, hybrid encryption, HTTP communication
* with retry logic, and response decryption. It is the core of the NOMYO
* Java client and is used internally by {@link SecureChatCompletion}.
*
* Encryption Wire Format
* Encrypted payloads use hybrid encryption (AES-256-GCM + RSA-4096-OAEP-SHA256):
*
* - A per-request 256-bit AES key encrypts the payload via AES-256-GCM
* - The AES key is encrypted via RSA-4096-OAEP-SHA256 using the server's public key
* - The result is a JSON package with base64-encoded ciphertext, nonce, tag, and encrypted AES key
*
*
* Key Lifecycle
* Client RSA keys are generated on first use (if not loaded from disk) and
* reused across all subsequent calls until the client is discarded. Keys can
* be persisted to disk via {@link #generateKeys(boolean, String, String)} or
* loaded from disk via {@link #loadKeys(String, String, String)}.
*/
public class SecureCompletionClient {
// ── Instance Attributes ─────────────────────────────────────────
/**
* Base URL of the NOMYO router (trailing slash stripped).
*/
@Getter
private final String routerUrl;
/**
* Whether HTTP (non-HTTPS) URLs are permitted.
*/
@Getter
private final boolean allowHttp;
/**
* RSA key size in bits. Always {@link Constants#RSA_KEY_SIZE}.
*/
@Getter
private final int keySize;
/**
* Maximum number of retries for retryable errors.
*/
@Getter
private final int maxRetries;
/**
* Whether secure memory operations are active.
*/
@Getter
private final boolean useSecureMemory;
/**
* Lock for double-checked key initialization.
*/
private final ReentrantLock keyInitLock = new ReentrantLock();
/**
* RSA private key, or {@code null} if not yet loaded/generated.
*/
@Getter
private PrivateKey privateKey;
// ── Internal State ──────────────────────────────────────────────
/**
* PEM-encoded public key string, or {@code null} if not yet loaded/generated.
*/
@Getter
private String publicPemKey;
/**
* Whether keys have been initialized.
*/
private volatile boolean keysInitialized = false;
/**
* Constructs a {@code SecureCompletionClient} with default settings.
*/
public SecureCompletionClient() {
this(Constants.DEFAULT_BASE_URL, false, true, Constants.DEFAULT_MAX_RETRIES);
}
/**
* Constructs a {@code SecureCompletionClient} with the specified settings.
*
* @param routerUrl the NOMYO router base URL
* @param allowHttp whether to permit HTTP (non-HTTPS) URLs
* @param secureMemory whether to enable memory locking/zeroing
* @param maxRetries number of retries on retryable errors
*/
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;
}
// ── Key Management ──────────────────────────────────────────────
private static String getEncryptedPrivateKeyFromFile(String privateKeyPath) {
File myObj = new File(privateKeyPath);
StringBuilder builder = new StringBuilder();
try (Scanner myReader = new Scanner(myObj)) {
while (myReader.hasNextLine()) {
builder.append(myReader.nextLine());
}
} catch (FileNotFoundException e) {
throw new RuntimeException("Tried to load private key from disk but no file found" + e.getMessage());
}
return builder.toString();
}
/**
* Generates a new 4096-bit RSA key pair.
*
* The public exponent is fixed at 65537. The generated key pair
* is stored in memory. Use {@code saveToDir} to persist to disk.
*
* @param saveToFile whether to save the keys to disk
* @param keyDir directory to save keys (ignored if {@code saveToFile} is {@code false})
* @param password optional password to encrypt the private key file
*/
public void generateKeys(boolean saveToFile, String keyDir, String password) {
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);
if (saveToFile) {
File keyFolder = new File(keyDir);
if (!keyFolder.exists() && !keyFolder.mkdirs()) {
throw new IOException("Failed to create key directory: " + keyDir);
}
Path privateKeyPath = Path.of(keyDir, Constants.DEFAULT_PRIVATE_KEY_FILE);
if (!Files.exists(privateKeyPath)) {
Set filePermissions = PosixFilePermissions.fromString(Constants.PRIVATE_KEY_FILE_MODE);
Files.createFile(privateKeyPath, PosixFilePermissions.asFileAttribute(filePermissions));
try (FileWriter fileWriter = new FileWriter(privateKeyPath.toFile())) {
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 |
InvalidKeyException e) {
throw new RuntimeException(e);
}
}
fileWriter.write(privatePem);
fileWriter.flush();
}
}
Path publicKeyPath = Path.of(keyDir, Constants.DEFAULT_PUBLIC_KEY_FILE);
if (!Files.exists(publicKeyPath)) {
Set publicPermissions = PosixFilePermissions.fromString(Constants.PUBLIC_KEY_FILE_MODE);
Files.createFile(publicKeyPath, PosixFilePermissions.asFileAttribute(publicPermissions));
try (FileWriter fileWriter = new FileWriter(publicKeyPath.toFile())) {
fileWriter.write(publicPem);
fileWriter.flush();
}
}
}
this.privateKey = pair.getPrivate();
this.publicPemKey = publicPem;
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("RSA not available: " + e.getMessage(), e);
} catch (InvalidAlgorithmParameterException e) {
throw new RuntimeException(e);
} catch (IOException e) {
throw new RuntimeException("Failed to save keys: " + e.getMessage(), e);
}
}
/**
* Generates a new 4096-bit RSA key pair and saves to the default directory.
*
* @param saveToFile whether to save the keys to disk
*/
public void generateKeys(boolean saveToFile) {
generateKeys(saveToFile, Constants.DEFAULT_KEY_DIR, null);
}
/**
* Loads an RSA private key from disk.
*
* If {@code publicPemKeyPath} is {@code null}, the public key is
* derived from the loaded private key. Validates that the key size
* is at least {@link Constants#MIN_RSA_KEY_SIZE} bits.
*
* @param privateKeyPath path to the private key PEM file
* @param publicPemKeyPath optional path to the public key PEM file
* @param password optional password for the encrypted private key
*/
public void loadKeys(String privateKeyPath, String publicPemKeyPath, String password) {
if (password != null && !password.isEmpty()) {
String cipherText = getEncryptedPrivateKeyFromFile(privateKeyPath);
try {
cipherText = Pass2Key.decrypt("AES/GCM/NoPadding", cipherText, password);
} catch (NoSuchPaddingException | NoSuchAlgorithmException
| BadPaddingException | IllegalBlockSizeException | InvalidAlgorithmParameterException |
InvalidKeyException e) {
System.out.println("Wrong password!");
}
try {
this.privateKey = Pass2Key.convertStringToPrivateKey(cipherText);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
/**
* Loads an RSA private key from disk, deriving the public key.
*
* @param privateKeyPath path to the private key PEM file
* @param password optional password for the encrypted private key
*/
public void loadKeys(String privateKeyPath, String password) {
loadKeys(privateKeyPath, null, password);
}
// ── Server Key Fetching ─────────────────────────────────────────
/**
* Fetches the server's RSA public key from the PKI endpoint.
*
* Performs a GET request to {@code {routerUrl}/pki/public_key}
* and returns the server PEM public key as a string. Validates that
* the response parses as a valid PEM public key.
*
* @return the server's PEM-encoded public key string
* @throws SecurityError if the URL is not HTTPS and {@code allowHttp} is {@code false},
* or if the response does not contain a valid PEM public key
*/
public CompletableFuture fetchServerPublicKey() {
throw new UnsupportedOperationException("Not yet implemented");
}
// ── Encryption ──────────────────────────────────────────────────
/**
* Encrypts a payload dict using hybrid encryption.
*
* Serializes the payload to JSON, then encrypts it using:
*
* - A per-request 256-bit AES key (AES-256-GCM)
* - RSA-OAEP-SHA256 wrapping of the AES key with the server's public key
*
*
*
* @param payload the payload to encrypt (OpenAI-compatible chat parameters)
* @return raw encrypted bytes (JSON package serialized to bytes)
* @throws SecurityError if encryption fails or keys are not loaded
*/
public CompletableFuture encryptPayload(Map payload) {
throw new UnsupportedOperationException("Not yet implemented");
}
/**
* Core hybrid encryption routine.
*/
public CompletableFuture doEncrypt(byte[] payloadBytes, byte[] aesKey) {
throw new UnsupportedOperationException("Not yet implemented");
}
// ── Decryption ──────────────────────────────────────────────────
/**
* Decrypts a server response.
*/
public CompletableFuture